crewforth 3.2.0-rc.2 → 3.2.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +100 -3
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +10 -1
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +3 -3
  7. package/kit/agents/crew-review-agent.md +2 -0
  8. package/kit/eval/doctor.sh +13 -2
  9. package/kit/eval/gate-report.sh +2 -0
  10. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  11. package/kit/eval/lib/crew-map/project.mjs +3 -2
  12. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  13. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  14. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  15. package/kit/eval/lib/crew-map/surface.mjs +62 -1
  16. package/kit/eval/lib/gate-log.sh +52 -0
  17. package/kit/eval/lib/json-string.awk +27 -0
  18. package/kit/eval/lib/preset.sh +109 -0
  19. package/kit/eval/lib/presets/default.json +7 -0
  20. package/kit/eval/lib/presets/solo.json +7 -0
  21. package/kit/eval/lib/presets/strict.json +11 -0
  22. package/kit/eval/lib/preview.sh +150 -0
  23. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  24. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  25. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  26. package/kit/eval/lib/settings-json.awk +4 -0
  27. package/kit/eval/lib/statusline.sh +1 -1
  28. package/kit/eval/lib/verify-cmd.sh +17 -0
  29. package/kit/eval/review-bench.sh +26 -0
  30. package/kit/eval/smoke-test.sh +142 -20
  31. package/kit/hooks/board-sync.sh +2 -23
  32. package/kit/hooks/guard-agent-model.sh +32 -23
  33. package/kit/hooks/guard-bash.sh +36 -21
  34. package/kit/hooks/guard-write.sh +3 -7
  35. package/kit/hooks/precompact-snapshot.sh +75 -0
  36. package/kit/hooks/session-rehydrate.sh +25 -2
  37. package/kit/settings.json +12 -0
  38. package/kit/skills/crew-code-review/SKILL.md +16 -0
  39. package/kit/skills/crew-map/SKILL.md +16 -5
  40. package/kit/skills/crew-review/SKILL.md +2 -1
  41. package/kit/studio/README.md +21 -10
  42. package/kit/studio/server/index.js +74 -22
  43. package/kit/studio/server/lib/graph.js +9 -1
  44. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  45. package/kit/studio/server/lib/lan.js +87 -0
  46. package/kit/studio/server/lib/redact.js +60 -0
  47. package/kit/studio/server/lib/remote.js +104 -18
  48. package/kit/studio/server/lib/session.js +1 -1
  49. package/kit/studio/server/lib/terminal.js +51 -13
  50. package/kit/studio/server/lib/transcript.js +32 -0
  51. package/kit/studio/web/app.js +69 -7
  52. package/kit/studio/web/canvas.js +14 -2
  53. package/kit/studio/web/chat.js +32 -7
  54. package/kit/studio/web/graph-plan.js +3 -2
  55. package/kit/studio/web/map-plan.js +72 -3
  56. package/kit/studio/web/map.js +103 -10
  57. package/kit/studio/web/qr.js +200 -0
  58. package/kit/studio/web/style.css +34 -0
  59. package/kit/studio/web/tokens.css +6 -0
  60. package/package.json +1 -1
  61. package/start.sh +14 -1
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # PreCompact hook — just before the context is compacted, write down the state of the work in a few lines, so the
3
+ # session that comes back after the compaction is told it (session-rehydrate.sh reads it on SessionStart, source
4
+ # compact). A compaction summary is the model's own retelling; this is what the files and the gates say.
5
+ #
6
+ # What it writes, at most ~2 KB: the branch, the uncommitted files (five named, the rest counted), the last refusal in
7
+ # the gate log, how many findings each document under .crewforth/ holds, and whether a handover
8
+ # (docs/SESSION_STATE.md) is there. With the session id and the trigger, so a snapshot is only read back into the
9
+ # session that wrote it.
10
+ #
11
+ # Where: inside the repository's git directory (crewforth-session-snapshot.json), so it is never committed even in a
12
+ # project that tracks .claude/; outside a repository, .claude/state/session-snapshot.json.
13
+ #
14
+ # NEVER BLOCKS. PreCompact exit 2 blocks the compaction (docs: hooks, PreCompact), and blocking an automatic one at
15
+ # the context limit fails the request that needed it. Every path here ends in exit 0, and nothing is printed: a
16
+ # PreCompact hook's output does not reach the summary.
17
+ set -u
18
+ IN="$(cat 2>/dev/null || true)"
19
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
20
+ LIB="$_here/../eval/lib"
21
+
22
+ ROOT="${CLAUDE_PROJECT_DIR:-}"
23
+ [ -n "$ROOT" ] || ROOT="$(printf '%s' "$IN" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
24
+ [ -n "$ROOT" ] || ROOT="$PWD"
25
+ ROOT="${ROOT//\\\\//}"; ROOT="${ROOT//\\//}"
26
+ cd "$ROOT" 2>/dev/null || exit 0
27
+
28
+ field(){ local v="${IN#*\"$1\"}"; [ "$v" = "$IN" ] && return 1; v="${v#*:}"; v="${v#"${v%%[![:space:]]*}"}"; case "$v" in \"*) v="${v#\"}"; printf '%s' "${v%%\"*}" ;; *) printf '%s' "${v%%[,\}]*}" ;; esac; }
29
+ SID="$(field session_id)" || SID=""
30
+ TRIG="$(field trigger)" || TRIG=""
31
+ case "$SID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac # a snapshot no session can claim is not written
32
+
33
+ # ---- what the files and the gates say ----
34
+ LINES=()
35
+ GD=""
36
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
37
+ GD="$(git rev-parse --absolute-git-dir 2>/dev/null)" || GD=""
38
+ BR="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || BR=""
39
+ [ -n "$BR" ] && LINES+=("branch $BR")
40
+ # quotePath off: a name in Turkish (or any non-ASCII) is given as written, not as octal escapes (seen on Windows).
41
+ CH="$(git -c core.quotePath=false status --porcelain 2>/dev/null)" || CH=""
42
+ if [ -n "$CH" ]; then
43
+ n=0; names=""
44
+ while IFS= read -r l; do n=$((n+1)); [ "$n" -le 5 ] && names="${names:+$names, }${l:3}"; done <<< "$CH"
45
+ LINES+=("$n uncommitted file(s): $names$([ "$n" -gt 5 ] && printf ', and %d more' $((n-5)))")
46
+ else LINES+=("nothing uncommitted"); fi
47
+ fi
48
+ if [ -f "$LIB/gate-log.sh" ]; then
49
+ . "$LIB/gate-log.sh"; crew_gate_log_read
50
+ if [ -n "${_CREW_GL:-}" ] && [ -f "$_CREW_GL" ]; then
51
+ last=""; while IFS= read -r l; do case "$l" in BLOCK$'\t'*) last="$l" ;; esac; done < "$_CREW_GL"
52
+ if [ -n "$last" ]; then IFS=$'\t' read -r _ rule why _ <<< "$last"; LINES+=("the last refusal in the gate log: ${rule} ${why:0:120}"); fi
53
+ fi
54
+ fi
55
+ fnd=""
56
+ for f in .crewforth/*.json; do
57
+ [ -f "$f" ] || continue
58
+ c=0; while IFS= read -r l; do case "$l" in *'"rule": '*) c=$((c+1)) ;; esac; done < "$f"
59
+ [ "$c" -gt 0 ] && { b="${f##*/}"; fnd="${fnd:+$fnd, }${b%.json} $c"; }
60
+ done
61
+ [ -n "$fnd" ] && LINES+=("findings on record: $fnd")
62
+ [ -s docs/SESSION_STATE.md ] && LINES+=("a handover is at docs/SESSION_STATE.md")
63
+ [ "${#LINES[@]}" -gt 0 ] || exit 0
64
+
65
+ SUM=""; for l in "${LINES[@]}"; do SUM="${SUM:+$SUM; }$l"; done
66
+ SUM="${SUM:0:1800}"
67
+
68
+ # ---- where it goes ----
69
+ if [ -n "$GD" ] && [ -d "$GD" ]; then OUT="$GD/crewforth-session-snapshot.json"
70
+ else mkdir -p .claude/state 2>/dev/null || exit 0; OUT=".claude/state/session-snapshot.json"; fi
71
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v NOW '%(%Y-%m-%dT%H:%M:%SZ)T' -1; else NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; fi
72
+ ESC="$(printf '%s\n' "$SUM" | LC_ALL=C awk -f "$LIB/json-string.awk" 2>/dev/null)" || exit 0
73
+ printf '{"session_id":"%s","trigger":"%s","at":"%s","summary":"%s"}\n' "$SID" "${TRIG//[!a-z]/}" "$NOW" "$ESC" > "$OUT.$$" 2>/dev/null && mv -f "$OUT.$$" "$OUT" 2>/dev/null
74
+ rm -f "$OUT.$$" 2>/dev/null
75
+ exit 0
@@ -68,11 +68,34 @@ if [ -f "$ROOT/.claude/hooks/pre-commit" ] && git -C "$ROOT" rev-parse --git-dir
68
68
  esac
