1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
|
#!/bin/bash
# Run one pi subagent, headless, to completion.
#
# run-agent.sh [options] <workdir> <provider> <model> <name>
#
# Options:
# --brief FILE task brief to hand the subagent (default: <workdir>/brief.md)
# --timeout SECS hard wall-clock limit (default: 1800)
# --thinking LEVEL off|minimal|low|medium|high|xhigh|max (default: high)
# --tools LIST pi tool allowlist, e.g. read,grep,find,ls for a subagent
# that must not run or change anything (default: pi's own)
# --cwd DIR directory the subagent runs in (default: the current one)
# --context-files let pi load AGENTS.md/CLAUDE.md from --cwd (default: off)
# --deliver TEXT what the final answer must be (default: "your complete
# result, as markdown")
#
# Writes, under <workdir>/out/:
# <name>.md the subagent's final answer: text of its last message
# <name>.cost.json tokens, seconds, exit status, USD cost from the provider
# <name>.jsonl the event stream without per-token deltas, for debugging
# <name>.err pi's stderr
# <name>.running exists only while the run is in flight; holds the PID
#
# A previous run under the same name is never overwritten: its files are moved
# to <name>.attempt<N>.* first, so a retry keeps the bill of the failure.
#
# Exit status is pi's, except that a run pi calls a success is turned into 1
# when its answer is empty or its last message ended in a provider error: pi
# exits 0 for both (an invalid API key, for one), and the cost file records
# the stop reason and error. 124 means the timeout fired -- the run is a
# write-off, but it still cost money; the cost file is written either way.
#
# Six mechanics here were each established the hard way. Do not "simplify"
# them away:
#
# 1. `--offline` is required. pi's startup model-catalog fetch intermittently
# hangs forever; --offline skips it. Symptom without it: the process
# produces no output at all, not even the session line, and never exits.
#
# 2. pi's stdout must be redirected to a FILE, never piped. A pipeline
# deadlocks: `timeout` kills pi, but the downstream reader never sees EOF
# and hangs past its own timeout. This is why filtering happens after the
# run, not inline.
#
# 3. The raw stream is filtered and deleted at once. Older pi re-emitted the
# entire message on every token delta, so it grew quadratically -- 790 MB
# and 3.1 GB on real runs. pi 1.x documents message_update as delta-only,
# but the stream is still mostly deltas nobody reads. Budget generous
# scratch per concurrent subagent until that is confirmed on the version
# in use.
#
# 4. Costs must be summed over every assistant message, not read off the last
# one. Each message carries only its own usage.
#
# 5. pi's stdin must be /dev/null. In -p mode pi also reads stdin when it is
# not a terminal, and waits for EOF. Started from a shell whose stdin never
# closes -- an agent's tool shell, a backgrounded job -- it hangs forever
# with no output at all, which looks exactly like the hang in (1).
#
# 6. Byte counts are stripped of whitespace before jq reads them. macOS `wc -c`
# pads its output (" 6"), jq's tonumber rejects that, and the whole
# cost file silently came out empty.
#
# `timeout` is GNU coreutils and macOS has none unless coreutils is installed,
# where it may be `gtimeout`. Without either, perl's alarm does the same job: it
# survives exec, and SIGALRM ends pi. That exit (142) is reported as 124.
#
# Extensions and skills are always off (-ne -ns): an extension can block on UI
# that a headless run has no way to answer, and the brief should say everything
# the subagent needs to know.
set -u
brief=""; limit=1800; thinking=high; tools=""; cwd=""; context=""
deliver="your complete result, as markdown"
while [[ $# -gt 0 ]]
do
case "$1" in
--brief) brief="$2"; shift 2 ;;
--timeout) limit="$2"; shift 2 ;;
--thinking) thinking="$2"; shift 2 ;;
--tools) tools="$2"; shift 2 ;;
--cwd) cwd="$2"; shift 2 ;;
--context-files) context=y; shift ;;
--deliver) deliver="$2"; shift 2 ;;
-h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
--) shift; break ;;
-*) echo "run-agent.sh: unknown option $1" >&2; exit 2 ;;
*) break ;;
esac
done
P="${1:?workdir}"; provider="${2:?provider}"; model="${3:?model}"; name="${4:?name}"
P="$(cd "$P" && pwd)" || exit 2
brief="${brief:-$P/brief.md}"
[[ "$brief" == /* ]] || brief="$PWD/$brief"
[[ -s "$brief" ]] || { echo "run-agent.sh: no brief at $brief" >&2; exit 2; }
out="$P/out"
mkdir -p "$out"
# Keep any earlier attempt, cost file included.
if [[ -e "$out/$name.cost.json" || -e "$out/$name.md" || -e "$out/$name.err" ]]
then
n=1
while [[ -e "$out/$name.attempt$n.cost.json" || -e "$out/$name.attempt$n.err" ]]
do
n=$((n + 1))
done
for ext in md cost.json jsonl err
do
[[ -e "$out/$name.$ext" ]] && mv "$out/$name.$ext" "$out/$name.attempt$n.$ext"
done
if [[ -e "$out/$name.attempt$n.cost.json" ]]
then
tmp="$(mktemp)"
jq --arg a "$name.attempt$n" '.name = $a' "$out/$name.attempt$n.cost.json" > "$tmp" \
&& mv "$tmp" "$out/$name.attempt$n.cost.json"
fi
fi
args=(--provider "$provider" --model "$model"
-p --no-session -ne -ns --offline
--thinking "$thinking" --mode json)
[[ -n "$context" ]] || args+=(-nc)
[[ -n "$tools" ]] && args+=(--tools "$tools")
if command -v timeout > /dev/null; then limiter=(timeout "$limit")
elif command -v gtimeout > /dev/null; then limiter=(gtimeout "$limit")
else limiter=(perl -e 'alarm shift; exec @ARGV or die "exec: $!\n"' "$limit")
fi
echo $$ > "$out/$name.running"
trap 'rm -f "$out/$name.running"' EXIT
start=$SECONDS
(
if [[ -n "$cwd" ]]; then cd "$cwd" || exit 2; fi
exec "${limiter[@]}" pi "${args[@]}" \
"Read the file $brief and do exactly what it says. Deliver $deliver as your final answer." \
< /dev/null > "$out/$name.raw" 2> "$out/$name.err"
)
rc=$?
[[ $rc -eq 142 && "${limiter[0]}" == perl ]] && rc=124
elapsed=$((SECONDS - start))
raw_bytes=$(wc -c < "$out/$name.raw" | tr -d '[:space:]')
grep -v '"type":"message_update"' "$out/$name.raw" > "$out/$name.jsonl"
rm -f "$out/$name.raw"
# The deliverable: text blocks of the last assistant message.
jq -rs '
[.[] | select(.type=="agent_end")] | last | .messages
| [.[] | select(.role=="assistant")] | last
| [.content[] | select(.type=="text") | .text] | join("")
' "$out/$name.jsonl" > "$out/$name.md" 2>/dev/null
# pi exits 0 when the provider refused the request, or the model said nothing.
stop=$(jq -rs '[.[] | select(.type=="message_end" and .message.role=="assistant")]
| last | .message.stopReason // "none"' "$out/$name.jsonl" 2>/dev/null)
if [[ $rc -eq 0 ]] && { [[ "$stop" == error || "$stop" == none ]] || ! grep -q '[^[:space:]]' "$out/$name.md"; }
then
rc=1
fi
jq -rs --arg name "$name" --arg model "$model" --arg provider "$provider" \
--arg rc "$rc" --arg elapsed "$elapsed" --arg raw "$raw_bytes" '
[.[] | select(.type=="message_end" and .message.role=="assistant") | .message] as $msgs
| ($msgs | last) as $last
| ($msgs | map(.usage))
| {name: $name, provider: $provider, model: $model, exit: ($rc|tonumber),
stopReason: ($last.stopReason // null),
error: ($last.errorMessage // null | if . then .[0:500] else . end),
seconds: ($elapsed|tonumber), turns: length,
input: (map(.input) | add // 0), output: (map(.output) | add // 0),
cacheRead: (map(.cacheRead) | add // 0),
cacheWrite: (map(.cacheWrite) | add // 0),
rawStreamBytes: ($raw|tonumber),
cost: (map(.cost.total) | add // 0)}
' "$out/$name.jsonl" > "$out/$name.cost.json" 2>/dev/null
printf '%-20s exit:%-4s stop:%-8s %5ss answer:%7s bytes cost:$%s\n' \
"$name" "$rc" "$stop" "$elapsed" "$(wc -c < "$out/$name.md" | tr -d '[:space:]')" \
"$(jq -r '.cost' "$out/$name.cost.json" 2>/dev/null)"
if [[ $rc -ne 0 ]]
then
jq -r '.error // empty' "$out/$name.cost.json" 2>/dev/null | head -c 300; echo
fi
exit $rc
|