crewforth 3.1.0-rc.2 → 3.1.0-rc.4

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.
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # guard-schedule.sh - a prompt a tool schedules is not the user's (§4.4).
3
+ #
4
+ # WHY. In `auto` and `dontAsk` the approval for a commit or a push is what the user types: `/crew-approve commit`,
5
+ # recorded by hooks/prompt-approval.sh from UserPromptSubmit. A prompt that a tool SCHEDULES raises the same event
6
+ # when it fires. So a session that scheduled `/crew-approve commit` would be approving its own commit.
7
+ #
8
+ # WHAT. Wired on PreToolUse for the tools that schedule a prompt (CronCreate, ScheduleWakeup, RemoteTrigger, and an
9
+ # MCP tool whose name holds trigger, schedule, cron or later). The call is refused when ANY string in its input is
10
+ # an approval, by the function that reads the user's own prompt: prompt-approval.sh is sourced for it, so there is
11
+ # one definition of what an approval is. An ordinary scheduled prompt holds no such string and passes untouched.
12
+ #
13
+ # COST. One awk on these few tools, to take the strings out of the payload; nothing on any other tool call.
14
+ # HONEST SCOPE. A tool this hook is not wired on, or a prompt put together only when it fires, is past it.
15
+ set -uo pipefail
16
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
17
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
18
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
19
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
20
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
21
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
22
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
23
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
24
+ # staged key all passed.
25
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
26
+ # scan for private strings read that as "no match".
27
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
28
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
29
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
30
+ # Byte-identical in every gate; the suite pins it.
31
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
32
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
33
+ export LC_ALL=C
34
+ # ---- /CREW-LOCALE
35
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
36
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
37
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
38
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
39
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
40
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
41
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
42
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
43
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
44
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
45
+ # that is not found (status 127, the script continues inside the same command).
46
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
47
+ _crew_stop(){ # $1 = what happened
48
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
49
+ echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
50
+ echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
51
+ exit 2
52
+ }
53
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
54
+ # ---- /CREW-FAILCLOSED
55
+ _gate_main(){
56
+ local here="${BASH_SOURCE%/*}" strs sv tn sch=0
57
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
58
+ IFS= read -r -d '' INPUT || true
59
+ if [ ! -f "$here/prompt-approval.sh" ]; then
60
+ echo "GUARD (§4.4): prompt-approval.sh is not beside this gate, so a scheduled prompt cannot be checked for an approval and the call is refused. Update Crewforth." >&2
61
+ exit 2
62
+ fi
63
+ . "$here/prompt-approval.sh"
64
+ declare -F _crew_appr_op >/dev/null 2>&1 || { echo "GUARD (§4.4): prompt-approval.sh did not give the approval check, so the call is refused. Update Crewforth." >&2; exit 2; }
65
+ _json_slice "$INPUT" tool_name >/dev/null; tn="$_JS"
66
+ shopt -s nocasematch
67
+ case "$tn" in *cron*|*schedul*|*trigger*|*later*|*wakeup*) sch=1 ;; esac
68
+ shopt -u nocasematch
69
+ [ "$sch" = 1 ] || exit 0
70
+ # Every JSON string of at most 120 bytes, still escaped, one per line (a JSON string holds no raw newline). An
71
+ # approval is a few words: a longer string is never one, and is not kept.
72
+ strs="$(printf '%s' "$INPUT" | LC_ALL=C awk '{ buf = buf $0 " " } END { n = length(buf); ins = 0; esc = 0; s = ""; big = 0
73
+ for (i = 1; i <= n; i++) { c = substr(buf, i, 1)
74
+ if (!ins) { if (c == "\"") { ins = 1; s = ""; big = 0 }; continue }
75
+ if (esc) { esc = 0; if (!big) s = s c; continue }
76
+ if (c == "\\") { esc = 1; if (!big) s = s c; continue }
77
+ if (c == "\"") { ins = 0; if (!big) print s; continue }
78
+ if (!big) { s = s c; if (length(s) > 120) big = 1 } } }')" || {
79
+ echo "GUARD (§4.4): the prompt this call schedules could not be read (awk did not run), so the call is refused." >&2; exit 2; }
80
+ while IFS= read -r sv; do
81
+ [ -n "$sv" ] || continue
82
+ _json_unescape "$sv" >/dev/null; _crew_appr_op "$_JU"
83
+ if [ -n "$OP" ]; then
84
+ echo "GUARD (§4.4): this call schedules a prompt that is an approval for a commit or a push ($OP). An approval is what the user types; a prompt a tool schedules would approve in their place, so the call is refused." >&2
85
+ echo "Schedule the work without it. For the approval itself, show the commit message and ask: the user can type /crew-approve $OP when they agree." >&2
86
+ exit 2
87
+ fi
88
+ done <<< "$strs"
89
+ exit 0
90
+ }
91
+ _gate_main "$@"
92
+ _crew_stop "a command of the gate was abandoned"
@@ -8,8 +8,8 @@
8
8
  # yes, and this hook is the only thing that turns it into a record.