69
69
  fi
70
70
  [ -n "$HOOKMSG" ] && MSG="${MSG:+$MSG }$HOOKMSG"
71
- [ -n "$MSG" ] || exit 0 # no handover to offer and nothing to say about language -> stay silent
71
+
72
+ # THE STATE BEFORE THE COMPACTION. precompact-snapshot.sh wrote it just before this context was compacted: the
73
+ # branch, the uncommitted files, the last gate refusal, the findings on record. Read back only after a compaction and
74
+ # only into the session that wrote it (same session id); its summary is already JSON-escaped, so it is put into the
75
+ # output as it is, and the rest of MSG stays fixed text that needs no escaping and no extra process.
76
+ SNAP=""
77
+ _rh="${BASH_SOURCE%[/\\]*}"; [ "$_rh" = "${BASH_SOURCE}" ] && _rh=.
78
+ if [ "$SRC" = compact ] && [ -f "$_rh/../eval/lib/gate-log.sh" ]; then
79
+ . "$_rh/../eval/lib/gate-log.sh"
80
+ _sf=""
81
+ if crew_git_dir "$ROOT" && [ -f "$_CREW_GD/crewforth-session-snapshot.json" ]; then _sf="$_CREW_GD/crewforth-session-snapshot.json"
82
+ elif [ -f "$ROOT/.claude/state/session-snapshot.json" ]; then _sf="$ROOT/.claude/state/session-snapshot.json"; fi
83
+ _sid(){ _SID=""; local v="${1#*\"session_id\"}"; [ "$v" = "$1" ] && return 0; v="${v#*\"}"; _SID="${v%%\"*}"; }
84
+ if [ -n "$_sf" ] && IFS= read -r _sj < "$_sf" 2>/dev/null; then
85
+ _sid "$IN"; _mine="$_SID"; _sid "$_sj"
86
+ case "$_sj" in *'"summary":"'*)
87
+ if [ -n "$_mine" ] && [ "$_mine" = "$_SID" ]; then
88
+ _sum="${_sj#*\"summary\":\"}"; _sum="${_sum%\"\}*}"
89
+ SNAP="Before this context was compacted, Crewforth wrote down the state of the work from the files and the gates: $_sum. It is a record of that moment; check the files before relying on it."
90
+ fi ;;
91
+ esac
92
+ fi
93
+ fi
94
+ [ -n "$MSG$SNAP" ] || exit 0 # nothing to offer -> stay silent
72
95
 
