# 550S — The Wandering Earth has 550W. Your terminal gets 550 lines of Shell. Powered by agent.sh.

> Source: <https://gist.github.com/lloydzhou/c675546c6395fb4ac4365c2f111f503e>
> Published: 2026-09-23 05:39:11+00:00

|  | #!/usr/bin/env bash | 
|  | # 550S — Shell. Single-file. Symlink. | 
|  | # | 
|  | # Built on the shell. | 
|  | # Delivered as one file. | 
|  | # Extended with symlinks. | 
|  | # | 
|  | # Source: https://github.com/lloydzhou/agent.sh | 
|  | # License: MIT — see LICENSE in the source repository. | 
|  | # agent.sh — filesystem-first AI agent in pure bash/awk | 
|  | # Chat Completions only; dependencies: bash, curl, awk. | 
|  | # Auto-create: AGENTS.md (rules), .agents/tools/ (executables), .agents/conv.jsonl (history). | 
|  |  | 
|  | set -uo pipefail | 
|  |  | 
|  | MODEL="${MODEL:-gpt-5.6-luna}" | 
|  | API_KEY="${OPENAI_API_KEY:-}" | 
|  | API_URL="${OPENAI_BASE_URL:-https://api.openai.com/v1}/chat/completions" | 
|  | AGENT_DIR="${AGENT_DIR:-$PWD/.agents}" | 
|  | TOOLS_DIR="$AGENT_DIR/tools" | 
|  | CONV_FILE="$AGENT_DIR/conv.jsonl" | 
|  | MAX_TURNS="${MAX_TURNS:-50}" | 
|  | TOOL_TIMEOUT_SECS="${TOOL_TIMEOUT_SECS:-60}" | 
|  | TOOL_RESULT_MAX_BYTES="${TOOL_RESULT_MAX_BYTES:-100000}" | 
|  | USER_INPUT="" | 
|  |  | 
|  | TOOL_DEFS_JSON="" | 
|  | DISPLAY_LAST_CHAR=$'\n' | 
|  | PREV_WAS_REASONING=false | 
|  | INTERRUPT_REQUESTED=false | 
|  |  | 
|  | # ================= inline awk program ================= | 
|  | # Quoted heredoc preserves backslashes; read returns nonzero at EOF. | 
|  | IFS= read -r -d '' AWK_PROGRAM <<'AWK_PROGRAM' \|\| true | 
|  | # Well-formed JSON: direct fields, raw array elements, decoded strings. | 
|  |  | 
|  | # Read one JSON value, honoring strings and nested containers. | 
|  | function jread_at(s, i,    start, c, depth, quoted, escaped) { | 
|  | while (substr(s, i, 1) ~ /[ \t\r\n]/) i++ | 
|  | start = i | 
|  | for (; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | if (quoted) { | 
|  | if (escaped) escaped = 0 | 
|  | else if (c == "\\") escaped = 1 | 
|  | else if (c == "\"") { | 
|  | quoted = 0 | 
|  | if (!depth) return substr(s, start, i - start + 1) | 
|  | } | 
|  | } else { | 
|  | if (!depth && c ~ /[,}\] \t\r\n]/) break | 
|  | if (c == "\"") quoted = 1 | 
|  | else if (c == "{" \|\| c == "[") depth++ | 
|  | else if (c == "}" \|\| c == "]") { | 
|  | if (--depth == 0) return substr(s, start, i - start + 1) | 
|  | } | 
|  | } | 
|  | } | 
|  | return substr(s, start, i - start) | 
|  | } | 
|  |  | 
|  | function jfield(s, key,    i, k, v) { | 
|  | sub(/^[ \t\r\n]+/, "", s) | 
|  | if (substr(s, 1, 1) != "{") return "" | 
|  | for (i = 2; i < length(s);) { | 
|  | while (substr(s, i, 1) ~ /[, \t\r\n]/) i++ | 
|  | k = jread_at(s, i); i += length(k) | 
|  | while (substr(s, i, 1) ~ /[: \t\r\n]/) i++ | 
|  | v = jread_at(s, i) | 
|  | if (k == "\"" key "\"") return v | 
|  | if (v == "") break | 
|  | i += length(v) | 
|  | } | 
|  | return "" | 
|  | } | 
|  |  | 
|  | function jstr(s, key,    v) { | 
|  | v = jfield(s, key) | 
|  | return substr(v, 1, 1) == "\"" ? junesc(substr(v, 2, length(v) - 2)) : "" | 
|  | } | 
|  |  | 
|  | function jsplit(s, out,    i, v, n) { | 
|  | split("", out) | 
|  | sub(/^[ \t\r\n]+/, "", s) | 
|  | if (substr(s, 1, 1) != "[") return 0 | 
|  | for (i = 2; i < length(s);) { | 
|  | while (substr(s, i, 1) ~ /[, \t\r\n]/) i++ | 
|  | v = jread_at(s, i) | 
|  | if (v == "") break | 
|  | out[++n] = v; i += length(v) | 
|  | } | 
|  | return n + 0 | 
|  | } | 
|  |  | 
|  | # ---------- escape / unescape ---------- | 
|  | function jescape(s,    out, i, c, code) { | 
|  | for (i = 1; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | if ((code = index("\"\\\b\f\n\r\t", c)) > 0) | 
|  | out = out "\\" substr("\"\\bfnrt", code, 1) | 
|  | else if ((code = index("\001\002\003\004\005\006\007\010\011\012\013\014\015\016\017\020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037", c)) > 0) | 
|  | out = out sprintf("\\u%04x", code) | 
|  | else out = out c | 
|  | } | 
|  | return out | 
|  | } | 
|  |  | 
|  | function junesc(s,    out, i, c, pos, hex, cp, lo) { | 
|  | for (i = 1; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | if (c != "\\") { out = out c; continue } | 
|  | if (++i > length(s)) break | 
|  | c = substr(s, i, 1) | 
|  | if (c != "u") { | 
|  | pos = index("bfnrt\"\\/", c) | 
|  | out = out (pos ? substr("\b\f\n\r\t\"\\/", pos, 1) : c) | 
|  | continue | 
|  | } | 
|  | hex = substr(s, i + 1, 4) | 
|  | cp = hex_to_int(hex) | 
|  | if (length(hex) < 4 \|\| cp < 0) { out = out "\\u"; continue } | 
|  | if (cp >= 55296 && cp <= 56319 && i + 10 <= length(s) && substr(s, i + 5, 2) == "\\u") { | 
|  | lo = hex_to_int(substr(s, i + 7, 4)) | 
|  | if (lo >= 56320 && lo <= 57343) { | 
|  | out = out utf8(65536 + (cp - 55296) * 1024 + lo - 56320) | 
|  | i += 10; continue | 
|  | } | 
|  | } | 
|  | out = out (cp >= 55296 && cp <= 57343 ? "\\u" hex : utf8(cp)) | 
|  | i += 4 | 
|  | } | 
|  | return out | 
|  | } | 
|  |  | 
|  | function hex_to_int(hex,    i, d, value) { | 
|  | for (i = 1; i <= length(hex); i++) { | 
|  | d = index("0123456789abcdef", tolower(substr(hex, i, 1))) - 1 | 
|  | if (d < 0) return -1 | 
|  | value = value * 16 + d | 
|  | } | 
|  | return value + 0 | 
|  | } | 
|  |  | 
|  | function utf8(cp) { | 
|  | # Decoding runs under LC_ALL=C; build UTF-8 bytes explicitly. | 
|  | if (cp <= 127) return sprintf("%c", cp) | 
|  | if (cp <= 2047) return sprintf("%c%c", 192 + int(cp / 64), 128 + (cp % 64)) | 
|  | if (cp <= 65535) return sprintf("%c%c%c", 224 + int(cp / 4096), 128 + int((cp % 4096) / 64), 128 + (cp % 64)) | 
|  | return sprintf("%c%c%c%c", 240 + int(cp / 262144), 128 + int((cp % 262144) / 4096), 128 + int((cp % 4096) / 64), 128 + (cp % 64)) | 
|  | } | 
|  |  | 
|  | # ---------- CLI ---------- | 
|  | BEGIN { | 
|  | if (json_mode == "escape_string") { | 
|  | if ((getline json_input) < 0) json_input = "" | 
|  | else while ((getline _line) > 0) json_input = json_input "\n" _line | 
|  | printf "%s", jescape(substr(json_input, 1, length(json_input) - 1)) | 
|  | exit 0 | 
|  | } | 
|  | } | 
|  | # SSE -> TSV; tool_calls: name, id, call JSON, argv prefixed with '=' to preserve empty args. | 
|  |  | 
|  | function esc(s,    out, i, c) { | 
|  | for (i = 1; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | out = out (c == "\\" ? "\\\\" : c == "\t" ? "\\t" : c == "\n" ? "\\n" : c == "\r" ? "\\r" : c) | 
|  | } | 
|  | return out | 
|  | } | 
|  |  | 
|  | BEGIN { _reset() } | 
|  |  | 
|  | /^error\t/ { print "error\t" esc(substr($0, 7)); fflush(); done = 1; exit 0 } | 
|  | /^retry$/ { _reset(); print "retry"; fflush(); next } | 
|  | /^data:  $$ DONE $$ / { | 
|  | _emit_tools() | 
|  | if (pending_stop != "") print "finish_reason\t" pending_stop | 
|  | fflush(); done = 1; next | 
|  | } | 
|  | /^data: / { | 
|  | jsplit(jfield(substr($0, 7), "choices"), choices) | 
|  | choice = choices[1] | 
|  | delta = jfield(choice, "delta") | 
|  | fr = jstr(choice, "finish_reason") | 
|  | if (fr != "") pending_stop = fr | 
|  | content = jstr(delta, "content") | 
|  | if (content != "") print "content\t" esc(content) | 
|  | reasoning = jstr(delta, "reasoning_content") | 
|  | if (reasoning != "") print "reasoning\t" esc(reasoning) | 
|  | _parse_tool_calls(jfield(delta, "tool_calls")) | 
|  | if (fr == "tool_calls") _emit_tools() | 
|  | fflush() | 
|  | } | 
|  | END { | 
|  | if (json_mode != "escape_string" && !done && pending_stop == "") print "error\tStream interrupted (no [DONE] received)" | 
|  | } | 
|  |  | 
|  | function _reset() { | 
|  | done = 0; pending_stop = ""; tc_max = -1 | 
|  | split("", tc_name); split("", tc_id); split("", tc_args) | 
|  | } | 
|  |  | 
|  | function _parse_tool_calls(json,    n, p, tc, idx, f, id) { | 
|  | n = jsplit(json, chunks) | 
|  | for (p = 1; p <= n; p++) { | 
|  | tc = chunks[p] | 
|  | idx = jfield(tc, "index") | 
|  | if (idx !~ /^[0-9]+$/) continue | 
|  | idx += 0 | 
|  | if (idx > tc_max) tc_max = idx | 
|  | f = jfield(tc, "function") | 
|  | id = jstr(tc, "id") | 
|  | if (id != "") tc_id[idx] = id | 
|  | tc_name[idx] = tc_name[idx] jstr(f, "name") | 
|  | tc_args[idx] = tc_args[idx] jstr(f, "arguments") | 
|  | } | 
|  | } | 
|  |  | 
|  | function _emit_tools(    idx, n, i, call, line) { | 
|  | for (idx = 0; idx <= tc_max; idx++) { | 
|  | if (tc_args[idx] == "") continue | 
|  | # Preserve the arguments JSON text; decode only the execution copy. | 
|  | call = "{\"id\":\"" jescape(tc_id[idx]) "\",\"type\":\"function\",\"function\":{\"name\":\"" jescape(tc_name[idx]) "\",\"arguments\":\"" jescape(tc_args[idx]) "\"}}" | 
|  | line = "tool_calls\t" esc(tc_name[idx]) "\t" esc(tc_id[idx]) "\t" esc(call) | 
|  | n = jsplit(jfield(tc_args[idx], "args"), argv) | 
|  | for (i = 1; i <= n; i++) line = line "\t=" esc(junesc(substr(argv[i], 2, length(argv[i]) - 2))) | 
|  | print line | 
|  | tc_args[idx] = "" | 
|  | } | 
|  | fflush() | 
|  | } | 
|  | AWK_PROGRAM | 
|  |  | 
|  | # ================= utils ================= | 
|  | util_awk_run() { LC_ALL=C LANG=C awk "$@"; } | 
|  |  | 
|  | util_unescape() { | 
|  | # Decode line-protocol fields into REPLY without stripping trailing newlines. | 
|  | local s="$1" c | 
|  | REPLY="" | 
|  | while [[ "$s" == *\\* ]]; do | 
|  | REPLY+="${s%%\\*}"; s="${s#*\\}" | 
|  | c="${s:0:1}"; s="${s:1}" | 
|  | case "$c" in n) REPLY+=$'\n' ;; t) REPLY+=$'\t' ;; r) REPLY+=$'\r' ;; *) REPLY+="$c" ;; esac | 
|  | done | 
|  | REPLY+="$s" | 
|  | } | 
|  |  | 
|  | util_run_timeout() { | 
|  | local seconds="$1" pid watcher rc; shift | 
|  | "$@" <&0 2>&1 & pid=$! | 
|  | ( sleep "$seconds"; kill -TERM "$pid" 2>/dev/null ) >/dev/null 2>&1 & watcher=$! | 
|  | wait "$pid" 2>/dev/null; rc=$? | 
|  | kill "$watcher" 2>/dev/null | 
|  | return $(( rc == 143 ? 124 : rc )) | 
|  | } | 
|  |  | 
|  | util_die() { | 
|  | printf '\033[31mError: %s\033[0m\n' "$*" >&2 | 
|  | exit 1 | 
|  | } | 
|  |  | 
|  | util_json_escape() { | 
|  | # A final sentinel preserves trailing newlines through awk's record reader. | 
|  | printf '%s.' "${1:-}" \| util_awk_run -v json_mode=escape_string "$AWK_PROGRAM" | 
|  | } | 
|  |  | 
|  | # ================= system prompt ================= | 
|  | agent_build_prompt() { | 
|  | cat <<'PROMPT' | 
|  | You are agent.sh, a minimal filesystem-first agent running in a terminal. | 
|  |  | 
|  | Tools: | 
|  | - Tools are plain executables (binaries, symlinks, scripts) in the agent tools directory; the tool name is the file name. | 
|  | - Each call passes "args": an array of strings. Elements are handed to the command as separate arguments — like Docker CMD exec form. No shell expansion: quotes, spaces, and $ are literal. Pass [] when the tool takes no arguments. | 
|  | - Read each tool's description before calling it. | 
|  | - A tool returns stdout and stderr. Non-zero exit is prefixed "Error (exit N)" — read the message and self-correct on the next call. Long output is truncated; use head/tail/grep to narrow large reads. | 
|  | - Shell composition (pipes, $(...), globs) does not apply to args. If a "bash" tool exists, call it with ["-c", "<command line>"] for full shell features. | 
|  | - You may issue several independent tool calls in one turn. | 
|  | PROMPT | 
|  | local locale="${LC_ALL:-${LC_MESSAGES:-${LANG:-en_US}}}" lang_name skill name desc indexed="" | 
|  | locale="${locale%%.*}" | 
|  | case "$locale" in ""\|C\|C.*\|POSIX) locale="en_US" ;; esac | 
|  | case "$locale" in zh*) lang_name="Chinese" ;; *) lang_name="English" ;; esac | 
|  | printf 'Environment:\nlang: %s\npwd: %s\nhome: %s\nplatform: %s\nshell: %s\n' \ | 
|  | "$locale" "$PWD" "${HOME:-?}" "$(uname -s 2>/dev/null \|\| echo unknown)" "${SHELL:-unknown}" | 
|  | printf '\nBy default, use %s (%s) for output, including reasoning/thinking, unless user instructions specify otherwise. Code, commands, and file content remain as-is.\n' "$lang_name" "$locale" | 
|  | for skill in "$AGENT_DIR"/skills/*/SKILL.md; do | 
|  | [[ -f "$skill" ]] \|\| continue | 
|  | if [[ -z "$indexed" ]]; then | 
|  | printf '\n<skill-index>\nWhen a skill matches the task, read its SKILL.md using any suitable available tool (e.g. cat or bash); no dedicated skill tool is needed. If none is available, do not assume its contents. Resolve relative paths from the skill directory.\n' | 
|  | indexed=1 | 
|  | fi | 
|  | name="${skill%/SKILL.md}"; name="${name##*/}" | 
|  | desc=$(awk ' | 
|  | { sub(/\r$/, "") } | 
|  | NR == 1 { if ($0 != "---") exit; next } | 
|  | /^---$/ { exit } | 
|  | /^description:[ \t]*/ { sub(/^description:[ \t]*/, ""); sub(/[ \t]+$/, ""); if ($0 ~ /^[\|>][-+0-9]*$/) exit; if ($0 ~ /^".*"$/ \|\| $0 ~ /^\047.*\047$/) $0 = substr($0, 2, length($0) - 2); print; exit } | 
|  | ' "$skill") | 
|  | printf -- '- %s%s\n  path: %s\n' "$name" "${desc:+: $desc}" "$skill" | 
|  | done | 
|  | [[ -n "$indexed" ]] && printf '</skill-index>\n' | 
|  | if [[ -f "$PWD/AGENTS.md" ]]; then | 
|  | printf '\nUser instructions:\n' | 
|  | cat "$PWD/AGENTS.md" | 
|  | fi | 
|  | } | 
|  |  | 
|  | # ================= tools (filesystem-discovered command templates) ================= | 
|  | tools_load() { | 
|  | TOOL_DEFS_JSON="" | 
|  | [[ -d "$TOOLS_DIR" ]] \|\| return 0 | 
|  | local f name desc | 
|  | for f in "$TOOLS_DIR"/*; do | 
|  | [[ -x "$f" ]] \|\| continue # tools are executables: binaries, symlinks, scripts | 
|  | name="${f##*/}" | 
|  | [[ "$name" =~ ^[A-Za-z0-9_-]{1,64}$ ]] \|\| { printf 'skip tool (bad name): %s\n' "$name" >&2; continue; } | 
|  | desc=$(tools_auto_desc "$f") | 
|  | [[ -n "$desc" ]] \|\| desc="Run $name" | 
|  | TOOL_DEFS_JSON+="${TOOL_DEFS_JSON:+,}"'{"type":"function","function":{"name":"'"$name"'","description":"'"$(util_json_escape "$desc")"'","parameters":{"type":"object","properties":{"args":{"type":"array","items":{"type":"string"},"description":"command-line arguments for '"$name"', passed as separate arguments (exec form); [] if none"}}}}}' | 
|  | done | 
|  | } | 
|  |  | 
|  | tools_auto_desc() { | 
|  | # Exact-name man match; fall back to --help with stdin=/dev/null and a timeout. | 
|  | local name="${1##*/}" d | 
|  | d=$(man -f "$name" 2>/dev/null \| awk -v name="$name" '$0 ~ "^" name "\$[0-9]+\$" { sub(/^[^(]*$[^)]*$[[:space:]]*-[[:space:]]*/, ""); print; exit }') | 
|  | [[ -z "$d" ]] && d=$(util_run_timeout 3 "$1" --help </dev/null 2>&1 \| grep -m1 -v '^[[:space:]]*$') | 
|  | printf '%s' "$d" | 
|  | } | 
|  |  | 
|  | tool_execute() { | 
|  | local name="$1" f="$TOOLS_DIR/$1" output rc; shift | 
|  | [[ -x "$f" ]] \|\| { printf 'Error: no such tool: %s' "$name"; return 0; } | 
|  | output=$(util_run_timeout "$TOOL_TIMEOUT_SECS" bash -c 'exec "$0" "$@"' "$f" "$@" 2>&1) | 
|  | rc=$? | 
|  | (( rc != 0 )) && output="Error (exit $rc): $output" | 
|  | if (( ${#output} > TOOL_RESULT_MAX_BYTES )); then | 
|  | local head=$(( (TOOL_RESULT_MAX_BYTES + 1) / 2 )) tail=$(( TOOL_RESULT_MAX_BYTES / 2 )) | 
|  | output="${output:0:head}"$'\n...[truncated]...\n'"${output: ${#output}-tail}" | 
|  | fi | 
|  | printf '%s' "$output" | 
|  | } | 
|  |  | 
|  | # ================= conversation (conv.jsonl: one OpenAI message per line) ================= | 
|  | conv_append() { printf '%s\n' "$1" >> "$CONV_FILE"; } | 
|  |  | 
|  | # ================= llm ================= | 
|  | llm_call() { | 
|  | local body='{"model":"'"$MODEL"'","messages":[{"role":"system","content":"'"$(util_json_escape "$(agent_build_prompt)")"'"}' | 
|  | body+="$(awk '{printf ",%s", $0}' "$CONV_FILE")"']' | 
|  | [[ -n "$TOOL_DEFS_JSON" ]] && body+=',"tools":['"$TOOL_DEFS_JSON"']' | 
|  | body+=',"stream":true}' | 
|  | curl -sS --no-buffer -D - --retry 2 --retry-delay 1 --retry-max-time 20 \ | 
|  | --connect-timeout 5 --speed-limit 1 --speed-time 60 \ | 
|  | -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ | 
|  | -d "$body" "$API_URL" 2>&1 \ | 
|  | \| util_awk_run ' | 
|  | { sub(/\r$/, "") } | 
|  | /^curl: / { print "error\t" $0; fflush(); exit 1 } | 
|  | /^HTTP\// { | 
|  | if (body) { print "retry"; fflush() } | 
|  | code = $2; body = 0; err = ""; next | 
|  | } | 
|  | /^[ \t]*$/ && !body { body = 1; next } | 
|  | !body { next } | 
|  | code >= 400 { err = err (err == "" ? "" : "\n") $0; next } | 
|  | { print; fflush() } | 
|  | END { | 
|  | if (code >= 400) { | 
|  | print "error\tHTTP " code ": " (err == "" ? "(empty)" : err) | 
|  | fflush() | 
|  | } | 
|  | } | 
|  | ' \ | 
|  | \| util_awk_run "$AWK_PROGRAM" | 
|  | } | 
|  |  | 
|  | # ================= display ================= | 
|  | display_ensure_newline() { | 
|  | if [[ "$DISPLAY_LAST_CHAR" != $'\n' ]]; then printf '\n'; DISPLAY_LAST_CHAR=$'\n'; fi | 
|  | return 0 | 
|  | } | 
|  |  | 
|  | display() { | 
|  | local kind="$1"; shift | 
|  | case "$kind" in | 
|  | content\|reasoning) | 
|  | [[ -n "$1" ]] \|\| return 0 | 
|  | if [[ "$kind" == reasoning ]]; then | 
|  | PREV_WAS_REASONING=true | 
|  | printf '\033[90m%s\033[0m' "$1" | 
|  | else | 
|  | [[ "$PREV_WAS_REASONING" == true ]] && printf '\n' | 
|  | PREV_WAS_REASONING=false | 
|  | printf '%s' "$1" | 
|  | fi | 
|  | DISPLAY_LAST_CHAR="${1: -1}"; return 0 ;; | 
|  | tool) display_ensure_newline; printf '\033[33m[tool] %s %s\033[0m\n' "$1" "${2:0:120}" ;; | 
|  | result) | 
|  | local first="${2%%$'\n'*}" | 
|  | printf '\033[90m[%s done] %s\033[0m\n' "$1" "${first:0:120}" ;; | 
|  | error) display_ensure_newline; printf '\033[31mError: %s\033[0m\n' "$1" >&2 ;; | 
|  | retry) printf '\033[90mretrying...\033[0m\n' >&2 ;; | 
|  | esac | 
|  | DISPLAY_LAST_CHAR=$'\n' | 
|  | } | 
|  |  | 
|  | # ================= agent loop ================= | 
|  | agent_loop() { | 
|  | local turn=0 stop="" text="" reasoning="" reasoning_field="" calls="" tool_messages="" _line _type _rest | 
|  | conv_append '{"role":"user","content":"'"$(util_json_escape "$1")"'"}' | 
|  | INTERRUPT_REQUESTED=false | 
|  | trap 'INTERRUPT_REQUESTED=true' INT | 
|  | while (( turn < MAX_TURNS )); do | 
|  | (( turn++ )) \|\| true | 
|  | stop=""; text=""; reasoning=""; calls=""; tool_messages="" | 
|  | exec 8< <(llm_call) | 
|  | while IFS= read -r _line <&8; do | 
|  | [[ "$INTERRUPT_REQUESTED" == true ]] && { stop="interrupted"; break; } | 
|  | _type="${_line%%$'\t'*}" | 
|  | _rest="${_line#*$'\t'}" | 
|  | case "$_type" in | 
|  | content) | 
|  | util_unescape "$_rest" | 
|  | display content "$REPLY" | 
|  | text+="$REPLY" ;; | 
|  | reasoning) | 
|  | util_unescape "$_rest"; display reasoning "$REPLY"; reasoning+="$REPLY" ;; | 
|  | tool_calls) | 
|  | # name, id, complete call JSON, then '='-prefixed argv fields. | 
|  | local _f=() _argv=() _name _id _call _output _i | 
|  | IFS=$'\t' read -r -a _f <<<"$_rest" | 
|  | util_unescape "${_f[0]}"; _name="$REPLY" | 
|  | util_unescape "${_f[1]}"; _id="$REPLY" | 
|  | util_unescape "${_f[2]}"; _call="$REPLY" | 
|  | for _i in ${_f[@]+"${_f[@]:3}"}; do | 
|  | util_unescape "${_i:1}"; _argv+=("$REPLY") | 
|  | done | 
|  | display tool "$_name" "${_argv[*]-}" | 
|  | _output=$(tool_execute "$_name" ${_argv[@]+"${_argv[@]}"}) | 
|  | display result "$_name" "$_output" | 
|  | calls+="${calls:+,}$_call" | 
|  | tool_messages+='{"role":"tool","tool_call_id":"'"$(util_json_escape "$_id")"'","content":"'"$(util_json_escape "$_output")"'"}'$'\n' ;; | 
|  | retry) | 
|  | display retry | 
|  | text=""; reasoning=""; calls=""; tool_messages="" ;; | 
|  | finish_reason) | 
|  | stop="$_rest" ;; | 
|  | error) | 
|  | util_unescape "$_rest"; display error "$REPLY" | 
|  | stop="error"; break ;; | 
|  | esac | 
|  | done | 
|  | exec 8<&- | 
|  | [[ "$stop" == "interrupted" \|\| "$stop" == "error" ]] && break | 
|  | # Persist this assistant turn, then continue only when tools were called | 
|  | reasoning_field="" | 
|  | [[ -n "$reasoning" ]] && reasoning_field=',"reasoning_content":"'"$(util_json_escape "$reasoning")"'"' | 
|  | if [[ -n "$calls" ]]; then | 
|  | local content_field="null" | 
|  | [[ -n "$text" ]] && content_field='"'"$(util_json_escape "$text")"'"' | 
|  | conv_append '{"role":"assistant","content":'"$content_field"',"tool_calls":['"$calls"']'"$reasoning_field"'}' | 
|  | printf '%s' "$tool_messages" >> "$CONV_FILE" | 
|  | [[ "$stop" == "tool_calls" ]] && continue | 
|  | else | 
|  | [[ -n "$text$reasoning" ]] && conv_append '{"role":"assistant","content":"'"$(util_json_escape "$text")"'"'"$reasoning_field"'}' | 
|  | fi | 
|  | break | 
|  | done | 
|  | trap - INT | 
|  | if (( turn >= MAX_TURNS )); then | 
|  | display error "Max turns ($MAX_TURNS) reached" | 
|  | return 1 | 
|  | fi | 
|  | [[ "$stop" == error ]] && return 1 | 
|  | return 0 | 
|  | } | 
|  |  | 
|  | # ================= interactive ================= | 
|  | interactive_mode() { | 
|  | local line | 
|  | printf '\033[36magent.sh (%s) — type exit or Ctrl+D to quit\033[0m\n' "$MODEL" | 
|  | while true; do | 
|  | if ! IFS= read -e -r -p $'\033[32m550S>\033[0m ' line; then printf '\n'; break; fi | 
|  | [[ "$line" == "exit" \|\| "$line" == "quit" ]] && break | 
|  | [[ -z "$line" ]] && continue | 
|  | history -s "$line" 2>/dev/null \|\| true | 
|  | agent_loop "$line" | 
|  | display_ensure_newline | 
|  | done | 
|  | printf '\033[36mGoodbye!\033[0m\n' | 
|  | } | 
|  |  | 
|  | # ================= cli ================= | 
|  | usage() { | 
|  | cat <<'EOF' | 
|  | agent.sh — filesystem-first AI agent (OpenAI Chat Completions) | 
|  |  | 
|  | Usage: | 
|  | agent.sh [prompt]          chat; no prompt + tty opens interactive REPL | 
|  | agent.sh < file            read prompt from stdin | 
|  | fresh conversation: mv -i .agents/conv.jsonl ".agents/conv-$(date +%Y%m%d-%H%M%S).jsonl" | 
|  |  | 
|  | Missing AGENTS.md, tools directory, and history file are created on startup. | 
|  |  | 
|  | Options: | 
|  | -m, --model NAME   model override (env MODEL) | 
|  | -h, --help         this help | 
|  |  | 
|  | Environment: | 
|  | OPENAI_API_KEY     required | 
|  | OPENAI_BASE_URL    default https://api.openai.com/v1 | 
|  | MODEL              default gpt-5.6-luna | 
|  | EOF | 
|  | } | 
|  |  | 
|  | parse_args() { | 
|  | while (( $# > 0 )); do | 
|  | case "$1" in | 
|  | -m\|--model) [[ -n "${2:-}" ]] \|\| util_die "$1 requires a model"; MODEL="$2"; shift 2 ;; | 
|  | -h\|--help) usage; exit 0 ;; | 
|  | -*) util_die "unknown option: $1 (see --help)" ;; | 
|  | *) [[ -n "$USER_INPUT" ]] && util_die "unexpected extra argument: $1" | 
|  | USER_INPUT="$1"; shift ;; | 
|  | esac | 
|  | done | 
|  | return 0 | 
|  | } | 
|  |  | 
|  | main() { | 
|  | parse_args "$@" | 
|  | [[ -n "$API_KEY" ]] \|\| util_die "OPENAI_API_KEY is not set" | 
|  | mkdir -p "$TOOLS_DIR" && touch "$CONV_FILE" \|\| util_die "Cannot initialize $AGENT_DIR" | 
|  | if [[ ! -e "$PWD/AGENTS.md" ]]; then | 
|  | printf '# Agent instructions\n\n' > "$PWD/AGENTS.md" \|\| util_die "Cannot create AGENTS.md" | 
|  | fi | 
|  | tools_load | 
|  | if [[ -z "$USER_INPUT" ]]; then | 
|  | if [[ -t 0 ]]; then interactive_mode; return $?; fi | 
|  | USER_INPUT=$(cat) | 
|  | fi | 
|  | agent_loop "$USER_INPUT" | 
|  | local rc=$? | 
|  | display_ensure_newline | 
|  | return "$rc" | 
|  | } | 
|  |  | 
|  | main "$@" |
