Files

1281 lines
48 KiB
Bash
Executable File

#!/bin/sh
# ai.sh — aish: AI shell agent.
#
# An AI agent with shell access. By default, context (log, config, prompt)
# is stored globally in ~/.aish/. Use --init to create a local .aish/ in
# the current directory for a customized, self-contained agent.
#
# Usage:
# ./ai.sh what is the capital of Japan
# One-shot: process message, save context, exit
# (multi-word messages need no quotes)
# ./ai.sh -a "goal" Autonomous: run until done or budget exhausted
# ./ai.sh -c find python files Command mode: LLM generates a shell command
# from the description, prints it, and runs it
# ./ai.sh -i Initialize a local .aish/ in the current dir
#
# Dependencies: curl, jq, coreutils (date, mkdir, chmod, head, cat, printf,
# mktemp, timeout, sync, basename, sleep, wc, tail, sed, grep).
# No build step.
# NOTE: intentionally NOT using `set -e`. This script has many intentional
# non-zero return paths (budget_check, api_call, agent_loop_run, the
# `[ test ] && action` override pattern in config_load). `set -e` would
# abort on those. Instead, errors are handled explicitly at every critical
# point: curl exit codes, jq parse fallbacks, api_call/budget_check return
# values checked in `if` context. This mirrors how the C code checks
# return values rather than relying on a global trap.
# =====================================================================
# Defaults (src/defaults.h)
# =====================================================================
# These are the built-in fallbacks. Any of these can be overridden by
# ~/.aish.conf (sourced below if present), ~/.aish/config.json (global),
# or .aish/config.json (local, if .aish/ exists in the current directory).
# DEFAULT_MODEL="glm-5.2"
DEFAULT_MODEL="gpt-5.4-mini"
# DEFAULT_MODEL="~google/gemini-flash-latest"
DEFAULT_API_BASE="https://api.ppq.ai"
DEFAULT_API_KEY=""
DEFAULT_MAX_COST="10.0" # USD
DEFAULT_MAX_STEPS="500"
DEFAULT_TIMEOUT="7200" # seconds, overall
DEFAULT_CMD_TIMEOUT="60" # seconds, per shell command
DEFAULT_MAX_OUTPUT_TOKENS="65536"
DEFAULT_TEMPERATURE="0.7"
# Load global user config if present (overrides defaults above).
# Create ~/.aish.conf with: DEFAULT_API_KEY="sk-..."
# See README.md for details.
[ -f "$HOME/.aish.conf" ] && . "$HOME/.aish.conf"
# Buffer limits (src/defaults.h:36)
MAX_STDOUT_CHARS=8000
MAX_STDERR_CHARS=4000
# API constants (src/defaults.h:54)
API_TIMEOUT_SECONDS=120
API_MAX_RETRIES=3
API_BACKOFF_BASE_SEC=5 # 5s, 10s, 15s
# Paths
AISH_DIR=".aish"
LOG_FILENAME="log.jsonl"
CONFIG_FILENAME="config.json"
PROMPT_FILENAME="system_prompt.txt"
SOCK_FILENAME="sock"
# Default system prompt (src/main.c:33 / prompts/default_system_prompt.txt)
DEFAULT_SYSTEM_PROMPT='You are an AI agent with shell access. You can run any command on this
system using the run_shell tool. You are operating in the current working
directory.
When given a task:
1. Reason about what needs to be done.
2. Use run_shell to explore the environment, read files, run commands.
3. Take action to complete the task.
4. When finished, put your final answer in your text response, then call
done() with a brief summary.
Your text response is what the user sees. Always include the complete answer
in your text response before calling done(). Do not leave the answer only in
command output — summarize the results in your own words.
Be concise in your reasoning. Use shell commands efficiently. If a command
fails, examine the error and try a different approach.
You have full access to the system. Be careful with destructive commands.'
# Tool descriptions (src/defaults.h:89)
TOOL_RUN_SHELL_DESC='Run a shell command on this system. Returns stdout, stderr, and exit code. Use this to run any program, read/write files, make network requests, use git, etc.'
TOOL_DONE_DESC='Signal that you have completed the task. Call this only when you are truly finished with the current request.'
# =====================================================================
# Paths (src/config.c:32)
# =====================================================================
CWD="$(pwd)"
LOCAL_DIR="$CWD/$AISH_DIR"
GLOBAL_DIR="$HOME/.aish"
# Local mode: .aish/ exists in the current directory.
# Global mode (default): use ~/.aish/ for all storage.
if [ -d "$LOCAL_DIR" ]; then
AISH_MODE="local"
DIR="$LOCAL_DIR"
else
AISH_MODE="global"
DIR="$GLOBAL_DIR"
fi
LOG_PATH="$DIR/$LOG_FILENAME"
CONFIG_PATH="$DIR/$CONFIG_FILENAME"
PROMPT_PATH="$DIR/$PROMPT_FILENAME"
SOCK_PATH="$DIR/$SOCK_FILENAME"
# =====================================================================
# Globals: config + runtime state
# =====================================================================
# Config fields (populated by config_load)
CFG_MODEL=""
CFG_API_BASE=""
CFG_MAX_COST=""
CFG_MAX_STEPS=""
CFG_TIMEOUT=""
CFG_CMD_TIMEOUT=""
CFG_MAX_OUTPUT_TOKENS=""
CFG_TEMPERATURE=""
# Runtime
MODE="standalone" # standalone | autonomous
MESSAGE=""
RESUME=1
API_KEY=""
# Budget (src/budget.c)
BUDGET_STEP=0
BUDGET_COST=0.0
BUDGET_START=0
# Conversation: a temp file holding one JSON message object per line.
# Assembled into an array with jq -s '.' when building the API request.
MESSAGES_FILE=""
# =====================================================================
# Usage (src/main.c:50)
# =====================================================================
usage() {
prog="$1"
cat >&2 <<EOF
Usage: $prog [OPTIONS] [MESSAGE]
An AI agent with shell access. Multi-word messages do not need quotes.
By default, context is stored globally in ~/.aish/. Use --init to create
a local .aish/ in the current directory for a self-contained agent.
Modes:
$prog what is the capital of Japan
One-shot: process message, save context, exit
$prog -a "goal" Autonomous: run until done or budget exhausted
$prog -c find python files modified this week
Command mode: LLM generates a shell command from
the description, prints it, and runs it
$prog -i Initialize a local .aish/ in the current directory
Options:
-c, --cmd Command mode (generate and run a shell command)
-a, --autonomous Run in autonomous mode (goal required)
-i, --init Create local .aish/ with default config + prompt, exit
-x, --clear Clear the log file and exit
-r, --reset Clear the log file and exit (alias for -x)
-t, --tail Tail (follow) the log file in real time
-v, --verbose Show status lines ([loaded], [step], [finished], etc.)
Default is quiet: only the response is printed.
-m, --model MODEL Override model from config
-k, --api-key KEY API key (or set PPQ_API_KEY env var)
-C, --max-cost USD Override max cost from config
-s, --max-steps N Override max steps from config
--timeout SECS Override overall timeout from config
-T, --cmd-timeout SECS Override per-command timeout from config
--resume Resume from existing log (default: yes)
-R, --no-resume Start fresh (ignore existing log)
-h, --help Show this help
Environment:
PPQ_API_KEY API key for ppq.ai
Storage:
~/.aish/ Global: config.json, system_prompt.txt, log.jsonl
.aish/ Local: same files, only if created with --init
Config priority: CLI flags > local .aish/config.json (if .aish/ exists)
> ~/.aish/config.json > ~/.aish.conf shell vars > built-in defaults.
EOF
}
# =====================================================================
# Arg parsing defaults (actual parsing happens in main flow below)
# =====================================================================
IS_AUTONOMOUS=0
CMD_MODE=0
INIT_MODE=0
CLEAR_MODE=0
TAIL_MODE=0
VERBOSE=0
MODEL_OVERRIDE=""
API_KEY_OVERRIDE=""
HAS_MAX_COST=0
HAS_MAX_STEPS=0
HAS_TIMEOUT=0
HAS_CMD_TIMEOUT=0
MAX_COST_OVERRIDE=""
MAX_STEPS_OVERRIDE=""
TIMEOUT_OVERRIDE=""
CMD_TIMEOUT_OVERRIDE=""
# =====================================================================
# Ensure data dir exists + default config + default prompt
# In global mode, creates ~/.aish/ if missing. In local mode, .aish/
# already exists (we checked at path resolution), so this just ensures
# the config and prompt files exist.
# =====================================================================
ensure_data_dir() {
mkdir -p "$DIR"
chmod 700 "$DIR" 2>/dev/null || true
# Default config.json (src/config.c:107)
if [ ! -f "$CONFIG_PATH" ]; then
jq -n \
--arg model "$DEFAULT_MODEL" \
--arg base "$DEFAULT_API_BASE" \
--argjson maxcost "$DEFAULT_MAX_COST" \
--argjson maxsteps "$DEFAULT_MAX_STEPS" \
--argjson timeout "$DEFAULT_TIMEOUT" \
--argjson cmdtimeout "$DEFAULT_CMD_TIMEOUT" \
--arg prompt "$PROMPT_FILENAME" \
--argjson maxtok "$DEFAULT_MAX_OUTPUT_TOKENS" \
--argjson temp "$DEFAULT_TEMPERATURE" \
'{model:$model, api_base:$base, max_cost:$maxcost, max_steps:$maxsteps,
timeout:$timeout, cmd_timeout:$cmdtimeout, system_prompt:$prompt,
max_output_tokens:$maxtok, temperature:$temp}' \
> "$CONFIG_PATH"
fi
# Default system_prompt.txt (src/config.c:122)
if [ ! -f "$PROMPT_PATH" ]; then
printf '%s\n' "$DEFAULT_SYSTEM_PROMPT" > "$PROMPT_PATH"
fi
}
# =====================================================================
# Config load (src/config.c:162)
# =====================================================================
config_load() {
# Read each field with jq, falling back to defaults (src/config.c:174)
CFG_MODEL="$(jq -r --arg d "$DEFAULT_MODEL" '.model // $d' "$CONFIG_PATH")"
CFG_API_BASE="$(jq -r --arg d "$DEFAULT_API_BASE" '.api_base // $d' "$CONFIG_PATH")"
CFG_MAX_COST="$(jq -r --argjson d "$DEFAULT_MAX_COST" '.max_cost // $d' "$CONFIG_PATH")"
CFG_MAX_STEPS="$(jq -r --argjson d "$DEFAULT_MAX_STEPS" '.max_steps // $d' "$CONFIG_PATH")"
CFG_TIMEOUT="$(jq -r --argjson d "$DEFAULT_TIMEOUT" '.timeout // $d' "$CONFIG_PATH")"
CFG_CMD_TIMEOUT="$(jq -r --argjson d "$DEFAULT_CMD_TIMEOUT" '.cmd_timeout // $d' "$CONFIG_PATH")"
CFG_MAX_OUTPUT_TOKENS="$(jq -r --argjson d "$DEFAULT_MAX_OUTPUT_TOKENS" '.max_output_tokens // $d' "$CONFIG_PATH")"
CFG_TEMPERATURE="$(jq -r --argjson d "$DEFAULT_TEMPERATURE" '.temperature // $d' "$CONFIG_PATH")"
# Apply CLI overrides (src/main.c:186)
[ -n "$MODEL_OVERRIDE" ] && CFG_MODEL="$MODEL_OVERRIDE"
[ "$HAS_MAX_COST" = "1" ] && CFG_MAX_COST="$MAX_COST_OVERRIDE"
[ "$HAS_MAX_STEPS" = "1" ] && CFG_MAX_STEPS="$MAX_STEPS_OVERRIDE"
[ "$HAS_TIMEOUT" = "1" ] && CFG_TIMEOUT="$TIMEOUT_OVERRIDE"
[ "$HAS_CMD_TIMEOUT" = "1" ] && CFG_CMD_TIMEOUT="$CMD_TIMEOUT_OVERRIDE"
}
# Load system prompt: per-project file if present, else default (src/main.c:201)
load_system_prompt() {
if [ -f "$PROMPT_PATH" ]; then
cat "$PROMPT_PATH"
else
printf '%s\n' "$DEFAULT_SYSTEM_PROMPT"
fi
}
# =====================================================================
# Logger (src/logger.c)
# =====================================================================
# Unix epoch seconds. Compact, timezone-free (epoch is universal), and
# reveals no time-of-day. Convert to a human-readable UTC time later with
# `date -u -d @1754237295` if needed.
log_timestamp() {
date +%s
}
# Append a pre-built JSON line and flush (src/logger.c:59 write_and_flush).
# Accepts the line either as $1 or via stdin (so callers can pipe jq output).
log_append() {
if [ -n "$1" ]; then
line="$1"
else
line="$(cat)"
fi
[ -z "$line" ] && return 0
printf '%s\n' "$line" >> "$LOG_PATH"
sync "$LOG_PATH" 2>/dev/null || true
}
# step entry (src/logger.c:83)
# Model response entry — logs what the model decided (reasoning, command to
# run, or done summary). One entry per API response. This is logged BEFORE
# any command execution (flush-before-act: if we crash mid-execution, the
# log shows the model's decision without a matching exec entry).
# Args: step mode model reasoning command done_summary tokens_in tokens_out step_cost cum_cost elapsed
log_response() {
step="$1"; mode="$2"; model="$3"; reasoning="$4"; command="$5"
done_summary="$6"; tokens_in="$7"; tokens_out="$8"
step_cost="$9"; cum_cost="${10}"; elapsed="${11}"
ts="$(log_timestamp)"
jq -nc \
--argjson ts "$ts" --argjson step "$step" --arg mode "$mode" --arg model "$model" \
--arg reasoning "$reasoning" --arg command "$command" --arg done_summary "$done_summary" \
--argjson tin "$tokens_in" --argjson tout "$tokens_out" \
--argjson scost "$step_cost" --argjson ccost "$cum_cost" \
--argjson el "$elapsed" --arg source "model" \
'{timestamp:$ts, source:$source, event:"response", step:$step, mode:$mode, model:$model,
reasoning:$reasoning, command:$command, done_summary:$done_summary,
tokens_in:$tin, tokens_out:$tout,
step_cost:$scost, cumulative_cost:$ccost, elapsed_seconds:$el}' \
| log_append
}
# Command execution entry — logs what aish did when it ran a command.
# One entry per run_shell execution. Logged AFTER execution completes.
# Args: step stdout stderr exit_code cum_cost
log_exec() {
step="$1"; out="$2"; err="$3"; exit_code="$4"; cum_cost="$5"
ts="$(log_timestamp)"
jq -nc \
--argjson ts "$ts" --argjson step "$step" --arg event "exec" \
--arg out "$out" --arg err "$err" --argjson ec "$exit_code" \
--argjson cc "$cum_cost" --arg source "aish" \
'{timestamp:$ts, source:$source, event:$event, step:$step,
command_output:$out, command_stderr:$err, exit_code:$ec,
cumulative_cost:$cc}' \
| log_append
}
# user_input entry (src/logger.c:150)
log_user_input() {
text="$1"
ts="$(log_timestamp)"
jq -nc --argjson ts "$ts" --arg event "user_input" --arg text "$text" \
--arg source "user" \
'{timestamp:$ts, source:$source, event:$event, text:$text}' | log_append
}
# generic event entry (src/logger.c:166)
# Args: event detail_key detail_value step cum_cost
log_event() {
event="$1"; dkey="$2"; dval="$3"; step="$4"; cum_cost="$5"
ts="$(log_timestamp)"
if [ -n "$dkey" ] && [ -n "$dval" ]; then
jq -nc --argjson ts "$ts" --arg event "$event" --arg dkey "$dkey" --arg dval "$dval" \
--argjson step "$step" --argjson cc "$cum_cost" --arg source "aish" \
'{timestamp:$ts, source:$source, event:$event, ($dkey):$dval, step:$step, cumulative_cost:$cc}' \
| log_append
else
jq -nc --argjson ts "$ts" --arg event "$event" \
--argjson step "$step" --argjson cc "$cum_cost" --arg source "aish" \
'{timestamp:$ts, source:$source, event:$event, step:$step, cumulative_cost:$cc}' \
| log_append
fi
}
# api_error entry (src/agent_loop.c:185)
# Args: step retries error
log_api_error() {
step="$1"; retries="$2"; error="$3"
ts="$(log_timestamp)"
jq -nc --argjson ts "$ts" --arg event "api_error" --argjson step "$step" \
--argjson retries "$retries" --arg error "$error" --arg source "aish" \
'{timestamp:$ts, source:$source, event:"api_error", step:$step, retries:$retries, error:$error}' \
| log_append
}
# =====================================================================
# Conversation helpers
# =====================================================================
# Messages are accumulated in $MESSAGES_FILE, one JSON object per line.
# The system prompt is the first message (src/resume.c:64).
conv_init() {
MESSAGES_FILE="$(mktemp)"
# system message
jq -nc --arg content "$1" '{role:"system", content:$content}' >> "$MESSAGES_FILE"
}
conv_append_user() {
jq -nc --arg content "$1" '{role:"user", content:$content}' >> "$MESSAGES_FILE"
}
conv_append_assistant_text() {
# assistant message with content only (src/agent_loop.c:262)
jq -nc --arg content "$1" '{role:"assistant", content:$content}' >> "$MESSAGES_FILE"
}
conv_append_assistant_with_tools() {
# assistant message with tool_calls (src/agent_loop.c:382)
# $1 = reasoning (may be empty), $2 = path to a file of tool_call JSON objects (one per line)
reasoning="$1"
tc_file="$2"
if [ -s "$tc_file" ]; then
tool_calls="$(jq -s '.' "$tc_file")"
jq -nc --arg content "$reasoning" --argjson tcs "$tool_calls" \
'{role:"assistant", content:$content, tool_calls:$tcs}' >> "$MESSAGES_FILE"
else
jq -nc --arg content "$reasoning" '{role:"assistant", content:$content}' >> "$MESSAGES_FILE"
fi
}
conv_append_tool() {
# tool-role result message (src/agent_loop.c:358)
# $1 = tool_call_id, $2 = tool name, $3 = result content (string)
jq -nc --arg id "$1" --arg name "$2" --arg content "$3" \
'{role:"tool", tool_call_id:$id, name:$name, content:$content}' >> "$MESSAGES_FILE"
}
# =====================================================================
# Resume (src/resume.c:57)
# =====================================================================
# Replay log.jsonl into the messages file. Drops incomplete steps.
# Sets RESUME_LAST_STEP and RESUME_CUM_COST.
RESUME_LAST_STEP=0
RESUME_CUM_COST=0.0
resume_from_log() {
system_prompt="$1"
conv_init "$system_prompt"
[ -f "$LOG_PATH" ] || return 0
last_complete_step=0
pending_command=""
pending_reasoning=""
pending_step=""
# Read line by line. Lines can be large (command output), so avoid
# `while read` word-splitting issues by using a temp file + jq.
while IFS= read -r line || [ -n "$line" ]; do
[ -z "$line" ] && continue
# Parse fields with jq. Use //empty to get empty string on null.
event="$(printf '%s' "$line" | jq -r '.event // empty' 2>/dev/null)" || continue
step="$(printf '%s' "$line" | jq -r '.step // 0' 2>/dev/null)" || step=0
cum="$(printf '%s' "$line" | jq -r '.cumulative_cost // 0' 2>/dev/null)" || cum=0
# Track last seen cumulative cost (src/resume.c:97)
if [ -n "$cum" ]; then
case "$cum" in
''|*[!0-9.]*) ;;
*) RESUME_CUM_COST="$cum" ;;
esac
fi
# user_input event — replay as user message
if [ "$event" = "user_input" ]; then
text="$(printf '%s' "$line" | jq -r '.text // empty')"
[ -n "$text" ] && conv_append_user "$text"
continue
fi
# exec event — command execution result from aish.
# Pair it with the preceding response entry that had a command.
if [ "$event" = "exec" ]; then
exec_output="$(printf '%s' "$line" | jq -r '.command_output // empty' 2>/dev/null)"
exec_stderr="$(printf '%s' "$line" | jq -r '.command_stderr // empty' 2>/dev/null)"
exec_exit="$(printf '%s' "$line" | jq -r '.exit_code // 0' 2>/dev/null)"
# If we have a pending response with a command, complete it
if [ -n "$pending_command" ]; then
call_id="call_${pending_step}"
tc_tmp="$(mktemp)"
jq -nc --arg id "$call_id" --arg cmd "$pending_command" \
'{type:"function", id:$id, function:{name:"run_shell", arguments:({"command":$cmd}|tojson)}}' \
>> "$tc_tmp"
conv_append_assistant_with_tools "$pending_reasoning" "$tc_tmp"
rm -f "$tc_tmp"
result_content="$(jq -nc --arg out "$exec_output" --arg err "$exec_stderr" \
--argjson ec "$exec_exit" \
'{stdout:$out, stderr:$err, exit_code:$ec}')"
conv_append_tool "$call_id" "run_shell" "$result_content"
last_complete_step="$pending_step"
pending_command=""
pending_reasoning=""
pending_step=""
fi
continue
fi
# api_error / budget events — not conversation content
if [ "$event" = "api_error" ] || [ "$event" = "cost budget exceeded" ] \
|| [ "$event" = "max steps reached" ] || [ "$event" = "timeout reached" ]; then
continue
fi
# response event — model's decision (reasoning, command, or done)
if [ "$event" = "response" ]; then
reasoning="$(printf '%s' "$line" | jq -r '.reasoning // empty' 2>/dev/null)" || reasoning=""
command="$(printf '%s' "$line" | jq -r '.command // empty' 2>/dev/null)" || command=""
done_summary="$(printf '%s' "$line" | jq -r '.done_summary // empty' 2>/dev/null)" || done_summary=""
if [ -n "$command" ]; then
# Model requested a command — stash and wait for exec entry
pending_command="$command"
pending_reasoning="$reasoning"
pending_step="$step"
elif [ -n "$done_summary" ]; then
# Model called done() — the response entry is the final answer.
# Don't replay done() as conversation; it ends the session.
last_complete_step="$step"
elif [ -n "$reasoning" ]; then
# Assistant text-only message (no command, no done)
conv_append_assistant_text "$reasoning"
last_complete_step="$step"
fi
continue
fi
# Legacy: old-format "done" and "step_result" events from prior logs
if [ "$event" = "done" ] || [ "$event" = "step_result" ]; then
continue
fi
done < "$LOG_PATH"
RESUME_LAST_STEP="$last_complete_step"
}
# =====================================================================
# Budget (src/budget.c)
# =====================================================================
budget_init() {
BUDGET_MAX_COST="$1"
BUDGET_MAX_STEPS="$2"
BUDGET_TIMEOUT="$3"
BUDGET_COST="$4" # seeded from resume
BUDGET_STEP="$5" # seeded from resume
BUDGET_START="$(date +%s)"
}
budget_elapsed() {
now="$(date +%s)"
echo $(( now - BUDGET_START ))
}
# Returns 0 (true) if budget ok, 1 (false) if exceeded; sets BUDGET_REASON.
# Order matches src/budget.c:31.
budget_check() {
# cost
if awk "BEGIN { exit !($BUDGET_COST >= $BUDGET_MAX_COST) }"; then
BUDGET_REASON="cost budget exceeded"
return 1
fi
# steps
if [ "$BUDGET_STEP" -ge "$BUDGET_MAX_STEPS" ]; then
BUDGET_REASON="max steps reached"
return 1
fi
# time
if [ "$BUDGET_TIMEOUT" -gt 0 ]; then
el="$(budget_elapsed)"
if [ "$el" -gt "$BUDGET_TIMEOUT" ]; then
BUDGET_REASON="timeout reached"
return 1
fi
fi
BUDGET_REASON=""
return 0
}
# Estimate cost for a step (src/budget.c:59). Args: model tokens_in tokens_out
budget_estimate_cost() {
model="$1"; tin="$2"; tout="$3"
case "$model" in
glm-5.2) in_rate=0.85; out_rate=2.68 ;;
gpt-5.3-codex) in_rate=1.85; out_rate=14.77 ;;
gpt-4o) in_rate=2.50; out_rate=10.00 ;;
gpt-4o-mini) in_rate=0.15; out_rate=0.60 ;;
claude-3.5-sonnet) in_rate=3.00; out_rate=15.00 ;;
*) in_rate=1.0; out_rate=5.0 ;;
esac
# cost = (tin*in + tout*out) / 1e6
awk "BEGIN { printf \"%.6f\", ($tin * $in_rate + $tout * $out_rate) / 1000000.0 }"
}
# =====================================================================
# Shell exec (src/shell_exec.c:39)
# =====================================================================
# Run a command with timeout, capture stdout/stderr/exit separately.
# Sets SH_OUT, SH_ERR, SH_EXIT. Truncates output. Handles empty + timeout.
# Args: command timeout_seconds
shell_exec() {
command="$1"
timeout_sec="$2"
[ "$timeout_sec" -le 0 ] 2>/dev/null && timeout_sec="$DEFAULT_CMD_TIMEOUT"
# Empty command (src/shell_exec.c:45)
if [ -z "$command" ]; then
SH_OUT=""
SH_ERR="[empty command]"
SH_EXIT=-1
return
fi
err_tmp="$(mktemp)"
# timeout kills the whole process group (src/shell_exec.c:117 killpg)
# --signal=KILL --kill-after=2 ensures the tree dies.
SH_OUT="$(timeout --signal=KILL --kill-after=2 "$timeout_sec" sh -c "$command" 2>"$err_tmp")"
SH_EXIT=$?
SH_ERR="$(cat "$err_tmp")"
rm -f "$err_tmp"
# timeout(1) exits 124 on timeout, 137 on KILL. Normalize (src/shell_exec.c:120)
if [ "$SH_EXIT" = "124" ] || [ "$SH_EXIT" = "137" ]; then
SH_EXIT=-1
if [ -z "$SH_ERR" ]; then
SH_ERR="[command timed out — process group killed]"
fi
fi
# Truncate stdout (src/shell_exec.c:183)
if [ -n "$SH_OUT" ]; then
out_len="$(printf '%s' "$SH_OUT" | wc -c)"
if [ "$out_len" -gt "$MAX_STDOUT_CHARS" ]; then
SH_OUT="$(printf '%s' "$SH_OUT" | head -c "$MAX_STDOUT_CHARS")
...[truncated at ${MAX_STDOUT_CHARS} chars]"
fi
fi
# Truncate stderr (src/shell_exec.c:199)
if [ -n "$SH_ERR" ]; then
err_len="$(printf '%s' "$SH_ERR" | wc -c)"
if [ "$err_len" -gt "$MAX_STDERR_CHARS" ]; then
SH_ERR="$(printf '%s' "$SH_ERR" | head -c "$MAX_STDERR_CHARS")
...[truncated at ${MAX_STDERR_CHARS} chars]"
fi
fi
}
# =====================================================================
# API request building (src/json_util.c)
# =====================================================================
# Build the tools array (constant). Matches src/json_util.c:12.
build_tools_json() {
jq -nc \
--arg rsdesc "$TOOL_RUN_SHELL_DESC" --arg ddesc "$TOOL_DONE_DESC" \
'[
{type:"function",
function:{name:"run_shell", description:$rsdesc,
parameters:{type:"object",
properties:{command:{type:"string",description:"The shell command to execute."},
timeout_seconds:{type:"integer",description:"Optional timeout for this command in seconds (default 60)."}},
required:["command"]}}},
{type:"function",
function:{name:"done", description:$ddesc,
parameters:{type:"object",
properties:{summary:{type:"string",description:"A brief summary of the outcome."}},
required:["summary"]}}}
]'
}
# Build the full request body (src/json_util.c:77).
# Args: model max_tokens temperature
build_request_body() {
model="$1"; max_tokens="$2"; temperature="$3"
tools_json="$(build_tools_json)"
messages_json="$(jq -s '.' "$MESSAGES_FILE")"
jq -nc \
--arg model "$model" --argjson messages "$messages_json" \
--argjson tools "$tools_json" --argjson maxtok "$max_tokens" \
--argjson temp "$temperature" \
'{model:$model, messages:$messages, tools:$tools,
tool_choice:"auto", temperature:$temp, max_tokens:$maxtok}'
}
# =====================================================================
# API client (src/api_client.c:93)
# =====================================================================
# Call the API with retry + backoff. Sets API_RESPONSE (body) on success.
# Returns 0 on success, 1 on failure. Logs api_error on final failure.
# Args: step (for logging)
api_call() {
step="$1"
# Strip trailing slash from api_base (src/api_client.c:110)
base="${CFG_API_BASE%/}"
url="${base}/chat/completions"
body="$(build_request_body "$CFG_MODEL" "$CFG_MAX_OUTPUT_TOKENS" "$CFG_TEMPERATURE")"
attempt=1
while [ "$attempt" -le "$API_MAX_RETRIES" ]; do
# curl: -sS silent but show errors, --max-time overall timeout,
# -w '\n%{http_code}' appends HTTP status as last line.
resp_with_code="$(printf '%s' "$body" | \
curl -sS -X POST "$url" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
--data-binary @- \
--max-time "$API_TIMEOUT_SECONDS" \
-w '\n%{http_code}' 2>&1)" || curl_rc=$?
curl_rc=${curl_rc:-0}
if [ "$curl_rc" != "0" ]; then
# curl-level failure (network, timeout) — retry
if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then
sleep "$(( API_BACKOFF_BASE_SEC * attempt ))"
attempt=$((attempt+1))
continue
fi
log_api_error "$step" "$API_MAX_RETRIES" "curl error: rc=$curl_rc"
return 1
fi
# Split body and status (last line is the http code)
http_code="$(printf '%s' "$resp_with_code" | tail -n1)"
resp_body="$(printf '%s' "$resp_with_code" | sed '$d')"
case "$http_code" in
2*)
# Check for an error object in the body — some providers
# return HTTP 200 with {"error":{...}} on rate limits etc.
err_msg="$(printf '%s' "$resp_body" | jq -r '.error.message // .error // empty' 2>/dev/null)"
if [ -n "$err_msg" ]; then
if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then
sleep "$(( API_BACKOFF_BASE_SEC * attempt ))"
attempt=$((attempt+1))
continue
fi
log_api_error "$step" "$API_MAX_RETRIES" "API error (HTTP $http_code): $err_msg"
return 1
fi
API_RESPONSE="$resp_body"
return 0
;;
*)
# Non-2xx — retry (mirrors C: retries on any non-2xx)
if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then
sleep "$(( API_BACKOFF_BASE_SEC * attempt ))"
attempt=$((attempt+1))
continue
fi
log_api_error "$step" "$API_MAX_RETRIES" "HTTP $http_code: $resp_body"
return 1
;;
esac
done
log_api_error "$step" "$API_MAX_RETRIES" "no attempts succeeded"
return 1
}
# =====================================================================
# Response parsing (src/json_util.c:128)
# =====================================================================
# Parse API_RESPONSE into globals:
# RESP_CONTENT, RESP_TOKENS_IN, RESP_TOKENS_OUT
# RESP_TOOL_CALLS_FILE — temp file with one tool_call object per line:
# {id, name, arguments} (arguments is the raw JSON string from the API)
# RESP_TOOL_CALL_COUNT
parse_response() {
RESP_CONTENT="$(printf '%s' "$API_RESPONSE" | jq -r '.choices[0].message.content // empty' 2>/dev/null)" || RESP_CONTENT=""
RESP_TOKENS_IN="$(printf '%s' "$API_RESPONSE" | jq -r '.usage.prompt_tokens // 0' 2>/dev/null)" || RESP_TOKENS_IN=0
RESP_TOKENS_OUT="$(printf '%s' "$API_RESPONSE" | jq -r '.usage.completion_tokens // 0' 2>/dev/null)" || RESP_TOKENS_OUT=0
RESP_TOOL_CALLS_FILE="$(mktemp)"
# Extract each tool call as {id, name, arguments} (src/json_util.c:154)
printf '%s' "$API_RESPONSE" | jq -c '.choices[0].message.tool_calls[]? |
{id:(.id // "call"), name:(.function.name // ""), arguments:(.function.arguments // "{}")}' \
> "$RESP_TOOL_CALLS_FILE" 2>/dev/null || true
RESP_TOOL_CALL_COUNT=0
if [ -s "$RESP_TOOL_CALLS_FILE" ]; then
RESP_TOOL_CALL_COUNT="$(wc -l < "$RESP_TOOL_CALLS_FILE")"
fi
}
# Get a string field from a tool call's arguments JSON. Args: arguments_json field
parse_arg_string() {
printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // empty' 2>/dev/null
}
# Get an int field from a tool call's arguments JSON. Args: arguments_json field fallback
parse_arg_int() {
val="$(printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // empty' 2>/dev/null)"
[ -z "$val" ] && echo "$3" || echo "$val"
}
# =====================================================================
# Display helpers (src/agent_loop.c:28)
# =====================================================================
# Truncate to 120 chars with ellipsis (src/agent_loop.c:32)
truncate_display() {
s="$1"
len="$(printf '%s' "$s" | wc -c)"
if [ "$len" -gt 120 ]; then
printf '%s' "$s" | head -c 117
printf '...'
else
printf '%s' "$s"
fi
}
# Print a status line to stderr only in --verbose mode. Status lines are
# diagnostics (loaded/step/budget/done/finished); the actual response goes
# to stdout via print_response so it can be piped cleanly.
status() {
[ "$VERBOSE" = "1" ] || return 0
printf '%s\n' "$*" >&2
}
print_step() {
step="$1"; command="$2"; reasoning="$3"
if [ -n "$command" ]; then
disp="$(truncate_display "$command")"
status "[step $step] \$ $disp"
elif [ -n "$reasoning" ]; then
disp="$(truncate_display "$reasoning")"
status "[step $step] (reasoning) $disp"
else
status "[step $step]"
fi
}
print_response() {
# The response is the real payload — always goes to stdout.
# Default (quiet): print it raw so output is clean.
# Verbose: prefix with [response] for consistency with other status lines.
[ -z "$1" ] && return 0
if [ "$VERBOSE" = "1" ]; then
printf '[response] %s\n' "$1"
else
printf '%s\n' "$1"
fi
}
# =====================================================================
# Agent loop (src/agent_loop.c:117)
# =====================================================================
agent_loop_run() {
result=1 # default failure
while true; do
# 1. Check budget (src/agent_loop.c:133)
if ! budget_check; then
status "[budget] ${BUDGET_REASON:-limit reached} — stopping"
log_event "$BUDGET_REASON" "" "" "$BUDGET_STEP" "$BUDGET_COST"
result=0 # normal stop
break
fi
BUDGET_STEP=$((BUDGET_STEP + 1))
# 3-4. Call the API (src/agent_loop.c:175)
if ! api_call "$BUDGET_STEP"; then
status "[error] API call failed (see log)"
result=1
break
fi
# 4. Parse response (src/agent_loop.c:191)
parse_response
# Detect empty responses: no content and no tool calls.
# No hidden prompting — just report the error and stop.
if [ -z "$RESP_CONTENT" ] && [ "$RESP_TOOL_CALL_COUNT" -eq 0 ]; then
echo "[error] empty response from API (no content, no tool calls)" >&2
log_api_error "$BUDGET_STEP" 3 "empty response: $API_RESPONSE"
result=1
break
fi
# 5. Calculate cost (src/agent_loop.c:203)
step_cost="$(budget_estimate_cost "$CFG_MODEL" "$RESP_TOKENS_IN" "$RESP_TOKENS_OUT")"
BUDGET_COST="$(awk "BEGIN { printf \"%.6f\", $BUDGET_COST + $step_cost }")"
# 6. Scan tool calls to extract log_command and done_summary BEFORE
# logging. This lets us write one model entry that captures the full
# model response (reasoning + command + done summary) in a single
# log line. Flush-before-act: logged before any command execution.
log_command=""
pre_done_summary=""
if [ "$RESP_TOOL_CALL_COUNT" -gt 0 ]; then
idx=0
while [ "$idx" -lt "$RESP_TOOL_CALL_COUNT" ]; do
tc_line="$(sed -n "$((idx+1))p" "$RESP_TOOL_CALLS_FILE")"
tc_name="$(printf '%s' "$tc_line" | jq -r '.name // empty')"
tc_args="$(printf '%s' "$tc_line" | jq -r '.arguments // "{}"')"
if [ "$tc_name" = "run_shell" ] && [ -z "$log_command" ]; then
log_command="$(parse_arg_string "$tc_args" "command")"
fi
if [ "$tc_name" = "done" ]; then
pre_done_summary="$(parse_arg_string "$tc_args" "summary")"
fi
idx=$((idx+1))
done
fi
elapsed="$(budget_elapsed)"
log_response "$BUDGET_STEP" "$MODE" "$CFG_MODEL" "$RESP_CONTENT" "$log_command" \
"$pre_done_summary" "$RESP_TOKENS_IN" "$RESP_TOKENS_OUT" \
"$step_cost" "$BUDGET_COST" "$elapsed"
# 7. Print step info (src/agent_loop.c:244)
print_step "$BUDGET_STEP" "$log_command" "$RESP_CONTENT"
# 8. No tool calls — text response (src/agent_loop.c:259)
if [ "$RESP_TOOL_CALL_COUNT" -eq 0 ]; then
conv_append_assistant_text "$RESP_CONTENT"
print_response "$RESP_CONTENT"
rm -f "$RESP_TOOL_CALLS_FILE"
# Text response with no tool calls = done (both standalone and
# autonomous). No hidden prompting to continue.
result=0
break
fi
# 9. Process tool calls (src/agent_loop.c:290)
done_called=0
done_summary=""
tc_objs_file="$(mktemp)" # tool_call objects for the assistant message
idx=0
while [ "$idx" -lt "$RESP_TOOL_CALL_COUNT" ]; do
tc_line="$(sed -n "$((idx+1))p" "$RESP_TOOL_CALLS_FILE")"
tc_id="$(printf '%s' "$tc_line" | jq -r '.id // empty')"
tc_name="$(printf '%s' "$tc_line" | jq -r '.name // empty')"
tc_args="$(printf '%s' "$tc_line" | jq -r '.arguments // "{}"')"
[ -z "$tc_name" ] && { idx=$((idx+1)); continue; }
if [ "$tc_name" = "done" ]; then
done_called=1
done_summary="$(parse_arg_string "$tc_args" "summary")"
# Still add to conversation (src/agent_loop.c:309)
jq -nc --arg id "${tc_id:-done}" --arg args "$tc_args" \
'{type:"function", id:$id, function:{name:"done", arguments:$args}}' >> "$tc_objs_file"
idx=$((idx+1))
continue
fi
if [ "$tc_name" = "run_shell" ]; then
command="$(parse_arg_string "$tc_args" "command")"
cmd_timeout="$(parse_arg_int "$tc_args" "timeout_seconds" "$CFG_CMD_TIMEOUT")"
if [ -z "$command" ]; then
# Empty command (src/agent_loop.c:322)
jq -nc --arg id "${tc_id:-shell}" --arg args "$tc_args" \
'{type:"function", id:$id, function:{name:"run_shell", arguments:$args}}' >> "$tc_objs_file"
result_json="$(jq -nc --arg out "" --arg err "[empty command]" --argjson ec -1 \
'{stdout:$out, stderr:$err, exit_code:$ec}')"
conv_append_tool "${tc_id:-shell}" "run_shell" "$result_json"
idx=$((idx+1))
continue
fi
# Execute (src/agent_loop.c:338)
shell_exec "$command" "$cmd_timeout"
# Log execution result AFTER execution (flush-before-act)
log_exec "$BUDGET_STEP" "$SH_OUT" "$SH_ERR" "$SH_EXIT" "$BUDGET_COST"
# Add to conversation (src/agent_loop.c:347)
jq -nc --arg id "${tc_id:-shell}" --arg args "$tc_args" \
'{type:"function", id:$id, function:{name:"run_shell", arguments:$args}}' >> "$tc_objs_file"
result_json="$(jq -nc --arg out "$SH_OUT" --arg err "$SH_ERR" --argjson ec "$SH_EXIT" \
'{stdout:$out, stderr:$err, exit_code:$ec}')"
conv_append_tool "${tc_id:-shell}" "run_shell" "$result_json"
idx=$((idx+1))
continue
fi
# Unknown tool (src/agent_loop.c:367)
err_msg="unknown tool: $tc_name"
jq -nc --arg id "${tc_id:-unknown}" --arg name "$tc_name" --arg args "$tc_args" \
'{type:"function", id:$id, function:{name:$name, arguments:$args}}' >> "$tc_objs_file"
result_json="$(jq -nc --arg err "$err_msg" --argjson ec -1 \
'{stdout:"", stderr:$err, exit_code:$ec}')"
conv_append_tool "${tc_id:-unknown}" "$tc_name" "$result_json"
idx=$((idx+1))
done
# 11. Append assistant message with tool_calls (src/agent_loop.c:382)
conv_append_assistant_with_tools "$RESP_CONTENT" "$tc_objs_file"
rm -f "$tc_objs_file"
rm -f "$RESP_TOOL_CALLS_FILE"
# 12. done() handling — print the model's reasoning (the actual answer)
# to the screen. Fall back to done_summary only if reasoning is empty.
if [ "$done_called" = "1" ]; then
final_answer="$RESP_CONTENT"
[ -z "$final_answer" ] && final_answer="$done_summary"
[ -z "$final_answer" ] && final_answer="task complete"
print_response "$final_answer"
status "[done] $final_answer"
result=0
break
fi
done
return $result
}
# =====================================================================
# Main flow — runs when executed directly (./ai.sh ...).
# All functionality (ask + command-generation modes) is handled here;
# the script is no longer intended to be sourced.
# =====================================================================
# Arg parsing + mode detection
PROG_NAME="$(basename "$0")"
if [ "$PROG_NAME" = "aa" ]; then
echo "error: interactive server mode (aa) is not yet implemented" >&2
echo " use: $PROG_NAME \"message\" (one-shot)" >&2
exit 1
fi
i=1
while [ $i -le $# ]; do
eval "arg=\"\${$i}\""
case "$arg" in
--help|-h)
usage "$PROG_NAME"
exit 0
;;
--verbose|-v)
VERBOSE=1
i=$((i+1))
;;
--autonomous|-a)
IS_AUTONOMOUS=1
i=$((i+1))
;;
--cmd|-c)
CMD_MODE=1
i=$((i+1))
;;
--init|-i)
INIT_MODE=1
i=$((i+1))
;;
--clear|-x|--reset|-r)
CLEAR_MODE=1
i=$((i+1))
;;
--tail|-t)
TAIL_MODE=1
i=$((i+1))
;;
--model|-m)
i=$((i+1)); eval "MODEL_OVERRIDE=\"\${$i}\""; i=$((i+1))
;;
--api-key|-k)
i=$((i+1)); eval "API_KEY_OVERRIDE=\"\${$i}\""; i=$((i+1))
;;
--max-cost|-C)
i=$((i+1)); eval "MAX_COST_OVERRIDE=\"\${$i}\""
HAS_MAX_COST=1; i=$((i+1))
;;
--max-steps|-s)
i=$((i+1)); eval "MAX_STEPS_OVERRIDE=\"\${$i}\""
HAS_MAX_STEPS=1; i=$((i+1))
;;
--timeout)
i=$((i+1)); eval "TIMEOUT_OVERRIDE=\"\${$i}\""
HAS_TIMEOUT=1; i=$((i+1))
;;
--cmd-timeout|-T)
i=$((i+1)); eval "CMD_TIMEOUT_OVERRIDE=\"\${$i}\""
HAS_CMD_TIMEOUT=1; i=$((i+1))
;;
--resume)
RESUME=1; i=$((i+1))
;;
--no-resume|-R)
RESUME=0; i=$((i+1))
;;
--*)
echo "unknown option: $arg" >&2
usage "$PROG_NAME"
exit 1
;;
-*)
echo "unknown option: $arg" >&2
usage "$PROG_NAME"
exit 1
;;
*)
if [ -z "$MESSAGE" ]; then MESSAGE="$arg"; else MESSAGE="$MESSAGE $arg"; fi
i=$((i+1))
;;
esac
done
# --clear / --reset: delete the log file and exit.
if [ "$CLEAR_MODE" = "1" ]; then
if [ -f "$LOG_PATH" ]; then
rm -f "$LOG_PATH"
echo "Cleared $LOG_PATH"
else
echo "No log to clear ($LOG_PATH does not exist)"
fi
exit 0
fi
# --tail: follow the log file in real time.
if [ "$TAIL_MODE" = "1" ]; then
if [ -f "$LOG_PATH" ]; then
tail -f "$LOG_PATH"
else
echo "No log to tail ($LOG_PATH does not exist)" >&2
exit 1
fi
exit 0
fi
# --init: create local .aish/ with default config + prompt, then exit.
if [ "$INIT_MODE" = "1" ]; then
mkdir -p "$LOCAL_DIR" || { echo "error: failed to create $LOCAL_DIR" >&2; exit 1; }
chmod 700 "$LOCAL_DIR" 2>/dev/null || true
# Temporarily point paths to the local dir for ensure_data_dir
DIR="$LOCAL_DIR"
CONFIG_PATH="$DIR/$CONFIG_FILENAME"
PROMPT_PATH="$DIR/$PROMPT_FILENAME"
LOG_PATH="$DIR/$LOG_FILENAME"
SOCK_PATH="$DIR/$SOCK_FILENAME"
ensure_data_dir
echo "Initialized $LOCAL_DIR"
echo " Edit $CONFIG_PATH to change model, budget, etc."
echo " Edit $PROMPT_PATH to customize the agent's instructions."
exit 0
fi
# Read piped stdin if present (not a terminal). If args were also given,
# append the stdin content to the message. This enables:
# echo "explain this" | ./ai.sh
# cat error.log | ./ai.sh explain this error
# git diff | ./ai.sh -c generate a commit message
if [ ! -t 0 ]; then
STDIN_INPUT="$(cat)"
if [ -n "$STDIN_INPUT" ]; then
if [ -z "$MESSAGE" ]; then
MESSAGE="$STDIN_INPUT"
else
MESSAGE="$MESSAGE
$STDIN_INPUT"
fi
fi
fi
unset STDIN_INPUT
if [ -z "$MESSAGE" ]; then
echo "error: no message provided" >&2
usage "$0"
exit 1
fi
# --cmd mode: rewrite the message into a command-generation prompt and
# force a short, non-resuming run. The generated command is captured,
# printed to stderr in grey, and eval'd in this shell.
if [ "$CMD_MODE" = "1" ]; then
MESSAGE="Respond with only the shell command to accomplish this task. No explanation, no markdown, no backticks — just the raw command ready to paste into a terminal: $MESSAGE"
RESUME=0
MAX_STEPS_OVERRIDE=3
HAS_MAX_STEPS=1
fi
[ "$IS_AUTONOMOUS" = "1" ] && MODE="autonomous" || MODE="standalone"
# Ensure data dir exists (creates ~/.aish/ in global mode; no-op dir-wise
# in local mode since .aish/ already exists). Ensures config + prompt files.
if ! ensure_data_dir; then
echo "error: failed to create data directory ($DIR)" >&2
exit 1
fi
# Get API key — priority: --api-key flag > PPQ_API_KEY env > DEFAULT_API_KEY
API_KEY="$API_KEY_OVERRIDE"
if [ -z "$API_KEY" ]; then
API_KEY="${PPQ_API_KEY:-}"
fi
if [ -z "$API_KEY" ]; then
API_KEY="$DEFAULT_API_KEY"
fi
if [ -z "$API_KEY" ]; then
echo "error: no API key provided. Use --api-key or set PPQ_API_KEY" >&2
echo " (.aish/ has been set up — set the key and try again)" >&2
exit 1
fi
# Load config
config_load
# Load system prompt
SYSTEM_PROMPT="$(load_system_prompt)"
# Resume or fresh
if [ "$RESUME" = "1" ]; then
resume_from_log "$SYSTEM_PROMPT"
msg_count="$(wc -l < "$MESSAGES_FILE")"
if [ "$msg_count" -gt 1 ]; then
status "[loaded $LOG_PATH$msg_count messages, \$$RESUME_CUM_COST spent]"
fi
seed_step="$RESUME_LAST_STEP"
seed_cost="$RESUME_CUM_COST"
else
conv_init "$SYSTEM_PROMPT"
seed_step=0
seed_cost=0.0
fi
# Append the user's message
conv_append_user "$MESSAGE"
log_user_input "$MESSAGE"
# Initialize budget, seeded from resume
budget_init "$CFG_MAX_COST" "$CFG_MAX_STEPS" "$CFG_TIMEOUT" "$seed_cost" "$seed_step"
# Mode banner
if [ "$MODE" = "autonomous" ]; then
if [ "$(printf '%s' "$MESSAGE" | wc -c)" -gt 80 ]; then
status "[autonomous mode — goal: (long goal)]"
else
status "[autonomous mode — goal: $MESSAGE]"
fi
fi
# Run the agent loop. In --cmd mode, capture stdout (the generated command)
# while letting status messages go to stderr.
if [ "$CMD_MODE" = "1" ]; then
CMD_OUT="$(agent_loop_run 2>&1)"
loop_rc=$?
# Extract the last non-empty line as the command (status lines start with [).
CMD_GEN="$(printf '%s\n' "$CMD_OUT" | grep -v '^\[' | sed '/^$/d' | tail -n 1)"
if [ -z "$CMD_GEN" ]; then
echo "aishc: no command returned from LLM" >&2
rm -f "$MESSAGES_FILE"
exit 1
fi
printf '\033[2m$ %s\033[0m\n' "$CMD_GEN" >&2
eval "$CMD_GEN"
eval_rc=$?
rm -f "$MESSAGES_FILE"
exit $eval_rc
fi
agent_loop_run
loop_rc=$?
# Print summary
status ""
status "[finished — $BUDGET_STEP steps, \$$BUDGET_COST spent]"
# Cleanup
rm -f "$MESSAGES_FILE"
exit $loop_rc