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.
- package/CHANGELOG.md +70 -4
- package/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +5 -2
- package/kit/eval/doctor.sh +26 -0
- package/kit/eval/smoke-test.sh +274 -18
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +93 -6
- package/kit/hooks/guard-schedule.sh +92 -0
- package/kit/hooks/prompt-approval.sh +68 -21
- package/kit/settings.json +11 -0
- package/kit/skills/crew-approve/SKILL.md +26 -0
- package/kit/studio/server/index.js +18 -0
- package/kit/studio/server/lib/graph.js +137 -15
- 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 +159 -38
- package/kit/studio/web/approvals.js +20 -1
- package/kit/studio/web/canvas.js +158 -30
- 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 +173 -13
- 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 +44 -0
- package/kit/studio/web/style.css +64 -10
- package/kit/studio/web/timeline-plan.js +13 -5
- package/kit/studio/web/timeline.js +10 -4
- package/kit/studio/web/usage.js +77 -0
- package/package.json +1 -1
|
@@ -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
|
|
12
|
-
#
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
388
|
-
|
|
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(), //
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
const
|
|
257
|
-
const
|
|
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
|
-
|
|
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
|