9
9
  #
10
10
  # WHAT COUNTS. The WHOLE message, trimmed, is one of (letter case does not matter):
11
- # approve: commit approve: push approve: commit+push
12
- # onay: commit onay: push onay: commit+push
11
+ # /crew-approve commit /crew-approve push /crew-approve commit+push
12
+ # and, as the release candidates of 3.1.0 had it, `approve: <what>` or `onay: <what>`.
13
13
  # A sentence that contains those words is not an approval, and neither is a message with a second line: text
14
14
  # that was pasted, quoted or relayed from somewhere else never has that shape by accident.
15
15
  #
@@ -51,8 +51,13 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
51
51
  case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
52
52
  export LC_ALL=C
53
53
  # ---- /CREW-LOCALE
54
- IFS= read -r -d '' INPUT || true
55
- case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
54
+ # guard-schedule.sh SOURCES this file for one thing, _crew_appr_op: which text is an approval is decided here and
55
+ # nowhere else. Sourced, the file defines its functions and returns before anything is read or written.
56
+ _PA_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _PA_LIB=1
57
+ if [ "$_PA_LIB" = 0 ]; then
58
+ IFS= read -r -d '' INPUT || true
59
+ case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
60
+ fi
56
61
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
57
62
  _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
58
63
  # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
@@ -351,6 +356,38 @@ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktre
351
356
  }
352
357
  # ---- /CREW-APPROVAL-PATH ---------------------------------------------------------------------------------
353
358
 
