crewforth 3.1.0-rc.2 → 3.1.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/kit/agents/crew-commit-agent.md +5 -2
- package/kit/eval/doctor.sh +26 -0
- package/kit/eval/smoke-test.sh +69 -2
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +36 -5
- package/kit/studio/server/index.js +18 -0
- package/kit/studio/server/lib/graph.js +110 -11
- package/kit/studio/server/lib/permissions.js +11 -3
- package/kit/studio/server/lib/pricing.js +63 -0
- package/kit/studio/server/lib/usage.js +144 -0
- package/kit/studio/web/app.js +158 -37
- package/kit/studio/web/approvals.js +20 -1
- package/kit/studio/web/canvas.js +95 -20
- package/kit/studio/web/chat.js +1 -0
- package/kit/studio/web/convo.js +4 -2
- package/kit/studio/web/dock.js +1 -0
- package/kit/studio/web/graph-plan.js +67 -12
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +14 -1
- package/kit/studio/web/list-plan.js +4 -4
- package/kit/studio/web/list.js +2 -0
- package/kit/studio/web/names.js +33 -0
- package/kit/studio/web/style.css +48 -10
- package/kit/studio/web/timeline-plan.js +5 -5
- package/kit/studio/web/timeline.js +10 -4
- package/kit/studio/web/usage.js +77 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -118,9 +118,39 @@ versioning follows [SemVer](https://semver.org/).
|
|
|
118
118
|
- **Studio: a layout for a narrow window.** Below 640 px the panel is one column: the navigator is a drawer, the
|
|
119
119
|
inspector and the conversation take the whole stage and stop above the dock. Checked in a narrow desktop browser
|
|
120
120
|
window; not measured on a phone.
|
|
121
|
+
- **Studio: what a session spent.** A summary box on the canvas and one line in the navigator give the session's
|
|
122
|
+
time, its tokens (agents included) and an estimate of the cost, such as `51m · 2.8M · ~$3.22`. The cost is an
|
|
123
|
+
estimate at API list price and reads as one (a leading `~`; on a subscription it is not what you are billed). The
|
|
124
|
+
prices are one table, read from the pricing page on 2026-10-08; a model with no row in it shows `—`, never a guess.
|
|
125
|
+
Not checked against an invoice.
|
|
126
|
+
- **Studio: background work is shown.** A Bash command sent to the background is listed on its session (`1 in
|
|
127
|
+
background` on the card, **In the background** in the inspector) until the notice that ends it arrives.
|
|
128
|
+
- **Studio: a question waiting in a terminal is named.** For a session started in a terminal, a permission question
|
|
129
|
+
can only be answered there; the view now says so, with the tool and the command: `Waiting for an answer in the
|
|
130
|
+
terminal: Bash · npm run build`.
|
|
131
|
+
- **Studio: the inspector can be resized** (drag its left edge, arrow keys, double-click for the default; 280 to
|
|
132
|
+
720 px, remembered).
|
|
121
133
|
|
|
122
134
|
### Changed
|
|
123
135
|
|
|
136
|
+
- **Studio: agents are named by their role.** `crew-backend-expert` reads Backend, `general-purpose` General Purpose,
|
|
137
|
+
in the graph, the Timeline, the List, the dock, the inspector and the conversation; the declared type is under the
|
|
138
|
+
name in the inspector and on hover. A task cut short on a card is said in full on hover.
|
|
139
|
+
- **Studio: a group of agents opens beside itself.** Several agents of one type are one card; in a window of 1024 px
|
|
140
|
+
and up, clicking it puts its agents in the next column as ordinary cards, and opening another group folds the
|
|
141
|
+
first. Below that width it opens downward as before.
|
|
142
|
+
- **In `auto` and `dontAsk`, a refused commit or push says what to ask and what to send back.** The refusal was a
|
|
143
|
+
paragraph with the approval text somewhere inside it, and the question the model made of it was as long. It now
|
|
144
|
+
asks for one shape: the commit message in one code block and, under it, one line with the exact text to send,
|
|
145
|
+
`approve: commit`, `approve: push` or `approve: commit+push` (`onay: …`), named for the call that was refused. A
|
|
146
|
+
sentence that says yes is still not an approval, and nothing about what an approval covers has changed.
|
|
147
|
+
- **Where the approval cannot be recorded, the refusal says so.** The record is written by `prompt-approval.sh`. In a
|
|
148
|
+
session whose settings do not wire it (a worktree that carries an older Crewforth, for one) the message
|
|
149
|
+
`approve: commit` recorded nothing, and the refusal went on asking for it. The gate now reads the settings it can
|
|
150
|
+
see (the plugin's `hooks.json`, the project's `settings.json` and `settings.local.json`, the user's
|
|
151
|
+
`settings.json`) and, when none wires the hook, says the approval path is closed in this session and names the
|
|
152
|
+
two ways that work: Shift+Tab, or your own terminal. The command is refused either way. `doctor.sh` warns about
|
|
153
|
+
the same thing, and about a worktree of the repository that carries another Crewforth version.
|
|
124
154
|
- **After a final release, npm's `next` no longer names something older than `latest`.** A final moved `latest`
|
|
125
155
|
only, so `npx crewforth@next` went on installing the rc before it. The release now moves `next` to the final when
|
|
126
156
|
what `next` names is older; an rc of a later version stays where it is.
|
|
@@ -205,6 +235,16 @@ versioning follows [SemVer](https://semver.org/).
|
|
|
205
235
|
the same (measured on Windows, in a real session), and the conversation says who refused it. Known limit: the dock
|
|
206
236
|
can ask about a call that another gate refuses anyway.
|
|
207
237
|
|
|
238
|
+
### Fixed
|
|
239
|
+
|
|
240
|
+
- **Studio: full screen keeps its panels.** In full screen the navigator, the inspector and the conversation could
|
|
241
|
+
not be opened, so a selected agent showed nothing and a request under "Needs you" could not be answered. A style
|
|
242
|
+
rule hid every panel there; it is gone, and full screen now only takes the browser away.
|
|
243
|
+
- **Studio: a background agent that is still working is no longer drawn "Done".** Claude Code sends a `completed`
|
|
244
|
+
notice each time a background agent stops, and the agent can be resumed after it; Studio took the first notice as
|
|
245
|
+
final. A notice now stands only until the agent writes to its transcript again, and while its session is running
|
|
246
|
+
an agent in the middle of a turn is drawn Running.
|
|
247
|
+
|
|
208
248
|
## [3.0.4] — 2026-10-05
|
|
209
249
|
|
|
210
250
|
### Security
|
|
@@ -87,8 +87,11 @@ On a mixed/non-atomic diff, **propose a split**; do not commit before approval (
|
|
|
87
87
|
The tool-level gate `guard-bash.sh` intercepts commit/push in **every** permission mode: in normal modes it raises an
|
|
88
88
|
approval prompt only the user can answer — so present the message FIRST, then run the commit yourself and let the user
|
|
89
89
|
approve it at the prompt. Never hand the user a command to paste into their own terminal. In `auto` and `dontAsk`
|
|
90
|
-
the prompt reaches nobody: stage,
|
|
91
|
-
|
|
90
|
+
the prompt reaches nobody: stage, then ask in this shape and with nothing after it — the message in ONE code block,
|
|
91
|
+
and under it one line in the user's language: "If you approve, write only this: `approve: commit`" (`approve: push`,
|
|
92
|
+
`approve: commit+push`; `onay:` too). The user approves by sending that text alone; a sentence that says yes is not an
|
|
93
|
+
approval. It covers what is staged on that HEAD. If the gate says the approval path is closed in this session (the
|
|
94
|
+
recording hook is not wired), say so and offer Shift+Tab or their own terminal instead.
|
|
92
95
|
Then run `git commit -m '…'` alone in its call (single-quoted, or from a here-document with a quoted delimiter),
|
|
93
96
|
and a push as `git push <remote> <branch>`. Never write that message yourself. Under `plan` and
|
|
94
97
|
`bypassPermissions` the gate fails closed; there the user must switch modes or pre-authorise with
|
package/kit/eval/doctor.sh
CHANGED
|
@@ -96,6 +96,10 @@ _mt() {
|
|
|
96
96
|
'SessionStart not wired — session rehydration after /compact or /clear is inactive (update Crewforth)') s="SessionStart bağlı değil — /compact ya da /clear sonrasında oturum toparlanmıyor (Crewforth'u güncelleyin)" ;;
|
|
97
97
|
'SessionStart wired (session rehydration active)') s='SessionStart bağlı (oturum toparlama devrede)' ;;
|
|
98
98
|
'settings.json is invalid JSON') s='settings.json geçersiz JSON' ;;
|
|
99
|
+
'approval by your own message is wired (auto / dontAsk: approve: commit)') s='kendi mesajınızla onay bağlı (auto / dontAsk: onay: commit)' ;;
|
|
100
|
+
"settings.json wires prompt-approval.sh but the script is missing — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)") s="settings.json prompt-approval.sh'i bağlıyor ama betik yok — auto ve dontAsk modunda 'onay: commit' mesajı hiçbir şey kaydetmez; Shift+Tab ile mod değiştirin ya da kendi terminalinizden commit edin (Crewforth'u güncelleyin)" ;;
|
|
101
|
+
"prompt-approval.sh is not wired on UserPromptSubmit — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)") s="prompt-approval.sh UserPromptSubmit'e bağlı değil — auto ve dontAsk modunda 'onay: commit' mesajı hiçbir şey kaydetmez; Shift+Tab ile mod değiştirin ya da kendi terminalinizden commit edin (Crewforth'u güncelleyin)" ;;
|
|
102
|
+
"worktree %s carries Crewforth %s, this one %s — a session there runs that version's gates and approval path (update Crewforth there)") s="%s worktree'sinde Crewforth %s var, burada %s — orada açılan oturum o sürümün kapılarını ve onay yolunu koşar (Crewforth'u orada güncelleyin)" ;;
|
|
99
103
|
"fix the syntax by hand — restoring Crewforth's file would drop any hooks you added") s="sözdizimini elle düzeltin — Crewforth'un dosyasını geri yüklemek eklediğiniz hook'ları siler" ;;
|
|
100
104
|
'settings.json wires hooks through the %s placeholder — on Windows its separators are stripped before bash runs, so NO hook launches and every gate is silently absent') s="settings.json hook'ları %s yer tutucusuyla bağlıyor — Windows'ta ayırıcılar bash çalışmadan silinir, bu yüzden HİÇBİR hook başlamaz ve bütün kapılar sessizce yok olur" ;;
|
|
101
105
|
'update Crewforth (npx crewforth adopt) — hook commands become:') s="Crewforth'u güncelleyin (npx crewforth adopt) — hook komutları şu olur:" ;;
|
|
@@ -349,6 +353,14 @@ if [ -f "$S" ]; then
|
|
|
349
353
|
|| bad "settings.json hook events empty or missing:%s — those gates won't fire" "restore settings.json from Crewforth (npx crewforth update)" "$EMPTY"
|
|
350
354
|
sn="$(awk -v op=len -v path=hooks.SessionStart -f "$SJ" "$S" 2>/dev/null)"
|
|
351
355
|
case "$sn" in ''|0) warn "SessionStart not wired — session rehydration after /compact or /clear is inactive (update Crewforth)" ;; *) ok "SessionStart wired (session rehydration active)" ;; esac
|
|
356
|
+
# In auto and dontAsk the only approval is the user's own message, and ONE hook records it. Where it is not wired
|
|
357
|
+
# the gate stays closed and a message 'approve: commit' records nothing (field report: a worktree on an older
|
|
358
|
+
# Crewforth). A warning, not a failure: commit and push still cannot happen without the user.
|
|
359
|
+
case "$(awk -v op=get -v path=hooks.UserPromptSubmit -f "$SJ" "$S" 2>/dev/null)" in
|
|
360
|
+
*prompt-approval.sh*) if [ -f .claude/hooks/prompt-approval.sh ]; then ok "approval by your own message is wired (auto / dontAsk: approve: commit)"
|
|
361
|
+
else warn "settings.json wires prompt-approval.sh but the script is missing — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)"; fi ;;
|
|
362
|
+
*) warn "prompt-approval.sh is not wired on UserPromptSubmit — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)" ;;
|
|
363
|
+
esac
|
|
352
364
|
else bad "settings.json is invalid JSON" "fix the syntax by hand — restoring Crewforth's file would drop any hooks you added"; fi
|
|
353
365
|
# `${CLAUDE_PROJECT_DIR}` inside a hook command is the shape that breaks on Windows, and it breaks invisibly.
|
|
354
366
|
# Claude Code substitutes that placeholder into the command STRING before any shell sees it; on Windows the
|
|
@@ -370,6 +382,20 @@ if [ -f "$S" ]; then
|
|
|
370
382
|
else
|
|
371
383
|
bad "settings.json missing — the tool-level gates (commit approval, guards, context) are INACTIVE" "reinstall Crewforth"
|
|
372
384
|
fi
|
|
385
|
+
# 4b) Another worktree of this repository on another Crewforth. A session that moves there runs THAT version's hooks
|
|
386
|
+
# against this one's expectations: its gates, its approval path (3.0 has none for auto mode). `.claude/` is
|
|
387
|
+
# tracked in some projects, so a worktree cut from an older branch carries the older kit.
|
|
388
|
+
if [ -f .claude/VERSION ] && git rev-parse --git-dir >/dev/null 2>&1; then
|
|
389
|
+
_wv0="$(head -1 .claude/VERSION 2>/dev/null | tr -cd '0-9A-Za-z.-')"; _wt0="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
390
|
+
while IFS= read -r _wl; do
|
|
391
|
+
case "$_wl" in "worktree "*) _wp="${_wl#worktree }" ;; *) continue ;; esac
|
|
392
|
+
[ "$_wp" != "$_wt0" ] && [ -f "$_wp/.claude/VERSION" ] || continue
|
|
393
|
+
_wv="$(head -1 "$_wp/.claude/VERSION" 2>/dev/null | tr -cd '0-9A-Za-z.-')"
|
|
394
|
+
[ "$_wv" = "$_wv0" ] || warn "worktree %s carries Crewforth %s, this one %s — a session there runs that version's gates and approval path (update Crewforth there)" "$_wp" "${_wv:-?}" "$_wv0"
|
|
395
|
+
done <<EOF_WT
|
|
396
|
+
$(git worktree list --porcelain 2>/dev/null)
|
|
397
|
+
EOF_WT
|
|
398
|
+
fi
|
|
373
399
|
|
|
374
400
|
# 4a) Skill listing budget. Claude Code puts a listing of every model-invocable skill's name and description into
|
|
375
401
|
# context each turn and caps it at skillListingBudgetFraction of the context window (default 0.01); over the
|
package/kit/eval/smoke-test.sh
CHANGED
|
@@ -6167,6 +6167,35 @@ GBCP
|
|
|
6167
6167
|
( cd "$DTMP" && env -u CREW_LANG CREW_I18N_MISS="$DTMP/miss2" bash .claude/eval/doctor-twin.sh >/dev/null 2>&1 )
|
|
6168
6168
|
grep -qx 'planted line with no translation' "$DTMP/miss2" && pass "twin: a planted untranslated line is named by the miss list" \
|
|
6169
6169
|
|| fail "twin: the miss list did not name a planted untranslated line — the empty list above proves nothing"
|
|
6170
|
+
# THE APPROVAL PATH, AS DOCTOR SEES IT. In auto and dontAsk one hook records the user's own message; where it is
|
|
6171
|
+
# not wired, or another worktree carries another Crewforth, the gate stays closed and its advice used to be wrong
|
|
6172
|
+
# (field report, 3.1.0-rc.2). Four states of the wiring and two of the worktree, each read from doctor's own lines.
|
|
6173
|
+
DAP="$(mktemp -d)"; DAP="$(cd -P "$DAP" && pwd)"; mkdir -p "$DAP/p/.claude"
|
|
6174
|
+
for d in eval hooks; do [ -d "$ROOT/$d" ] && cp -R "$ROOT/$d" "$DAP/p/.claude/$d"; done
|
|
6175
|
+
cp "$ROOT/settings.json" "$DAP/p/.claude/settings.json" 2>/dev/null; echo 3.1.0 > "$DAP/p/.claude/VERSION"
|
|
6176
|
+
( cd "$DAP/p" && git init -q . && git config user.email t@example.com && git config user.name t && git config core.hooksPath /dev/null \
|
|
6177
|
+
&& echo x > f && git add f && git commit -qm init && git worktree add -q "$DAP/old" -b old ) >/dev/null 2>&1
|
|
6178
|
+
_dap(){ ( cd "$DAP/p" && CREW_LANG=en bash .claude/eval/doctor.sh 2>/dev/null ); }
|
|
6179
|
+
if [ ! -d "$DAP/old" ]; then fail "FIXTURE: the linked worktree for doctor's approval cases was not created"
|
|
6180
|
+
else
|
|
6181
|
+
_d1="$(_dap)"
|
|
6182
|
+
case "$_d1" in *"approval by your own message is wired"*) case "$_d1" in *"carries Crewforth"*|*"records nothing"*) fail "doctor warned about the approval path on a healthy install with a worktree that carries no Crewforth" ;;
|
|
6183
|
+
*) pass "doctor: the approval hook is reported wired on a fresh install, with no warning" ;; esac ;;
|
|
6184
|
+
*) fail "doctor did not report the approval hook as wired on a fresh install" ;; esac
|
|
6185
|
+
mkdir -p "$DAP/old/.claude"; echo 3.1.0 > "$DAP/old/.claude/VERSION"; _d2="$(_dap)"
|
|
6186
|
+
echo 3.0.4 > "$DAP/old/.claude/VERSION"; _d3="$(_dap)"
|
|
6187
|
+
case "$_d2" in *"carries Crewforth"*) fail "doctor warned about a worktree on the SAME Crewforth" ;; *)
|
|
6188
|
+
# The path is git's own spelling (`C:/…` on Windows, where this script says `/tmp/…`), so only its last part is read.
|
|
6189
|
+
case "$_d3" in *"/old carries Crewforth 3.0.4, this one 3.1.0"*) pass "doctor: a worktree on another Crewforth is named with both versions; one on the same version is not" ;;
|
|
6190
|
+
*) fail "doctor did not name a worktree that carries Crewforth 3.0.4 beside this 3.1.0" ;; esac ;; esac
|
|
6191
|
+
mv "$DAP/p/.claude/hooks/prompt-approval.sh" "$DAP/pa.sh"; _d4="$(_dap)"; mv "$DAP/pa.sh" "$DAP/p/.claude/hooks/prompt-approval.sh"
|
|
6192
|
+
case "$_d4" in *"wires prompt-approval.sh but the script is missing"*) pass "doctor: wired with the script missing is a warning that names the two ways that still work" ;;
|
|
6193
|
+
*) fail "doctor did not warn when prompt-approval.sh is wired and missing" ;; esac
|
|
6194
|
+
sed 's/prompt-approval\.sh/some-other.sh/' "$DAP/p/.claude/settings.json" > "$DAP/s.tmp" && mv "$DAP/s.tmp" "$DAP/p/.claude/settings.json"; _d5="$(_dap)"
|
|
6195
|
+
case "$_d5" in *"prompt-approval.sh is not wired on UserPromptSubmit"*"Shift+Tab"*) pass "doctor: an install whose settings do not wire the approval hook is warned, with Shift+Tab and the terminal named" ;;
|
|
6196
|
+
*) fail "doctor did not warn when prompt-approval.sh is not wired on UserPromptSubmit" ;; esac
|
|
6197
|
+
fi
|
|
6198
|
+
rm -rf "$DAP"
|
|
6170
6199
|
# WHAT git IS POINTED AT. .claude/git-shim is Crewforth's too: the updater points git there when the project has a
|
|
6171
6200
|
# hook chain of its own, and each shim runs Crewforth's hook and then the project's. Doctor did not know it: on such a
|
|
6172
6201
|
# project it answered "not Crewforth's hooks" and advised `git config core.hooksPath .claude/hooks`, the command that
|
|
@@ -6897,10 +6926,11 @@ _pa_new(){ # a fresh repository on branch feat/x with one remote, a staged chan
|
|
|
6897
6926
|
&& git config user.name t && git config core.hooksPath /dev/null && git checkout -q -b feat/x && echo one > a.txt && git add a.txt \
|
|
6898
6927
|
&& git commit -qm init && git remote add origin "$_PA/remote.git" && echo two >> a.txt && git add a.txt ) >/dev/null 2>&1
|
|
6899
6928
|
_pa_review; }
|
|
6929
|
+
# The fixture wires the recording hook, as an install does (_pa_review writes the settings file): the refusal reads it.
|
|
6900
6930
|
# The remote's address AS GIT SPELLS IT. On Git Bash git answers `C:/…` where the shell says `/c/…`, so an address is
|
|
6901
6931
|
# never compared against a path this script built: it is asked back from git (_pa_url), and "elsewhere" is derived from it.
|
|
6902
6932
|
_pa_url(){ ( cd "$_paw" && git remote get-url --push "${1:-origin}" 2>/dev/null ); }
|
|
6903
|
-
_pa_review(){ ( cd "$_paw" && mkdir -p .claude && printf '{"diff_oid":"%s","head":"%s"}\n' "$(git diff --cached | git hash-object --stdin)" "$(git rev-parse --verify --quiet HEAD)" > .claude/review-pass.json ); }
|
|
6933
|
+
_pa_review(){ ( cd "$_paw" && mkdir -p .claude && printf '%s' '{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}]}}' > .claude/settings.json && printf '{"diff_oid":"%s","head":"%s"}\n' "$(git diff --cached | git hash-object --stdin)" "$(git rev-parse --verify --quiet HEAD)" > .claude/review-pass.json ); }
|
|
6904
6934
|
_pa_rec="$_paw/.git/crewforth-approval"
|
|
6905
6935
|
# $1 = mode, $2 = the prompt AS JSON TEXT (already escaped), $3 = cwd (default: the repository)
|
|
6906
6936
|
_pa_say(){ printf '{"session_id":"s","cwd":"%s","permission_mode":"%s","hook_event_name":"UserPromptSubmit","prompt":"%s"}' "${3:-$_paw}" "$1" "$2" \
|
|
@@ -6917,7 +6947,7 @@ _pa_op(){ sed -n 's/^op=//p' "$_pa_rec" 2>/dev/null; }
|
|
|
6917
6947
|
if [ "$UNITS" != 1 ]; then
|
|
6918
6948
|
# The cases below feed payloads to the two hooks; like the other gate unit cases they belong to the source run.
|
|
6919
6949
|
# An installed project keeps the two pins after them: the shared definitions and the wiring.
|
|
6920
|
-
skip scope "approval by the user's own message: the unit cases run in the source checkout (scope=install)"
|
|
6950
|
+
skip scope "approval by the user's own message: the unit cases run in the source checkout (scope=install)" 39
|
|
6921
6951
|
elif [ ! -f "$HOOKS/prompt-approval.sh" ]; then
|
|
6922
6952
|
fail "hooks/prompt-approval.sh is missing — in auto and dontAsk nothing can turn the user's own yes into an approval"
|
|
6923
6953
|
else
|
|
@@ -7458,6 +7488,43 @@ else
|
|
|
7458
7488
|
if [ "$_pc2" -ge 5 ] && [ "$_pc2" -le 12 ]; then pass "an approval costs it a handful of git calls ($_pc2 external commands for commit+push), paid once per approval"
|
|
7459
7489
|
else fail "the approval hook ran $_pc2 external commands for one commit+push approval — outside 5..12, so either it measured nothing or it grew"; fi
|
|
7460
7490
|
fi
|
|
7491
|
+
# ---- what the refusal tells the model to do (3.1.0, field report) ---------------------------------------------
|
|
7492
|
+
# In auto the refusal is the only instruction the model gets, and the user sees what the model makes of it. Two
|
|
7493
|
+
# things are pinned: with the recording hook wired it asks for one shape (the message in one block, one line with
|
|
7494
|
+
# the exact text to send back, the operation of THIS call); with the hook not wired it says the path is closed and
|
|
7495
|
+
# does not tell anyone to type an approval. Verdicts do not move: each case is refused, and a matching record is
|
|
7496
|
+
# still allowed whatever the wiring says. The case sets CLAUDE_PROJECT_DIR and HOME itself: both are read.
|
|
7497
|
+
_pa_new; mkdir -p "$_PA/home"
|
|
7498
|
+
_pa_gate(){ printf '{"session_id":"s","cwd":"%s","permission_mode":"%s","tool_name":"Bash","tool_input":{"command":"%s"}}' "$_paw" "$1" "$2" > "$_PA/pl.json"
|
|
7499
|
+
( cd "$_paw" && HOME="$_PA/home" CLAUDE_PROJECT_DIR="$_paw" CREW_GATE_LOG=/dev/null bash "$HOOKS/guard-bash.sh" < "$_PA/pl.json" >"$_PA/out" 2>"$_PA/err" ); _par=$?; _pae="$(cat "$_PA/err")"; }
|
|
7500
|
+
_pa_wire(){ printf '%s' "$1" > "$_paw/.claude/settings.json"; }
|
|
7501
|
+
_PAW_ON='{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}]}}'
|
|
7502
|
+
_parow=""
|
|
7503
|
+
_pa_want(){ # $1 = label, $2 = text that must be there, $3 = text that must not
|
|
7504
|
+
case "$_par/$_pae" in 2/*"$2"*) case "$_pae" in *"$3"*) _parow="$_parow [$1: says '$3']" ;; esac ;; *) _parow="$_parow [$1: rc $_par, no '$2']" ;; esac; }
|
|
7505
|
+
_pa_wire "$_PAW_ON"
|
|
7506
|
+
_pa_gate auto 'git commit -m x'; _pa_want 'wired, commit' 'write only this: approve: commit' 'APPROVAL PATH IS CLOSED'
|
|
7507
|
+
_pa_want 'wired, commit, one block' 'in ONE code block' 'approve: push'
|
|
7508
|
+
_pa_gate dontAsk 'git push origin feat/x'; _pa_want 'wired, push' 'write only this: approve: push' 'APPROVAL PATH IS CLOSED'
|
|
7509
|
+
_pa_gate auto 'git commit -m x'; _pa_want 'wired, Turkish line' 'yalnız şunu yaz: onay: commit' 'APPROVAL PATH IS CLOSED'
|
|
7510
|
+
rm -f "$_paw/.claude/settings.json"
|
|
7511
|
+
_pa_gate auto 'git commit -m x'; _pa_want 'no settings' 'APPROVAL PATH IS CLOSED' 'write only this'
|
|
7512
|
+
_pa_want 'no settings, the ways' 'Shift+Tab' 'ONE code block'
|
|
7513
|
+
_pa_wire '{"note":"prompt-approval.sh","hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/route-hint.sh"}]}]}}'
|
|
7514
|
+
_pa_gate auto 'git push origin feat/x'; _pa_want 'named before the event only' 'APPROVAL PATH IS CLOSED' 'write only this'
|
|
7515
|
+
_pa_wire '{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"bash .claude/hooks/guard-bash.sh"}]}]}}'
|
|
7516
|
+
_pa_gate auto 'git commit -m x'; _pa_want 'other events only' 'APPROVAL PATH IS CLOSED' 'write only this'
|
|
7517
|
+
rm -f "$_paw/.claude/settings.json"; _pa_wire "$_PAW_ON"; mv "$_paw/.claude/settings.json" "$_paw/.claude/settings.local.json"
|
|
7518
|
+
_pa_gate auto 'git commit -m x'; _pa_want 'wired in settings.local.json' 'write only this: approve: commit' 'APPROVAL PATH IS CLOSED'
|
|
7519
|
+
rm -f "$_paw/.claude/settings.local.json"
|
|
7520
|
+
if [ -z "$_parow" ]; then pass "the auto-mode refusal asks in one shape when the approval hook is wired, and says the path is closed when it is not (9 readings, each refused)"
|
|
7521
|
+
else fail "the auto-mode refusal does not say what it should:$_parow"; fi
|
|
7522
|
+
# The wiring changes a sentence, never the verdict: with no settings at all, a record the hook wrote still allows
|
|
7523
|
+
# its command, and the same command without the record is refused.
|
|
7524
|
+
_pa_say auto 'approve: commit' >/dev/null; _pa_gate auto 'git commit -m x'; _pav1="$_par"
|
|
7525
|
+
_pa_new; _pa_gate auto 'git commit -m x'; _pav2="$_par"
|
|
7526
|
+
[ "$_pav1/$_pav2" = 0/2 ] && pass "the wiring check moves no verdict: a recorded approval is allowed (rc 0) and its absence refused (rc 2) with no settings file" \
|
|
7527
|
+
|| fail "the wiring check moved a verdict: approved $_pav1 (want 0), unapproved $_pav2 (want 2)"
|
|
7461
7528
|
fi # prompt-approval.sh present
|
|
7462
7529
|
|
|
7463
7530
|
# ---- the two definitions that live in two files, and the wiring -----------------------------------------------
|
|
@@ -103,7 +103,7 @@ if [ -n "${IN:-}" ] && [ -n "${PSID:-}" ]; then
|
|
|
103
103
|
# In auto and dontAsk the user's own message is the approval (hooks/prompt-approval.sh); plan and
|
|
104
104
|
# bypassPermissions have no such route.
|
|
105
105
|
case "$PM" in
|
|
106
|
-
auto|dontAsk) PMHOW="stage the change
|
|
106
|
+
auto|dontAsk) PMHOW="stage the change, show the message in ONE code block and under it one line in the user's language, 'If you approve, write only this: approve: commit' ('approve: push', 'approve: commit+push'; 'onay:' works too); the user approves by sending that text alone. It covers what is staged at that moment, for 30 minutes, until their next message; never write it for them" ;;
|
|
107
107
|
*) PMHOW="a real yes still comes first, then the user switches mode (or exports CLAUDE_GIT_OK=1 in headless/CI)" ;;
|
|
108
108
|
esac
|
|
109
109
|
echo "🔒 Permission mode: $PM. git commit/push fail closed in this mode (§4.4). Only when a commit or push comes up: say so before you ask for approval; $PMHOW. Otherwise do not mention it."
|
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -2355,6 +2355,26 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
|
|
|
2355
2355
|
[ -n "$tr" ] && [ "$cur" = "$tr" ] || { _APW="the commit on HEAD does not carry what the user approved"; return 1; }
|
|
2356
2356
|
return 0
|
|
2357
2357
|
}
|
|
2358
|
+
# IS THE RECORDING HOOK WIRED? Asked only when a commit or a push is about to be refused, so that the refusal names
|
|
2359
|
+
# a way that exists. It reads the settings this hook can see: the plugin's own hooks.json, the project's
|
|
2360
|
+
# settings.json and settings.local.json, the user's settings.json. HONEST SCOPE: the text of each file is searched
|
|
2361
|
+
# for the script's name after the event's name; it is not parsed, and managed settings are not read. A wrong answer
|
|
2362
|
+
# changes a sentence, never the verdict: the command is refused either way. eval/doctor.sh does the parsed check.
|
|
2363
|
+
_appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file wires it; else _APRW names what was read
|
|
2364
|
+
local f t d="${CLAUDE_PROJECT_DIR:-.}" seen="" here="${BASH_SOURCE%/*}"
|
|
2365
|
+
[ "$here" = "${BASH_SOURCE}" ] && here=.
|
|
2366
|
+
d="${d//\\//}"; d="${d%/}"
|
|
2367
|
+
_APRW="no prompt-approval.sh beside this hook"
|
|
2368
|
+
[ -f "$here/prompt-approval.sh" ] || return 1
|
|
2369
|
+
for f in "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}" "$d/.claude/settings.json" "$d/.claude/settings.local.json" "${HOME:+${HOME//\\//}/.claude/settings.json}"; do
|
|
2370
|
+
[ -n "$f" ] && [ -f "$f" ] || continue
|
|
2371
|
+
seen="$seen${seen:+, }$f"
|
|
2372
|
+
t=""; IFS= read -r -d '' t < "$f" || true
|
|
2373
|
+
case "$t" in *'"UserPromptSubmit"'*prompt-approval.sh*) return 0 ;; esac
|
|
2374
|
+
done
|
|
2375
|
+
_APRW="read: ${seen:-no settings file found}"
|
|
2376
|
+
return 1
|
|
2377
|
+
}
|
|
2358
2378
|
allow_approved(){
|
|
2359
2379
|
gatelog ALLOW 4.4 "the user's own approval message covers this command"
|
|
2360
2380
|
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
|
|
@@ -3144,11 +3164,22 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
|
|
|
3144
3164
|
gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
|
|
3145
3165
|
echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and in '$PERM_MODE' a permission prompt is answered by software, not by a person." >&2
|
|
3146
3166
|
echo "Not allowed now: $_APW." >&2
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3167
|
+
if ! _appr_wired; then
|
|
3168
|
+
# The record is written by one hook. Where it is not wired, a message from the user records nothing, and
|
|
3169
|
+
# telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
|
|
3170
|
+
echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A message 'approve: …' records nothing here." >&2
|
|
3171
|
+
echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
3172
|
+
echo "Do not create an approval any other way." >&2
|
|
3173
|
+
exit 2
|
|
3174
|
+
fi
|
|
3175
|
+
_aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
|
|
3176
|
+
echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
|
|
3177
|
+
echo " 1. the commit message, whole, in ONE code block;" >&2
|
|
3178
|
+
echo " 2. under it ONE line: If you approve, write only this: approve: $_aps" >&2
|
|
3179
|
+
echo " (in Turkish: Onaylıyorsan yalnız şunu yaz: onay: $_aps). Name the one that fits: commit, push, or commit+push when a push follows the commit." >&2
|
|
3180
|
+
echo "Their reply has to be that text alone: a sentence such as 'go ahead and commit' is not an approval. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
|
|
3181
|
+
echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
|
|
3182
|
+
echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
3152
3183
|
echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
|
|
3153
3184
|
exit 2 ;;
|
|
3154
3185
|
*)
|
|
@@ -19,6 +19,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
19
19
|
import { getFleet, measureSpawnCost } from './lib/fleet.js';
|
|
20
20
|
import { projectDir, listSessions, findSession, listProjects, sessionCwd } from './lib/projects.js';
|
|
21
21
|
import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
|
|
22
|
+
import { cachedUsage } from './lib/usage.js';
|
|
23
|
+
|
|
24
|
+
const USAGE_BATCH = 12;
|
|
22
25
|
import { palette } from './lib/palette.js';
|
|
23
26
|
import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
|
|
24
27
|
import { parsePeers, askAll, ask } from './lib/peers.js';
|
|
@@ -192,6 +195,21 @@ async function handle(req, res) {
|
|
|
192
195
|
});
|
|
193
196
|
}
|
|
194
197
|
|
|
198
|
+
// What a handful of sessions spent, for the navigator's rows. Asked for a few at a time and answered from a
|
|
199
|
+
// cache that lasts until a transcript grows: reading every transcript on the machine to draw a list is not
|
|
200
|
+
// something a page load should do.
|
|
201
|
+
if (url.pathname === '/api/usage') {
|
|
202
|
+
const ids = (url.searchParams.get('ids') ?? '').split(',').filter(Boolean).slice(0, USAGE_BATCH);
|
|
203
|
+
const usage = {};
|
|
204
|
+
for (const id of ids) {
|
|
205
|
+
const session = await findSession(id);
|
|
206
|
+
if (!session) continue; // a session on another machine: not read here, and not guessed
|
|
207
|
+
const u = await cachedUsage(session);
|
|
208
|
+
usage[id] = { durationMs: u.durationMs, workedMs: u.workedMs, tokens: u.tokens.total, cost: u.cost, unpriced: u.unpriced };
|
|
209
|
+
}
|
|
210
|
+
return sendJson(res, 200, { measured: true, usage });
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
if (url.pathname === '/api/fleet') {
|
|
196
214
|
const local = await getFleet();
|
|
197
215
|
const mine = (local.sessions ?? []).map((sn) => ({ ...sn, origin: SELF_NAME, local: true }));
|
|
@@ -19,6 +19,8 @@ import path from 'node:path';
|
|
|
19
19
|
|
|
20
20
|
import { readAll, contextFill } from './transcript.js';
|
|
21
21
|
import { agentMetaFiles } from './projects.js';
|
|
22
|
+
import { Usage, timesOf } from './usage.js';
|
|
23
|
+
import { detailOf } from './permissions.js';
|
|
22
24
|
|
|
23
25
|
const SESSION_NODE = 'session';
|
|
24
26
|
|
|
@@ -37,8 +39,12 @@ function textOf(content) {
|
|
|
37
39
|
* pairs the first id with the status swallows the rest; measured on this
|
|
38
40
|
* machine, one notice carried six ids and five were being dropped. Every id
|
|
39
41
|
* seen since the previous status takes that status.
|
|
42
|
+
*
|
|
43
|
+
* `at` is when the record carrying the notice was written. A notice is not the end of an agent: Claude Code sends
|
|
44
|
+
* one each time a background agent stops, and the agent can be sent another message and work again. Whether a
|
|
45
|
+
* notice still stands is decided by comparing `at` with what the agent wrote afterwards.
|
|
40
46
|
*/
|
|
41
|
-
function harvestCompletions(text, into) {
|
|
47
|
+
function harvestCompletions(text, into, at = null) {
|
|
42
48
|
if (!text || !text.includes('<task-id>')) return;
|
|
43
49
|
const token = /<task-id>([^<]+)<\/task-id>|<status>([^<]+)<\/status>/g;
|
|
44
50
|
let pending = [];
|
|
@@ -48,7 +54,7 @@ function harvestCompletions(text, into) {
|
|
|
48
54
|
pending.push(m[1].trim());
|
|
49
55
|
} else {
|
|
50
56
|
const status = m[2].trim();
|
|
51
|
-
for (const id of pending) into.set(id, status);
|
|
57
|
+
for (const id of pending) into.set(id, { status, at });
|
|
52
58
|
pending = [];
|
|
53
59
|
}
|
|
54
60
|
}
|
|
@@ -60,7 +66,9 @@ function scanMain(records) {
|
|
|
60
66
|
startedAt: null, updatedAt: null,
|
|
61
67
|
agentCalls: new Map(), // toolUseId -> { subagentType, description }
|
|
62
68
|
links: new Map(), // toolUseId -> { agentId, status, model }
|
|
63
|
-
completions: new Map(), //
|
|
69
|
+
completions: new Map(), // task id (an agent's, or a background command's) -> { status, at }
|
|
70
|
+
commands: new Map(), // toolUseId -> the command a Bash call ran
|
|
71
|
+
background: new Map(), // background task id -> { toolUseId, startedAt }
|
|
64
72
|
owners: new Map(), // toolUseId -> owner node id
|
|
65
73
|
userTurns: 0,
|
|
66
74
|
};
|
|
@@ -71,9 +79,11 @@ function scanMain(records) {
|
|
|
71
79
|
if (r?.cwd && !out.cwd) out.cwd = r.cwd;
|
|
72
80
|
if (r?.gitBranch && !out.gitBranch) out.gitBranch = r.gitBranch;
|
|
73
81
|
if (r?.version && !out.version) out.version = r.version;
|
|
82
|
+
let at = null;
|
|
74
83
|
if (r?.timestamp) {
|
|
75
84
|
const t = Date.parse(r.timestamp);
|
|
76
85
|
if (Number.isFinite(t)) {
|
|
86
|
+
at = t;
|
|
77
87
|
if (out.startedAt === null || t < out.startedAt) out.startedAt = t;
|
|
78
88
|
if (out.updatedAt === null || t > out.updatedAt) out.updatedAt = t;
|
|
79
89
|
}
|
|
@@ -86,6 +96,7 @@ function scanMain(records) {
|
|
|
86
96
|
for (const c of content) {
|
|
87
97
|
if (c?.type === 'tool_use' && c.id) {
|
|
88
98
|
out.owners.set(c.id, SESSION_NODE);
|
|
99
|
+
if (typeof c.input?.command === 'string') out.commands.set(c.id, c.input.command);
|
|
89
100
|
if (c.name === 'Agent') {
|
|
90
101
|
out.agentCalls.set(c.id, {
|
|
91
102
|
subagentType: c.input?.subagent_type ?? null,
|
|
@@ -95,12 +106,12 @@ function scanMain(records) {
|
|
|
95
106
|
}
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
|
-
harvestCompletions(textOf(content), out.completions);
|
|
109
|
+
harvestCompletions(textOf(content), out.completions, at);
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
if (r?.type === 'user') {
|
|
102
113
|
out.userTurns += 1;
|
|
103
|
-
harvestCompletions(textOf(r.message?.content), out.completions);
|
|
114
|
+
harvestCompletions(textOf(r.message?.content), out.completions, at);
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
// Present on the record that carries a subagent's result, whatever its type.
|
|
@@ -112,15 +123,81 @@ function scanMain(records) {
|
|
|
112
123
|
if (id) out.links.set(id, entry);
|
|
113
124
|
else out.links.set(`agent:${tur.agentId}`, entry);
|
|
114
125
|
}
|
|
115
|
-
|
|
126
|
+
// A command sent to the background answers at once with the id it runs under; its end comes later, as a notice.
|
|
127
|
+
if (tur && typeof tur === 'object' && typeof tur.backgroundTaskId === 'string') {
|
|
128
|
+
const id = r.message?.content?.find?.((c) => c?.type === 'tool_result')?.tool_use_id ?? null;
|
|
129
|
+
out.background.set(tur.backgroundTaskId, { toolUseId: id, startedAt: at });
|
|
130
|
+
}
|
|
131
|
+
if (r?.type === 'attachment') harvestCompletions(JSON.stringify(r.attachment ?? ''), out.completions, at);
|
|
116
132
|
}
|
|
117
133
|
|
|
118
134
|
return out;
|
|
119
135
|
}
|
|
120
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Does a transcript stop in the middle of a turn? It does when the last thing in it is a tool call with no answer,
|
|
139
|
+
* or an answer the agent has not replied to. A turn that is over ends on the agent's own words.
|
|
140
|
+
*/
|
|
141
|
+
export function midTurn(records) {
|
|
142
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
143
|
+
const r = records[i];
|
|
144
|
+
if (r?.type !== 'assistant' && r?.type !== 'user') continue;
|
|
145
|
+
const content = r.message?.content;
|
|
146
|
+
if (r.type === 'user') return true;
|
|
147
|
+
return Array.isArray(content) && content.some((c) => c?.type === 'tool_use');
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// How long after a notice an agent's own last record may be and still belong to the turn the notice ended. The
|
|
153
|
+
// notice is written after the agent's last record, so anything later than this is the agent working again.
|
|
154
|
+
const NOTICE_SLACK_MS = 5000;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Does a completion notice still say how the agent stands? Not when the agent wrote to its transcript after it:
|
|
158
|
+
* it was sent another message and is working, or has worked, again.
|
|
159
|
+
*/
|
|
160
|
+
export function noticeStands(notice, lastOwnAt) {
|
|
161
|
+
if (!notice) return false;
|
|
162
|
+
if (notice.at == null || lastOwnAt == null) return true;
|
|
163
|
+
return lastOwnAt <= notice.at + NOTICE_SLACK_MS;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A call that hands work to an agent is open for as long as that agent runs; it is not a call anyone is asked about.
|
|
167
|
+
const DELEGATES = new Set(['Agent', 'Task']);
|
|
168
|
+
const OPEN_DETAIL_MAX = 2000;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The tool calls a transcript has asked for and has no result for yet, oldest first.
|
|
172
|
+
*
|
|
173
|
+
* A call is open while it runs and while it waits for someone to allow it; the transcript does not say which. It
|
|
174
|
+
* is what a session started in a terminal is asking about when the machine reports it as waiting for its user —
|
|
175
|
+
* the panel has no hook in such a session, so this is the only place the question can be read from.
|
|
176
|
+
*/
|
|
177
|
+
export function openCalls(records) {
|
|
178
|
+
const open = new Map();
|
|
179
|
+
for (const r of records) {
|
|
180
|
+
const content = r?.message?.content;
|
|
181
|
+
if (!Array.isArray(content)) continue;
|
|
182
|
+
for (const c of content) {
|
|
183
|
+
if (r.type === 'assistant' && c?.type === 'tool_use' && c.id && !DELEGATES.has(c.name)) {
|
|
184
|
+
const at = Date.parse(r.timestamp ?? '');
|
|
185
|
+
const detail = detailOf(c.input);
|
|
186
|
+
open.set(c.id, {
|
|
187
|
+
toolUseId: c.id, toolName: c.name ?? 'unknown',
|
|
188
|
+
detail: detail === null ? null : detail.slice(0, OPEN_DETAIL_MAX),
|
|
189
|
+
at: Number.isFinite(at) ? at : null,
|
|
190
|
+
});
|
|
191
|
+
} else if (r.type === 'user' && c?.type === 'tool_result' && c.tool_use_id) open.delete(c.tool_use_id);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return [...open.values()];
|
|
195
|
+
}
|
|
196
|
+
|
|
121
197
|
/** Roll one agent's own transcript into the numbers its node shows. */
|
|
122
|
-
async function scanAgent(file) {
|
|
198
|
+
async function scanAgent(file, usage = null) {
|
|
123
199
|
const { records } = await readAll(file);
|
|
200
|
+
usage?.add(records);
|
|
124
201
|
const stats = {
|
|
125
202
|
tools: {}, toolCount: 0, lastTool: null, errors: 0,
|
|
126
203
|
tokens: null, startedAt: null, endedAt: null, turns: 0,
|
|
@@ -164,10 +241,12 @@ async function scanAgent(file) {
|
|
|
164
241
|
stats.durationMs = stats.startedAt !== null && stats.endedAt !== null
|
|
165
242
|
? stats.endedAt - stats.startedAt
|
|
166
243
|
: null;
|
|
244
|
+
stats.midTurn = midTurn(records);
|
|
245
|
+
stats.open = openCalls(records);
|
|
167
246
|
return stats;
|
|
168
247
|
}
|
|
169
248
|
|
|
170
|
-
async function readAgentDir(subagentsDir) {
|
|
249
|
+
async function readAgentDir(subagentsDir, usage = null) {
|
|
171
250
|
const out = [];
|
|
172
251
|
// Nested too: a workflow puts its agents under subagents/workflows/<id>/.
|
|
173
252
|
for (const metaPath of await agentMetaFiles(subagentsDir)) {
|
|
@@ -185,7 +264,7 @@ async function readAgentDir(subagentsDir) {
|
|
|
185
264
|
// `stats` needs the transcript read; a missing file is a normal state (the
|
|
186
265
|
// agent has not written yet), so absence is a null rather than a throw.
|
|
187
266
|
let stats = null;
|
|
188
|
-
if (mtime !== null) stats = await scanAgent(jsonl);
|
|
267
|
+
if (mtime !== null) stats = await scanAgent(jsonl, usage);
|
|
189
268
|
|
|
190
269
|
out.push({
|
|
191
270
|
agentId,
|
|
@@ -220,7 +299,9 @@ export const STALE_MS = 120000;
|
|
|
220
299
|
export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
221
300
|
const { records, malformed } = await readAll(session.file);
|
|
222
301
|
const main = scanMain(records);
|
|
223
|
-
|
|
302
|
+
// What the session spent, its agents' tokens included and each response counted once.
|
|
303
|
+
const usage = new Usage().add(records);
|
|
304
|
+
const agents = await readAgentDir(session.subagentsDir, usage);
|
|
224
305
|
const now = Date.now();
|
|
225
306
|
|
|
226
307
|
// Ownership: session first, then every agent's own tool_use ids, so a nested
|
|
@@ -248,13 +329,26 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
248
329
|
tokens: contextFill(records),
|
|
249
330
|
startedAt: main.startedAt,
|
|
250
331
|
endedAt: main.updatedAt,
|
|
332
|
+
usage: { ...usage.result(), ...timesOf(records) },
|
|
333
|
+
// Commands sent to the background that no notice has ended, oldest first. A session that is over has none
|
|
334
|
+
// running whatever this says: the page shows them only while the session is live.
|
|
335
|
+
backgroundCommands: [...main.background]
|
|
336
|
+
.filter(([id]) => !main.completions.has(id))
|
|
337
|
+
.map(([id, b]) => ({ id, toolName: 'Bash', detail: main.commands.get(b.toolUseId) ?? null, startedAt: b.startedAt }))
|
|
338
|
+
.sort((x, y) => (x.startedAt ?? 0) - (y.startedAt ?? 0)),
|
|
339
|
+
// The newest call nothing has answered, the session's own or an agent's. Null when every call has its result.
|
|
340
|
+
openCall: [
|
|
341
|
+
...openCalls(records).map((c) => ({ ...c, agentId: null, agentType: null })),
|
|
342
|
+
...agents.flatMap((a) => (a.stats?.open ?? []).map((c) => ({ ...c, agentId: a.agentId, agentType: a.agentType ?? null }))),
|
|
343
|
+
].sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0] ?? null,
|
|
251
344
|
}];
|
|
252
345
|
const edges = [];
|
|
253
346
|
|
|
254
347
|
for (const a of agents) {
|
|
255
348
|
const call = a.toolUseId ? main.agentCalls.get(a.toolUseId) : null;
|
|
256
349
|
const link = a.toolUseId ? byToolUse.get(a.toolUseId) : null;
|
|
257
|
-
const
|
|
350
|
+
const notice = main.completions.get(a.agentId) ?? null;
|
|
351
|
+
const completion = noticeStands(notice, a.stats?.endedAt ?? null) ? notice.status : null;
|
|
258
352
|
|
|
259
353
|
// Order matters. A synchronous Agent call never produces a task
|
|
260
354
|
// notification — it reports through toolUseResult.status instead. Reading
|
|
@@ -288,6 +382,11 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
288
382
|
toolUseId: a.toolUseId,
|
|
289
383
|
model: link?.model ?? null,
|
|
290
384
|
launchStatus: link?.status ?? null,
|
|
385
|
+
// Started in the background, and its transcript stops in the middle of a turn. Such an agent can be quiet
|
|
386
|
+
// for a long time inside one tool call; whether it is still working is the live session's to say, so the
|
|
387
|
+
// page decides (graph-plan.js `settle`), not this file.
|
|
388
|
+
background: link?.status === 'async_launched',
|
|
389
|
+
midTurn: a.stats?.midTurn ?? false,
|
|
291
390
|
parentId,
|
|
292
391
|
tools: a.stats?.tools ?? {},
|
|
293
392
|
toolCount: a.stats?.toolCount ?? 0,
|
|
@@ -74,6 +74,16 @@ function gateMode(mode) {
|
|
|
74
74
|
return typeof mode === 'string' && /^[A-Za-z]+$/.test(mode) ? `CREW_GATE_MODE=${mode} ` : '';
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The most identifying field each tool has. Showing "Bash" alone would ask someone to approve a command they
|
|
79
|
+
* cannot see.
|
|
80
|
+
*/
|
|
81
|
+
export function detailOf(input) {
|
|
82
|
+
const i = input && typeof input === 'object' ? input : {};
|
|
83
|
+
const d = i.command ?? i.file_path ?? i.pattern ?? i.query ?? i.description ?? null;
|
|
84
|
+
return typeof d === 'string' ? d : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
77
87
|
/** One pending request, as the panel needs to show it. */
|
|
78
88
|
function readRequest(spool, file) {
|
|
79
89
|
const toolUseId = file.replace(/\.json$/, '');
|
|
@@ -87,9 +97,7 @@ function readRequest(spool, file) {
|
|
|
87
97
|
return {
|
|
88
98
|
toolUseId,
|
|
89
99
|
toolName: payload.tool_name ?? 'unknown',
|
|
90
|
-
|
|
91
|
-
// someone to approve a command they cannot see.
|
|
92
|
-
detail: input.command ?? input.file_path ?? input.pattern ?? input.query ?? input.description ?? null,
|
|
100
|
+
detail: detailOf(input),
|
|
93
101
|
input,
|
|
94
102
|
cwd: payload.cwd ?? null,
|
|
95
103
|
// Who asked. Claude Code puts these in the hook's input only when the call comes from inside a subagent, so
|