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 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, present the message, and the user approves by sending a message that is only
91
- `approve: commit` (`approve: push`, `approve: commit+push`; `onay:` too). It covers what is staged on that HEAD.
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
@@ -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
@@ -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)" 37
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 and show the message, then the user approves by sending a message that is only 'approve: commit' ('approve: push', 'approve: commit+push'; 'onay:' works too). It covers what is staged at that moment, for 30 minutes, until their next message; never write it for them" ;;
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."
@@ -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
- echo "Present the commit MESSAGE to the user (stage first: the approval covers what is staged at that moment). Then one of:" >&2
3148
- echo " (a) the user sends a message that is ONLY the approval: 'approve: commit', 'approve: push' or 'approve: commit+push' ('onay: …' works too)." >&2
3149
- echo " It is tied to the staged diff and HEAD at that moment, lasts 30 minutes and ends at the user's next message. The command then runs ALONE in its call (no cd, no pipe, nothing chained): git commit -m …, or git push <remote> <branch>. OR" >&2
3150
- echo " (b) the user presses Shift+Tab to switch to default/acceptEdits — in this session, no restart — and this gate asks them directly, OR" >&2
3151
- echo " (c) the user runs the command in their own terminal." >&2
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(), // agentId -> status
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
- if (r?.type === 'attachment') harvestCompletions(JSON.stringify(r.attachment ?? ''), out.completions);
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
- const agents = await readAgentDir(session.subagentsDir);
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 completion = main.completions.get(a.agentId) ?? null;
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
- // The most identifying field each tool has. Showing "Bash" alone would ask
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