359
+ # ---- WHICH TEXT IS AN APPROVAL: one function, asked here of the user's prompt and by guard-schedule.sh of a
360
+ # prompt a tool is about to schedule ---------------------------------------------------------------------------
361
+ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push, or "" when the text is not an approval
362
+ local P="$1" _k _v LC_ALL=C
363
+ OP=""
364
+ P="${P//$'\r'/}"
365
+ while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
366
+ while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
367
+ [ "${#P}" -le 40 ] || return 0
368
+ case "$P" in
369
+ *$'\n'*) return 0 ;;
370
+ # THE COMMAND: `/crew-approve <what>`, or `/<plugin>:crew-approve <what>` as a plugin names its commands. Measured
371
+ # on Claude Code 2.1.284: a command the user types reaches UserPromptSubmit with `prompt` as typed, and a skill
372
+ # the model starts with the Skill tool raises PreToolUse and PostToolUse only, never that event.
373
+ /*) _k="${P%%[$' \t']*}"; [ "$_k" != "$P" ] || return 0
374
+ _v="${P:${#_k}}"; _k="${_k#/}"; _k="@${_k##*:}" ;; # `@`: a command, so `crew-approve: commit` as text is not one
375
+ # The text form of 3.1.0's release candidates, kept: `approve: <what>` and `onay: <what>`.
376
+ *:*) _k="${P%%:*}"; _v="${P#*:}" ;;
377
+ *) return 0 ;;
378
+ esac
379
+ _k="${_k//[$' \t']/}"; _v="${_v//[$' \t']/}"
380
+ # In the C locale: under tr_TR an upper-case I does not fold to i, and `ONAY: COMMIT` was not an approval (review).
381
+ shopt -s nocasematch
382
+ case "$_k" in
383
+ @crew-approve|approve|onay)
384
+ case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
385
+ esac
386
+ shopt -u nocasematch
387
+ }
388
+
389
+ [ "$_PA_LIB" = 1 ] && return 0
390
+
354
391
  # A second "prompt" key cannot come from the text of a prompt (inside a JSON string every quote is escaped), so
355
392
  # more than one means the payload is not the shape this hook knows, and nothing is read from it.
356
393
  _json_keycount "$INPUT" prompt; _n_prompt=$_KC
@@ -384,23 +421,8 @@ if [ -n "$REC" ] && [ -s "$REC" ]; then : > "$REC" 2>/dev/null || true; fi
384
421
  # ---- is this message an approval? ------------------------------------------------------------------------
385
422
  [ "$_n_prompt" = 1 ] || exit 0
386
423
  [ "${#RAW}" -le 120 ] || exit 0 # an approval is a few words; a pasted page is not decoded to find that out
387
- _json_unescape "$RAW" >/dev/null; P="$_JU"
388
- P="${P//$'\r'/}"
389
- while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
390
- while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
391
- [ "${#P}" -le 40 ] || exit 0
392
- case "$P" in *$'\n'*) exit 0 ;; *:*) ;; *) exit 0 ;; esac
393
- _k="${P%%:*}"; _v="${P#*:}"
394
- _k="${_k//[$' \t']/}"; _v="${_v//[$' \t']/}"
395
- OP=""
396
- # In the C locale: under tr_TR an upper-case I does not fold to i, and `ONAY: COMMIT` was not an approval (review).
397
- _pa_match(){ local LC_ALL=C; shopt -s nocasematch
398
- case "$_k" in
399
- approve|onay)
400
- case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
401
- esac
402
- shopt -u nocasematch; }
403
- _pa_match
424
+ _json_unescape "$RAW" >/dev/null
425
+ _crew_appr_op "$_JU"
404
426
  [ -n "$OP" ] || exit 0
405
427
 
406
428
  # From here on the message IS an approval, so every way of not recording it says why - to the user and to the
@@ -420,6 +442,31 @@ case "$PM" in
420
442
  esac
421
443
  [ -n "$REC" ] || no "this directory is not inside a git repository."
422
444
 
445
+ # A PERSON, IN A SESSION THAT HAS SHOWN THEM SOMETHING. A session can start another one and hand it the approval as
446
+ # its prompt: `claude -p "/crew-approve commit"`, or an interactive one driven through a pseudo-terminal. That
447
+ # session's only message is the approval, this hook would record it, and the session would then commit: the first
448
+ # session has approved for the user. Measured on Claude Code 2.1.294 (macOS), from a hook that printed what it saw:
449
+ # started how CLAUDE_CODE_SESSION_ATTENDED the transcript file when this hook runs
450
+ # claude -p, from a session's Bash 0 (set to 1 by the caller: still 0) not there (first message)
451
+ # claude -p, from an emptied environment 0 not there (first message)
452
+ # interactive, in a pty, from a session 1 not there, and never written: a
453
+ # session started inside another keeps none
454
+ # interactive, in a pty, emptied environment 1 not there at the first message; there after
455
+ # the desktop app, a conversation under way 1 there
456
+ # Nothing else told them apart: the payload has the same fields, CLAUDECODE and CLAUDE_CODE_CHILD_SESSION are 1 in
457
+ # all of them, CLAUDE_CODE_ENTRYPOINT is inherited from the outer session, and no hook has a terminal on stdin.
458
+ # So two things are asked. Neither is a documented contract, and both fail towards NOT recording:
459
+ # 1. the session is not a headless one;
460
+ # 2. the session's transcript is already on disk when the approval arrives, so the approval is not the session's
461
+ # first message and the session is not one that keeps no transcript.
462
+ # HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
463
+ # message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
464
+ case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
465
+ 0) no "this session has nobody in front of it (it was started with -p, or by a program), and an approval is what a person types into their own session." ;;
466
+ esac
467
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
468
+ { [ -n "$TP" ] && [ -s "$TP" ]; } || no "this is the first message of this session, or the session keeps no transcript (one started from inside another session keeps none). An approval answers a commit message the session has shown: have it shown, then approve."
469
+
423
470
  # The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
424
471
  # command can be handed any prompt.
425
472
  _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
package/kit/settings.json CHANGED
@@ -86,6 +86,17 @@
86
86
  "timeout": 600
87
87
  }
88
88
  ]
89
+ },
90
+ {
91
+ "matcher": "CronCreate|ScheduleWakeup|RemoteTrigger|mcp__.*([Tt]rigger|[Ss]chedul|[Cc]ron|[Ll]ater).*",
92
+ "hooks": [
93
+ {
94
+ "type": "command",
95
+ "shell": "bash",
96
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/guard-schedule.sh",
97
+ "timeout": 600
98
+ }
99
+ ]
89
100
  }
90
101
  ],
91
102
  "UserPromptSubmit": [
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: crew-approve
3
+ description: Approve the commit or push Claude just showed you (auto and dontAsk modes).
4
+ argument-hint: "[commit | push | commit+push]"
5
+ disable-model-invocation: true
6
+ metadata:
7
+ kind: command
8
+ ---
9
+ # /crew-approve
10
+ Argument: $ARGUMENTS
11
+
12
+ The user typed this command. Typing it is the approval: `hooks/prompt-approval.sh` read the message itself and
13
+ recorded it, tied to what is staged, to `HEAD` and to this session, for 30 minutes or until the user's next
14
+ message. Nothing in this text records anything, and you cannot give this approval for the user.
15
+
16
+ Read the line Crewforth added to this turn and do what it says:
17
+
18
+ - **"approval recorded"**: run exactly what it names, each command alone in its call. A commit is
19
+ `git commit -m '…'` with the message you showed (single-quoted, or from a here-document with a quoted delimiter);
20
+ a push is `git push <remote> <branch>`. The review record of §4.6 is still required.
21
+ - **"approval NOT recorded"**: tell the user the reason it gives, and run nothing.
22
+ - **No such line**: the session is in a mode where the gate asks the user itself (`default`, `acceptEdits`), or
23
+ the argument was not `commit`, `push` or `commit+push`. Say which, and in the first case run the command and let
24
+ the gate's own prompt ask.
25
+
26
+ Force-push and the rest of §4.5 are not opened by this.
@@ -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,10 @@ 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
+ waves: new Map(), // assistant message id -> wave number, in the order the messages were written
71
+ commands: new Map(), // toolUseId -> the command a Bash call ran
72
+ background: new Map(), // background task id -> { toolUseId, startedAt }
64
73
  owners: new Map(), // toolUseId -> owner node id
65
74
  userTurns: 0,
66
75
  };
@@ -71,9 +80,11 @@ function scanMain(records) {
71
80
  if (r?.cwd && !out.cwd) out.cwd = r.cwd;
72
81
  if (r?.gitBranch && !out.gitBranch) out.gitBranch = r.gitBranch;
73
82
  if (r?.version && !out.version) out.version = r.version;
83
+ let at = null;
74
84
  if (r?.timestamp) {
75
85
  const t = Date.parse(r.timestamp);
76
86
  if (Number.isFinite(t)) {
87
+ at = t;
77
88
  if (out.startedAt === null || t < out.startedAt) out.startedAt = t;
78
89
  if (out.updatedAt === null || t > out.updatedAt) out.updatedAt = t;
79
90
  }
@@ -86,21 +97,30 @@ function scanMain(records) {
86
97
  for (const c of content) {
87
98
  if (c?.type === 'tool_use' && c.id) {
88
99
  out.owners.set(c.id, SESSION_NODE);
89
- if (c.name === 'Agent') {
100
+ if (typeof c.input?.command === 'string') out.commands.set(c.id, c.input.command);
101
+ if (c.name === 'Agent' || c.name === 'Task') {
102
+ // The order the session called its agents in, and which calls were made together: the calls of one
103
+ // assistant message are one wave. A message is written as several records, one per block, and they
104
+ // share its id — so the wave is the message id, not the record.
105
+ const mid = r.message?.id ?? `record:${out.agentCalls.size}`;
106
+ if (!out.waves.has(mid)) out.waves.set(mid, out.waves.size + 1);
90
107
  out.agentCalls.set(c.id, {
91
108
  subagentType: c.input?.subagent_type ?? null,
92
109
  description: c.input?.description ?? null,
110
+ order: out.agentCalls.size + 1,
111
+ wave: out.waves.get(mid),
112
+ calledAt: at,
93
113
  });
94
114
  }
95
115
  }
96
116
  }
97
117
  }
98
- harvestCompletions(textOf(content), out.completions);
118
+ harvestCompletions(textOf(content), out.completions, at);
99
119
  }
100
120
 
101
121
  if (r?.type === 'user') {
102
122
  out.userTurns += 1;
103
- harvestCompletions(textOf(r.message?.content), out.completions);
123
+ harvestCompletions(textOf(r.message?.content), out.completions, at);
104
124
  }
105
125
 
106
126
  // Present on the record that carries a subagent's result, whatever its type.
@@ -108,19 +128,86 @@ function scanMain(records) {
108
128
  if (tur && typeof tur === 'object' && tur.agentId) {
109
129
  const id = r.message?.content?.find?.((c) => c?.type === 'tool_result')?.tool_use_id
110
130
  ?? r.toolUseID ?? null;
111
- const entry = { agentId: tur.agentId, status: tur.status ?? null, model: tur.resolvedModel ?? null };
131
+ // `at` is when the answer was written: for a call the session waited on, that is when the agent reported.
132
+ const entry = { agentId: tur.agentId, status: tur.status ?? null, model: tur.resolvedModel ?? null, at };
112
133
  if (id) out.links.set(id, entry);
113
134
  else out.links.set(`agent:${tur.agentId}`, entry);
114
135
  }
115
- if (r?.type === 'attachment') harvestCompletions(JSON.stringify(r.attachment ?? ''), out.completions);
136
+ // A command sent to the background answers at once with the id it runs under; its end comes later, as a notice.
137
+ if (tur && typeof tur === 'object' && typeof tur.backgroundTaskId === 'string') {
138
+ const id = r.message?.content?.find?.((c) => c?.type === 'tool_result')?.tool_use_id ?? null;
139
+ out.background.set(tur.backgroundTaskId, { toolUseId: id, startedAt: at });
140
+ }
141
+ if (r?.type === 'attachment') harvestCompletions(JSON.stringify(r.attachment ?? ''), out.completions, at);
116
142
  }
117
143
 
118
144
  return out;
119
145
  }
120
146
 
147
+ /**
148
+ * 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,
149
+ * or an answer the agent has not replied to. A turn that is over ends on the agent's own words.
150
+ */
151
+ export function midTurn(records) {
152
+ for (let i = records.length - 1; i >= 0; i -= 1) {
153
+ const r = records[i];
154
+ if (r?.type !== 'assistant' && r?.type !== 'user') continue;
155
+ const content = r.message?.content;
156
+ if (r.type === 'user') return true;
157
+ return Array.isArray(content) && content.some((c) => c?.type === 'tool_use');
158
+ }
159
+ return false;
160
+ }
161
+
162
+ // How long after a notice an agent's own last record may be and still belong to the turn the notice ended. The
163
+ // notice is written after the agent's last record, so anything later than this is the agent working again.
164
+ const NOTICE_SLACK_MS = 5000;
165
+
166
+ /**
167
+ * Does a completion notice still say how the agent stands? Not when the agent wrote to its transcript after it:
168
+ * it was sent another message and is working, or has worked, again.
169
+ */
170
+ export function noticeStands(notice, lastOwnAt) {
171
+ if (!notice) return false;
172
+ if (notice.at == null || lastOwnAt == null) return true;
173
+ return lastOwnAt <= notice.at + NOTICE_SLACK_MS;
174
+ }
175
+
176
+ // 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.
177
+ const DELEGATES = new Set(['Agent', 'Task']);
178
+ const OPEN_DETAIL_MAX = 2000;
179
+
180
+ /**
181
+ * The tool calls a transcript has asked for and has no result for yet, oldest first.
182
+ *
183
+ * A call is open while it runs and while it waits for someone to allow it; the transcript does not say which. It
184
+ * is what a session started in a terminal is asking about when the machine reports it as waiting for its user —
185
+ * the panel has no hook in such a session, so this is the only place the question can be read from.
186
+ */
187
+ export function openCalls(records) {
188
+ const open = new Map();
189
+ for (const r of records) {
190
+ const content = r?.message?.content;
191
+ if (!Array.isArray(content)) continue;
192
+ for (const c of content) {
193
+ if (r.type === 'assistant' && c?.type === 'tool_use' && c.id && !DELEGATES.has(c.name)) {
194
+ const at = Date.parse(r.timestamp ?? '');
195
+ const detail = detailOf(c.input);
196
+ open.set(c.id, {
197
+ toolUseId: c.id, toolName: c.name ?? 'unknown',
198
+ detail: detail === null ? null : detail.slice(0, OPEN_DETAIL_MAX),
199
+ at: Number.isFinite(at) ? at : null,
200
+ });
201
+ } else if (r.type === 'user' && c?.type === 'tool_result' && c.tool_use_id) open.delete(c.tool_use_id);
202
+ }
203
+ }
204
+ return [...open.values()];
205
+ }
206
+
121
207
  /** Roll one agent's own transcript into the numbers its node shows. */