73
96
  # hookSpecificOutput.additionalContext is the documented channel that injects text into the model's context.
74
97
  # ONE PATH, no jq: MSG is built from fixed constants with no quote, backslash, control character or newline, so it needs no
75
98
  # escaping and every machine emits the same bytes. If you ever make MSG dynamic, escape it the way board-sync.sh
76
99
  # does (jq-identical, measured) — a raw quote or tab here makes JSON the CLI silently drops.
77
- printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$MSG"
100
+ printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s%s%s"}}\n' "$MSG" "${MSG:+${SNAP:+ }}" "$SNAP"
78
101
  exit 0
package/kit/settings.json CHANGED
@@ -216,6 +216,18 @@
216
216
  ]
217
217
  }
218
218
  ],
219
+ "PreCompact": [
220
+ {
221
+ "hooks": [
222
+ {
223
+ "type": "command",
224
+ "shell": "bash",
225
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/precompact-snapshot.sh",
226
+ "timeout": 30
227
+ }
228
+ ]
229
+ }
230
+ ],
219
231
  "SessionStart": [
220
232
  {
221
233
  "matcher": "startup|resume|clear|compact|fork",
@@ -17,6 +17,21 @@ Trigger phrases: "code-review", "review the code", "review the PR", "review my c
17
17
  A review runs in four steps — **group, plan, review, fact-check** — and reports few findings that are right rather
18
18
  than many that might be. Every step below says what it produces.
19
19
 
20
+ ## 0. The pre-pass first, and the findings in one shape
21
+ Before reading the diff, run the deterministic pre-pass (`eval/lib/review-prepass/` in either edition):
22
+ `bash <dir>/prepass.sh` for the staged change, `--base <ref>` for a branch. It writes
23
+ `.crewforth/review-prepass.json`: secret patterns, a debug call or TODO left in code, shellcheck on shell files, a
24
+ lock file that moved without its manifest, a very large change. **Carry those over, do not re-derive them**; your
25
+ time goes to what a pattern cannot see. rc 3 (no Node) or a `notMeasured` line means that part was NOT checked:
26
+ say so in the review, never read it as clean.
27
+
28
+ Write your own findings in the same shape (`crewforth.findings/1`: `rule` `review-<category>`, `severity`,
29
+ `confidence`, `evidence` with `file`, `line` and a `text` quote, `detail` = what to do instead) and store them with
30
+ `bash <dir>/../findings/findings.sh write review <file>`. A `critical` or `high` finding first goes to
31
+ **crew-refuter** (claim + `file:line`, one call per lens, in parallel) and the document is written with
32
+ `--verified`, which then refuses any `medium`-or-higher finding without its verifiers. This is model discipline:
33
+ no hook runs the pre-pass or reads `review.json`.
34
+
20
35
  ## 1. Scope
21
36
  - **Review the lines this change adds or edits.** Deleted code is context for understanding them, not a target.
22
37
  - Code that is correct, or that the change did not touch, gets no comment.
@@ -142,6 +157,7 @@ five questions that decide *answer in the thread* vs *edit the code*: **`referen
142
157
  item leaves with a disposition from the triage table above — a reasoned refusal is an answer, silence is not.
143
158
 
144
159
  ## DoD (this skill's contribution)
160
+ - The pre-pass ran (or its rc 3 / `notMeasured` is reported as unchecked), and the findings are in `.crewforth/review.json`.
145
161
  - The plan (what changed, the risks) came before the findings; every file in each group was read.
146
162
  - Every finding has a severity, a category and a reason, in the finding format above.
147
163
  - Scope creep and hidden complexity are flagged.
@@ -27,18 +27,29 @@ the scan itself read, and a gate holds it to that. **Never commit.** Show the di
27
27
 
28
28
  3. **Describe.** Read `.crewforth/crew-map.json`, and as much of the cited source as you need. Then edit that
29
29
  file, changing ONLY these fields:
30
- - each `module` node's `narrative`: `{ "text": "…", "cites": ["file:line", …] }`. What the module is for, in
31
- at most two sentences. Every cite is a `file:line` from THAT node's own `evidence`; cite at least one. Leave a
32
- node `null` when its evidence does not tell you what it is for: an honest gap beats a guess.
30
+ - each `module` node's `narrative`: `{ "text": "…", "cites": ["file:line", …], "layer": "…", "domain": "…" }`.
31
+ What the module is for, in at most two sentences. Every cite is a `file:line` the scan read for THAT node: its
32
+ `evidence`, its `entryPoint`, or its `about` (the first prose line of its README); cite at least one. `layer` is where it sits, one of `ui` (what a person uses: an app, a site),
33
+ `api` (serves requests to others), `service` (runs work: a worker, a job), `data` (holds or shapes data),
34
+ `infra` (deploys and runs the rest), `tool` (a script or CLI for the team), `library` (code other modules
35
+ import); `domain` is the part of the business it serves, a short phrase ("payments", "mail import"). Both
36
+ are optional and rest on the same cites: leave one out rather than guess. Leave a node `null` when its
37
+ evidence does not tell you what it is for: an honest gap beats a guess.
33
38
  - the document's `overview`: the same shape, at most 2000 characters, for someone new to the project: the entry
34
39
  points, where data goes, the risky places. Its cites may be any node's evidence.
40
+ - the document's `tour`: `[{ "node": "<id>", "text": "…", "cites": ["file:line"] }, …]`, one to eight stops a
41
+ newcomer reads in order, usually the entry point first and the riskiest place last. Each stop names a node of
42
+ the map, says in at most two sentences why to look there, and cites lines from that node's own evidence.
35
43
  Describe what the cited lines show. A connection, a purpose or a risk the scan did not find is not added here:
36
44
  say it to the user instead.
37
45
 
38
46
  4. **Validate:** `bash MAP --validate` (Bash tool, not PowerShell). It refuses a description that cites a line
39
- the scan did not read, cites nothing, or runs past two sentences, and a map whose scanner fields were changed.
47
+ the scan did not read, cites nothing, or runs past two sentences; a layer not on the list or a domain written
48
+ as a sentence; a tour stop at no node, citing another node's lines, or at a node twice; and a map whose
49
+ scanner fields were changed.
40
50
  Fix what it names, in the descriptions only, until it holds.
41
51
 
42
- 5. **Hand over:** the scan's summary line, how many modules you described and how many you left `null` and why,
52
+ 5. **Hand over:** the scan's summary line, how many modules you described (and gave a layer) and how many you
53
+ left `null` and why, the tour's stops,
43
54
  then `git diff --stat .crewforth/crew-map.json`. Studio's **Map** view (`/crew-studio`) draws the result.
44
55
  Committing it is the user's call.
@@ -16,7 +16,8 @@ Run the change set through the review trio (read-only). **The audits go out in p
16
16
  - @agent-crew-privacy-agent (`privacy-compliance`) — **when the diff touches personal data**: legal basis,
17
17
  minimisation, retention, transfer.
18
18
  2. (if SonarQube is in use) **sonarqube-check** — 0/0/0/0 gate (language-agnostic).
19
- 3. **Last, once 1-2 leave no blocker:** @agent-crew-review-agent (crew-code-review) — "does it improve overall
19
+ 3. **Last, once 1-2 leave no blocker:** @agent-crew-review-agent (crew-code-review) — it starts from the
20
+ deterministic pre-pass (`.crewforth/review-prepass.json`) and writes `.crewforth/review.json`; "does it improve overall
20
21
  code health": the plan (what changed, the risks), then findings with a severity and a category. "Clean" means
21
22
  what crew-code-review means by it: **no critical or high finding open**. A medium or low audit finding does not
22
23
  hold the reviewer back; hand it over in the reviewer's prompt and keep it in the report. A critical or high one
@@ -201,14 +201,23 @@ loopback and the tunnel does the crossing.
201
201
  ## Remote viewers
202
202
 
203
203
  A viewer is a second token that reads and changes nothing, for watching the
204
- panel from another device without handing that device the panel. Add one in the
205
- Machines tab; its link is shown once. The server refuses a viewer's every
206
- request that is not a `GET`, and its reads of `/api/remote`, with 403 before any
207
- handler runs (`server/lib/remote.js`). Revoke ends the token and closes a
208
- stream it holds open. Viewers live in memory and end with the server.
209
-
210
- The server still binds to loopback. Reaching it from a phone is a tunnel the
211
- user runs, an ssh forward or `tailscale serve 7777`; Studio starts none.
204
+ panel from a phone on the same Wi-Fi without handing it the panel. Add one in
205
+ the Machines tab; its link is shown once, as a QR code (`web/qr.js`, made in the
206
+ page). The server refuses a viewer's every request that is not a `GET`, and its
207
+ reads of `/api/remote`, with 403 before any handler runs (`server/lib/remote.js`).
208
+ A viewer lasts eight hours, then its link answers 401 and the page says Session
209
+ expired. Revoke ends the token at once and closes a stream it holds open.
210
+ Viewers live in memory and end with the server.
211
+
212
+ Transcripts are hidden unless the viewer was added with them shown: the routes
213
+ that are only transcript are refused, and every other answer, the event stream
214
+ included, passes through `server/lib/redact.js`, which drops every string whose
215
+ key is not on its allow-list.
216
+
217
+ While a viewer is live the server also listens on the machine's private Wi-Fi
218
+ address (`server/lib/lan.js`), and there it takes a viewer's token only, never
219
+ the panel's. With none, it is on loopback alone. Every viewer connection and
220
+ refusal is logged (`GET /api/remote`, and the server's output).
212
221
 
213
222
  ## What Crewforth already measured
214
223
 
@@ -261,8 +270,10 @@ channel that does not exist.
261
270
 
262
271
  ## Security
263
272
 
264
- The server binds to `127.0.0.1` only, and the host is deliberately not
265
- configurable — it reads a developer's live sessions and can start new ones.
273
+ The server binds to `127.0.0.1`, and the host is deliberately not configurable —
274
+ it reads a developer's live sessions and can start new ones. The one exception is
275
+ the Wi-Fi listener above: open only while a viewer is live, for viewer tokens
276
+ only, read-only.
266
277
 
267
278
  A token is always required for `/api/`. Supply one, or let the server generate
268
279
  one and print it with the URL:
@@ -31,14 +31,17 @@ import { palette } from './lib/palette.js';
31
31
  import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
32
32
  import { parsePeers, askAll, ask } from './lib/peers.js';
33
33
  import {
34
- createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES,
34
+ createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES, isHeldOpen,
35
35
  } from './lib/session.js';
36
36
  import { decide, pending, alwaysList, revoke, sweepLeft } from './lib/permissions.js';
37
- import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
37
+ import { open as openTerminal, plan as terminalPlan, desktopLine } from './lib/terminal.js';
38
38
  import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
39
39
  import { writeState, clearStateSync, findRunning } from './lib/instance.js';
40
40
  import { remoteRoster } from './lib/roster.js';
41
- import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, viewerMay, MAX_VIEWERS } from './lib/remote.js';
41
+ import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, noteRefusal, viewerMay, MAX_VIEWERS, VIEWER_TTL_MS,
42
+ liveCount, remoteLog, sweep as sweepViewers, onViewersChange, onLog } from './lib/remote.js';
43
+ import { redact, transcriptOnly } from './lib/redact.js';
44
+ import { lanListener } from './lib/lan.js';
42
45
  import { randomUUID } from 'node:crypto';
43
46
  import { spawn } from 'node:child_process';
44
47
 
@@ -98,8 +101,12 @@ function send(res, status, body, headers = {}) {
98
101
  res.end(body);
99
102
  }
100
103
 
104
+ // The answers that go to a viewer whose link hides transcripts: every one of them passes through redact() on its way
105
+ // out, here and in the event stream, so no route has to remember to.
106
+ const REDACTED = new WeakSet();
107
+
101
108
  function sendJson(res, status, value) {
102
- send(res, status, JSON.stringify(value, null, 2), {
109
+ send(res, status, JSON.stringify(REDACTED.has(res) ? redact(value) : value, null, 2), {
103
110
  'content-type': 'application/json; charset=utf-8',
104
111
  });
105
112
  }
@@ -114,8 +121,12 @@ function sendJson(res, status, value) {
114
121
  const TOKEN = process.env.CREW_STUDIO_TOKEN || randomUUID();
115
122
  const TOKEN_GENERATED = !process.env.CREW_STUDIO_TOKEN;
116
123
 
124
+ // The Wi-Fi listener (lib/lan.js): set by main() once the port is known, and opened and closed with the number of
125
+ // live viewers. Null in the self-check unless it makes one.
126
+ let LAN = null;
127
+
117
128
  /** For the self-check, which asks a real server as the owner and as a viewer. */
118
- export const _internals = { token: TOKEN };
129
+ export const _internals = { token: TOKEN, setLan: (l) => { LAN = l; } };
119
130
 
120
131
  // Peers are other machines running Studio, reached over a forwarded port. This
121
132
  // server still listens on loopback only; a peer never widens that.
@@ -130,9 +141,12 @@ function whoAsks(req, url) {
130
141
  const header = req.headers['authorization'];
131
142
  const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : null;
132
143
  const given = [bearer, url.searchParams.get('token')].filter(Boolean);
133
- if (given.includes(TOKEN)) return { role: 'owner' };
144
+ // The panel's own token is accepted on loopback only. From the Wi-Fi it is not a token at all: a phone holding it
145
+ // would hold the panel, and nothing on the network should.
146
+ if (!req.crewLan && given.includes(TOKEN)) return { role: 'owner' };
134
147
  for (const t of given) {
135
148
  const viewer = viewerByToken(t);
149
+ if (viewer?.expired) return { role: 'expired', label: viewer.expired };
136
150
  if (viewer) return { role: 'viewer', viewer };
137
151
  }
138
152
  return null;
@@ -247,14 +261,30 @@ export async function handle(req, res) {
247
261
  // it for /style.css served an unstyled page and protected nothing: the shell
248
262
  // holds no secrets, and every /api/ path below is still gated.
249
263
  const who = url.pathname.startsWith('/api/') ? whoAsks(req, url) : null;
264
+ // From the Wi-Fi, no token (or the panel's own) is 401: the network is asked who it is. On loopback the answer
265
+ // stays 403, as it always was.
250
266
  if (url.pathname.startsWith('/api/') && !who) {
251
- return sendJson(res, 403, { ok: false, reason: 'token required' });
267
+ if (req.crewLan) noteRefusal(req, { status: 401, reason: 'no viewer token' });
268
+ return sendJson(res, req.crewLan ? 401 : 403, { ok: false, reason: 'token required' });
269
+ }
270
+ if (who?.role === 'expired') {
271
+ noteRefusal(req, { viewer: who.label, status: 401, reason: 'expired' });
272
+ return sendJson(res, 401, { ok: false, expired: true, reason: 'this link has expired: viewer links last eight hours' });
252
273
  }
253
274
  // A viewer is refused HERE, before any handler: no route below has to remember that a viewer exists, and a
254
- // route added tomorrow is read-only for a viewer without anyone writing a line for it.
275
+ // route added tomorrow is read-only for a viewer without anyone writing a line for it. The same holds for a
276
+ // link that hides transcripts: the routes that are nothing but transcript are refused here, and every other
277
+ // answer is filtered on its way out (REDACTED).
255
278
  if (who?.role === 'viewer') {
256
279
  const may = viewerMay(req.method, url.pathname);
257
- if (!may.ok) return sendJson(res, 403, { ok: false, reason: may.reason });
280
+ if (!may.ok) { noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: may.reason }); return sendJson(res, 403, { ok: false, reason: may.reason }); }
281
+ if (!who.viewer.transcripts) {
282
+ if (transcriptOnly(url.pathname)) {
283
+ noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: 'transcripts hidden' });
284
+ return sendJson(res, 403, { ok: false, transcriptsHidden: true, reason: 'transcripts are hidden on this link' });
285
+ }
286
+ REDACTED.add(res);
287
+ }
258
288
  noteRequest(who.viewer, req, res);
259
289
  }
260
290
 
@@ -262,6 +292,7 @@ export async function handle(req, res) {
262
292
  return sendJson(res, 200, {
263
293
  ok: true,
264
294
  role: who.role,
295
+ ...(who.role === 'viewer' ? { transcripts: who.viewer.transcripts, expiresAt: who.viewer.expiresAt } : {}),
265
296
  node: process.version,
266
297
  pid: process.pid,
267
298
  uptimeMs: Math.round(process.uptime() * 1000),
@@ -502,8 +533,12 @@ export async function handle(req, res) {
502
533
  const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
503
534
 
504
535
  if (req.method === 'GET') {
505
- // What WOULD run, so the panel can show it before anything is launched.
506
- return sendJson(res, 200, { measured: true, cwd, plan: cwd ? terminalPlan({ cwd, sessionId: id }) : null });
536
+ // What WOULD run, so the panel can show it before anything is launched; and, before Continue here is pressed,
537
+ // what it will do: continue this same session (nothing else holds it), or start a copy (something does). The
538
+ // answer is the one createSession acts on, asked the same way.
539
+ const held = await isHeldOpen(id);
540
+ return sendJson(res, 200, { measured: true, cwd, plan: cwd ? terminalPlan({ cwd, sessionId: id }) : null,
541
+ continueAs: held ? 'copy' : 'same', desktop: cwd ? desktopLine({ cwd, sessionId: id }) : null });
507
542
  }
508
543
 
509
544
  const gate = writeAllowed(req);
@@ -515,7 +550,6 @@ export async function handle(req, res) {
515
550
  const out = openTerminal({
516
551
  cwd: typeof body.cwd === 'string' ? body.cwd : cwd,
517
552
  sessionId: id,
518
- command: typeof body.command === 'string' ? body.command : undefined,
519
553
  });
520
554
  return sendJson(res, out.ok ? 200 : 400, out);
521
555
  }
@@ -529,7 +563,8 @@ export async function handle(req, res) {
529
563
  the owner reaches this far; a viewer was refused above. */
530
564
 
531
565
  if (url.pathname === '/api/remote' && req.method === 'GET') {
532
- return sendJson(res, 200, { measured: true, viewers: listViewers(), max: MAX_VIEWERS, now: Date.now() });
566
+ return sendJson(res, 200, { measured: true, viewers: listViewers(), max: MAX_VIEWERS, ttlMs: VIEWER_TTL_MS, now: Date.now(),
567
+ lan: LAN ? LAN.state() : { listening: false, addresses: [], errors: [] }, log: remoteLog(30) });
533
568
  }
534
569
 
535
570
  const remoteMatch = url.pathname.match(/^\/api\/remote(?:\/([^/]+))?$/);
@@ -539,11 +574,16 @@ export async function handle(req, res) {
539
574
  if (req.method === 'POST' && !remoteMatch[1]) {
540
575
  const body = await readBody(req);
541
576
  if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
542
- const made = addViewer(body.label);
543
- return sendJson(res, made.ok ? 201 : 409, { ...made, viewers: listViewers() });
577
+ const made = addViewer(body.label, { transcripts: body.transcripts === true });
578
+ // The Wi-Fi listener opens with the first viewer; its links are known once it listens.
579
+ if (made.ok && LAN) await LAN.sync(liveCount());
580
+ const lanUrls = made.ok && LAN ? LAN.urls(made.viewer.token) : [];
581
+ return sendJson(res, made.ok ? 201 : 409, { ...made, lanUrls, lan: LAN ? LAN.state() : null, viewers: listViewers() });
544
582
  }
545
583
  if (req.method === 'DELETE' && remoteMatch[1]) {
546
- return sendJson(res, 200, { ...revokeViewer(decodeURIComponent(remoteMatch[1])), viewers: listViewers() });
584
+ const out = revokeViewer(decodeURIComponent(remoteMatch[1]));
585
+ if (LAN) await LAN.sync(liveCount());
586
+ return sendJson(res, 200, { ...out, lan: LAN ? LAN.state() : null, viewers: listViewers() });
547
587
  }
548
588
  return sendJson(res, 405, { ok: false, reason: 'POST to add a viewer, or DELETE a viewer id' });
549
589
  }
@@ -744,7 +784,7 @@ async function stream(req, res, url) {
744
784
  });
745
785
 
746
786
  const send = (event, data) => {
747
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
787
+ res.write(`event: ${event}\ndata: ${JSON.stringify(REDACTED.has(res) ? redact(data) : data)}\n\n`);
748
788
  };
749
789
 
750
790
  // A session the panel just started has no transcript until its first turn
@@ -988,9 +1028,11 @@ async function main() {
988
1028
  ' --help this text\n\n' +
989
1029
  'Set CREW_STUDIO_TOKEN to require a bearer token, CREW_STUDIO_PEERS for a\n' +
990
1030
  'comma-separated peer list.\n\n' +
991
- 'To watch from another device, add a viewer under Machines in the panel\n' +
992
- 'and open its link through a tunnel of your own. A viewer reads and\n' +
993
- 'changes nothing, and the server stays on loopback.\n\n' +
1031
+ 'To watch from a phone on the same Wi-Fi, add a viewer under Machines in\n' +
1032
+ 'the panel and scan its QR code. A viewer reads and changes nothing, its\n' +
1033
+ 'link lasts eight hours, and transcripts stay hidden unless you show them.\n' +
1034
+ 'While a viewer is live the server also listens on this machine\'s Wi-Fi\n' +
1035
+ 'address, for viewer links only; with none it is on loopback alone.\n\n' +
994
1036
  'Peers are normally reached over a forwarded port, which keeps every\n' +
995
1037
  'Studio on loopback:\n' +
996
1038
  ' ssh -N -L 7778:127.0.0.1:7777 other-machine\n' +
@@ -1007,12 +1049,21 @@ async function main() {
1007
1049
  // Only a server that is about to run does this; asking for --help or --selftest removes nothing.
1008
1050
  sweepLeft();
1009
1051
 
1010
- const server = http.createServer((req, res) => {
1052
+ const handler = (req, res) => {
1011
1053
  handle(req, res).catch((e) => {
1012
1054
  process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
1013
1055
  if (!res.headersSent) send(res, 500, 'internal error');
1014
1056
  });
1015
- });
1057
+ };
1058
+ const server = http.createServer(handler);
1059
+
1060
+ // Viewers on the same Wi-Fi: a second listener on this machine's private address, open only while a viewer is
1061
+ // live (lib/lan.js). It closes when the last one is revoked or expires; the sweep ends expired ones even when no
1062
+ // request comes to notice them.
1063
+ LAN = lanListener({ handler, port: args.port });
1064
+ onViewersChange((n) => { LAN.sync(n); });
1065
+ onLog((e) => process.stdout.write(` viewer ${e.event}: ${e.viewer ?? 'unknown'}${e.ip ? ` from ${e.ip}` : ''}${e.over ? ` (${e.over})` : ''}${e.result ? ` — ${e.result}` : ''}\n`));
1066
+ setInterval(sweepViewers, 60 * 1000).unref();
1016
1067
 
1017
1068
  // A BUSY PORT IS NOT AUTOMATICALLY A PROBLEM. Before 2.10.2 this printed "try --port n+1" and exited 1, which
1018
1069
  // is the right answer only when a stranger holds the port. When the holder is our OWN panel -- the common case,
@@ -1093,6 +1144,7 @@ async function main() {
1093
1144
  // leaks running sessions is worse than one that never started them.
1094
1145
  stopAll();
1095
1146
  dropSpools();
1147
+ LAN?.close();
1096
1148
  server.close(() => process.exit(0));
1097
1149
  setTimeout(() => process.exit(0), 2000).unref();
1098
1150
  });
@@ -17,7 +17,7 @@
17
17
  import fsp from 'node:fs/promises';
18
18
  import path from 'node:path';
19
19
 
20
- import { readAll, contextFill } from './transcript.js';
20
+ import { readAll, contextFill, contextMix } from './transcript.js';
21
21
  import { agentMetaFiles } from './projects.js';
22
22
  import { Usage, withTimes } from './usage.js';
23
23
  import { detailOf } from './permissions.js';
@@ -139,6 +139,7 @@ export function linkRetries(calls) {
139
139
  if (!c.refused) return;
140
140
  const again = list.slice(i + 1).find((n) => n.subagentType === c.subagentType && n.description === c.description);
141
141
  if (!again) return;
142
+ c.retried = true;
142
143
  // A call refused twice keeps the first thing that was asked.
143
144
  again.firstTry = c.firstTry ?? { asked: c.model, floor: c.refused.floor, text: c.refused.text };
144
145
  });
@@ -456,7 +457,14 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
456
457
  version: main.version,
457
458
  status: 'session',
458
459
  turns: main.userTurns,
460
+ // Agent calls the model gate refused and the session has not made again: no agent started, so nothing else on
461
+ // the canvas says they happened (seen on a real project: two refused calls, an empty canvas, "2 failed" only in
462
+ // the session's own view). A refused call made again is shown on the call that ran (`firstTry`).
463
+ refusedCalls: [...main.agentCalls.values()].filter((c) => c.refused && !c.retried)
464
+ .map((c) => ({ agent: c.subagentType ?? null, model: c.model ?? null, text: c.refused.text })),
459
465
  tokens: contextFill(records),
466
+ // What that context is made of: the tool-output share since the last compaction, and the compactions so far.
467
+ context: contextMix(records),
460
468
  startedAt: main.startedAt,
461
469
  endedAt: main.updatedAt,
462
470
  usage: withTimes(usage, records),
@@ -52,11 +52,36 @@ function kitPaths(cwd) {
52
52
  * empty unless CREW_GATE_LOG_CMD=1 was set, and there is no timestamp at all —
53
53
  * both facts are returned rather than papered over.
54
54
  */
55
+ /**
56
+ * Where the gate log is, by the rule the gates write by (eval/lib/gate-log.sh): .claude/gate-log.tsv, else the
57
+ * repository's own .git/crewforth-gate-log.tsv (a project that tracks .claude/ has it there), searched from the
58
+ * project root up. null when there is none yet.
59
+ */
60
+ export function gateLogPath(cwd) {
61
+ const own = path.join(cwd, '.claude', 'gate-log.tsv');
62
+ if (fs.existsSync(own)) return own;
63
+ let d = cwd;
64
+ for (let n = 0; n < 12; n += 1) {
65
+ const g = path.join(d, '.git');
66
+ let st = null;
67
+ try { st = fs.statSync(g); } catch { /* not here */ }
68
+ if (st?.isDirectory()) { const f = path.join(g, 'crewforth-gate-log.tsv'); return fs.existsSync(f) ? f : null; }
69
+ if (st?.isFile()) {
70
+ const m = /^gitdir: (.+)$/m.exec(fs.readFileSync(g, 'utf8'));
71
+ if (!m) return null;
72
+ const gd = path.resolve(d, m[1].trim()); const f = path.join(gd, 'crewforth-gate-log.tsv');
73
+ return fs.existsSync(f) ? f : null;
74
+ }
75
+ const up = path.dirname(d); if (up === d) return null; d = up;
76
+ }
77
+ return null;
78
+ }
79
+
55
80
  export function gateLog(cwd, { limit = 200 } = {}) {
56
- const file = path.join(cwd, '.claude', 'gate-log.tsv');
81
+ const file = gateLogPath(cwd);
57
82
  let st;
58
- try { st = fs.statSync(file); } catch {
59
- return { measured: false, reason: 'no .claude/gate-log.tsv in this project', entries: [] };
83
+ try { st = fs.statSync(file ?? ''); } catch {
84
+ return { measured: false, reason: 'no gate log in this project yet (.claude/gate-log.tsv, or .git/crewforth-gate-log.tsv where .claude/ is tracked)', entries: [] };
60
85
  }
61
86
 
62
87
  // Only the tail: this file reached 21,730 lines in Crewforth's own checkout and
@@ -119,9 +144,9 @@ export async function gateReport(cwd) {
119
144
  // should read is one level up. Both layouts are given what they expect rather
120
145
  // than the script being asked to guess.
121
146
  const from = kit.kind === 'installed' ? cwd : path.dirname(kit.hooks);
122
- const logPath = path.join(cwd, '.claude', 'gate-log.tsv');
147
+ const logPath = gateLogPath(cwd);
123
148
  const env = { ...process.env };
124
- if (kit.kind !== 'installed' && fs.existsSync(logPath)) env.CREW_GATE_LOG = logPath;
149
+ if (logPath) env.CREW_GATE_LOG = logPath;
125
150
 
126
151
  const r = await run('bash', [script, '--json'], { cwd: from, env });
127
152
  // 3 means there is no log to read, which the script calls "not an error"; 4