122
- async function scanAgent(file) {
208
+ async function scanAgent(file, usage = null) {
123
209
  const { records } = await readAll(file);
210
+ usage?.add(records);
124
211
  const stats = {
125
212
  tools: {}, toolCount: 0, lastTool: null, errors: 0,
126
213
  tokens: null, startedAt: null, endedAt: null, turns: 0,
@@ -164,10 +251,12 @@ async function scanAgent(file) {
164
251
  stats.durationMs = stats.startedAt !== null && stats.endedAt !== null
165
252
  ? stats.endedAt - stats.startedAt
166
253
  : null;
254
+ stats.midTurn = midTurn(records);
255
+ stats.open = openCalls(records);
167
256
  return stats;
168
257
  }
169
258
 
170
- async function readAgentDir(subagentsDir) {
259
+ async function readAgentDir(subagentsDir, usage = null) {
171
260
  const out = [];
172
261
  // Nested too: a workflow puts its agents under subagents/workflows/<id>/.
173
262
  for (const metaPath of await agentMetaFiles(subagentsDir)) {
@@ -185,7 +274,7 @@ async function readAgentDir(subagentsDir) {
185
274
  // `stats` needs the transcript read; a missing file is a normal state (the
186
275
  // agent has not written yet), so absence is a null rather than a throw.
187
276
  let stats = null;
188
- if (mtime !== null) stats = await scanAgent(jsonl);
277
+ if (mtime !== null) stats = await scanAgent(jsonl, usage);
189
278
 
190
279
  out.push({
191
280
  agentId,
@@ -220,7 +309,9 @@ export const STALE_MS = 120000;
220
309
  export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
221
310
  const { records, malformed } = await readAll(session.file);
222
311
  const main = scanMain(records);
223
- const agents = await readAgentDir(session.subagentsDir);
312
+ // What the session spent, its agents' tokens included and each response counted once.
313
+ const usage = new Usage().add(records);
314
+ const agents = await readAgentDir(session.subagentsDir, usage);
224
315
  const now = Date.now();
225
316
 
226
317
  // Ownership: session first, then every agent's own tool_use ids, so a nested
@@ -233,6 +324,8 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
233
324
  // toolUseId -> agentId, from the returning result record.
234
325
  const byToolUse = new Map();
235
326
  for (const [k, v] of main.links) if (!k.startsWith('agent:')) byToolUse.set(k, v);
327
+ const callOf = new Map();
328
+ for (const [k, v] of byToolUse) callOf.set(v.agentId, k);
236
329
 
237
330
  const nodes = [{
238
331
  id: SESSION_NODE,
@@ -248,13 +341,28 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
248
341
  tokens: contextFill(records),
249
342
  startedAt: main.startedAt,
250
343
  endedAt: main.updatedAt,
344
+ usage: { ...usage.result(), ...timesOf(records) },
345
+ // Commands sent to the background that no notice has ended, oldest first. A session that is over has none
346
+ // running whatever this says: the page shows them only while the session is live.
347
+ backgroundCommands: [...main.background]
348
+ .filter(([id]) => !main.completions.has(id))
349
+ .map(([id, b]) => ({ id, toolName: 'Bash', detail: main.commands.get(b.toolUseId) ?? null, startedAt: b.startedAt }))
350
+ .sort((x, y) => (x.startedAt ?? 0) - (y.startedAt ?? 0)),
351
+ // The newest call nothing has answered, the session's own or an agent's. Null when every call has its result.
352
+ openCall: [
353
+ ...openCalls(records).map((c) => ({ ...c, agentId: null, agentType: null })),
354
+ ...agents.flatMap((a) => (a.stats?.open ?? []).map((c) => ({ ...c, agentId: a.agentId, agentType: a.agentType ?? null }))),
355
+ ].sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0] ?? null,
251
356
  }];
252
357
  const edges = [];
253
358
 
254
359
  for (const a of agents) {
255
- const call = a.toolUseId ? main.agentCalls.get(a.toolUseId) : null;
256
- const link = a.toolUseId ? byToolUse.get(a.toolUseId) : null;
257
- const completion = main.completions.get(a.agentId) ?? null;
360
+ // The call that started it: named by its own meta file, or found from the answer that named the agent.
361
+ const callId = a.toolUseId ?? callOf.get(a.agentId) ?? null;
362
+ const call = callId ? main.agentCalls.get(callId) : null;
363
+ const link = callId ? byToolUse.get(callId) : null;
364
+ const notice = main.completions.get(a.agentId) ?? null;
365
+ const completion = noticeStands(notice, a.stats?.endedAt ?? null) ? notice.status : null;
258
366
 
259
367
  // Order matters. A synchronous Agent call never produces a task
260
368
  // notification — it reports through toolUseResult.status instead. Reading
@@ -288,6 +396,11 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
288
396
  toolUseId: a.toolUseId,
289
397
  model: link?.model ?? null,
290
398
  launchStatus: link?.status ?? null,
399
+ // Started in the background, and its transcript stops in the middle of a turn. Such an agent can be quiet
400
+ // for a long time inside one tool call; whether it is still working is the live session's to say, so the
401
+ // page decides (graph-plan.js `settle`), not this file.
402
+ background: link?.status === 'async_launched',
403
+ midTurn: a.stats?.midTurn ?? false,
291
404
  parentId,
292
405
  tools: a.stats?.tools ?? {},
293
406
  toolCount: a.stats?.toolCount ?? 0,
@@ -299,6 +412,15 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
299
412
  endedAt: a.stats?.endedAt ?? null,
300
413
  durationMs: a.stats?.durationMs ?? null,
301
414
  updatedAt: a.mtime,
415
+ // Where it stands among the agents the session called: its place in the order of the calls, the wave it
416
+ // was called in, and when. Null for an agent the session's own transcript did not call (one a workflow
417
+ // started): nothing is known about its place from here.
418
+ order: call?.order ?? null,
419
+ wave: call?.wave ?? null,
420
+ calledAt: call?.calledAt ?? null,
421
+ // When it handed its result back: the answer to a call the session waited on, or the notice that ended a
422
+ // background agent. Null while it has not, and when no time was recorded.
423
+ reportedAt: link?.status === 'completed' ? (link.at ?? null) : (completion ? (notice.at ?? null) : null),
302
424
  });
303
425
 
304
426
  edges.push({ id: `${parentId}->${a.agentId}`, source: parentId, target: a.agentId, kind: 'spawn' });
@@ -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