crewforth 3.0.4 → 3.1.0-rc.2

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 (67) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/smoke-test.sh +2572 -373
  16. package/kit/hooks/commit-msg +1 -1
  17. package/kit/hooks/context-usage.sh +7 -1
  18. package/kit/hooks/guard-bash.sh +1545 -104
  19. package/kit/hooks/guard-commit-scan.sh +75 -19
  20. package/kit/hooks/guard-powershell.sh +65 -15
  21. package/kit/hooks/guard-write.sh +74 -22
  22. package/kit/hooks/no-bash-guard.ps1 +71 -0
  23. package/kit/hooks/pre-commit +5 -2
  24. package/kit/hooks/prompt-approval.sh +484 -0
  25. package/kit/hooks/route-hint.sh +13 -5
  26. package/kit/hooks/session-rehydrate.sh +18 -0
  27. package/kit/hooks/skill-trust.sh +17 -2
  28. package/kit/owned-blobs.tsv +43 -0
  29. package/kit/settings.json +20 -4
  30. package/kit/skills/a11y/SKILL.md +1 -0
  31. package/kit/skills/crew-doctor/SKILL.md +2 -2
  32. package/kit/skills/frontend/SKILL.md +3 -3
  33. package/kit/skills/frontend-flutter/SKILL.md +124 -0
  34. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +81 -0
  35. package/kit/skills/frontend-flutter/references/platform-async-release.md +100 -0
  36. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  37. package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -0,0 +1,54 @@
1
+ # An app that holds sensitive data (Flutter)
2
+
3
+ Apply only when the app stores data its user would not want read off a lost phone, a backup or the network:
4
+ financial, health, identity. Checked against https://pub.dev/packages/sqlite3 and its hook documentation,
5
+ https://pub.dev/packages/sqlcipher_flutter_libs and https://developer.android.com/identity/data/autobackup
6
+ (2026-10-07). The iOS part is from Apple's API names and was **not checked against a fetched page**.
7
+
8
+ ## The database is encrypted
9
+ - The project's database package decides how; the rule is that the file on disk is ciphertext.
10
+ - With `package:sqlite3` 3.x the SQLite build is chosen by a build hook, in the `pubspec.yaml` at the workspace root:
11
+
12
+ ```yaml
13
+ hooks:
14
+ user_defines:
15
+ sqlite3:
16
+ source: sqlcipher # or sqlite3mc; the default is sqlite3, which does not encrypt
17
+ ```
18
+
19
+ - `sqlcipher_flutter_libs` belongs to version 2.x of `package:sqlite3`; its own page says "Not used anymore, update
20
+ to version 3.x of package:sqlite3 instead". Do not add it to a project on 3.x.
21
+ - A test opens the database file without the key and expects the open to fail: an encryption that was configured
22
+ and silently not applied looks the same from the app.
23
+
24
+ ## The key is in the platform's secure store
25
+ - Generated on the device, kept in the Keychain (iOS) or behind the Keystore (Android) through the project's
26
+ secure-storage package. Not in the database's directory, not in shared preferences, not in the code or its assets.
27
+ - A key that is lost makes the data unreadable. Decide and write down what the app does then (an `adr`).
28
+
29
+ ## Out of backup and device transfer
30
+ The database and the secure store's own files are excluded on both platforms; a restored copy without its key is
31
+ unreadable at best, and a copy with its key is the data on another device.
32
+
33
+ - **Android.** `android:allowBackup="false"` on `<application>`. For an app that targets Android 12 (API 31) or
34
+ higher that turns off cloud backup but, in the documentation's words, "doesn't disable device-to-device transfers"
35
+ on some manufacturers' devices. So also `android:dataExtractionRules` pointing at an XML file whose
36
+ `<cloud-backup>` and `<device-transfer>` sections each exclude the database and the secure-storage files. Devices
37
+ on Android 11 or lower read `android:fullBackupContent` instead.
38
+ - **iOS.** The files carry the resource value `isExcludedFromBackup` (`NSURLIsExcludedFromBackupKey`), set from
39
+ native code or a plugin after the file exists. Set it again when the file is created anew.
40
+ - A test (or a check in CI) reads the manifest and the rules file and fails when either attribute is missing.
41
+
42
+ ## Every network call through a transport bound to its allowed hosts
43
+ - A transport is the one object a service's requests leave through. Each holds the host or hosts **it** may talk
44
+ to and **refuses any other**: it throws before the request is sent. An app with one backend has one transport;
45
+ an app that talks to several services gives each its own, with its own list, so a transport for one service
46
+ cannot reach another's host.
47
+ - A test asks each transport for a host that is not on its list and expects the throw. An architecture test fails
48
+ on a network call made outside a transport (a client constructed or a socket opened anywhere else). In a debug
49
+ run the throw is a crash someone sees; it is never swallowed.
50
+
51
+ ## Ads
52
+ In an app that shows ads, **no ad is drawn in a window where a sensitive screen is visible.** In a two-pane layout
53
+ the detail pane may be the sensitive one while the list is not: the rule is about the window, so the list's ad goes
54
+ too. A widget test at an expanded width expects no ad widget while the sensitive detail is shown.
@@ -35,6 +35,35 @@ _field() { # _field <key> -> value on stdout
35
35
  printf '%s' "${r%%\"*}"
36
36
  }
37
37
 
38
+ # ---- what "allowed" says to Claude Code --------------------------------------
39
+ # Exit 0 with nothing on stdout is not an approval: the harness reads it as "no
40
+ # decision" and sends the call through its own permission flow, and a headless
41
+ # session has nobody to ask. Measured in real sessions (Claude Code 2.1.284): in
42
+ # `default` mode Edit, Write and a writing Bash were all refused AFTER the viewer
43
+ # allowed them here, and in `acceptEdits` the Bash was. So an allowance given in
44
+ # the panel is said out loud, as the JSON decision the harness documents.
45
+ #
46
+ # Never in plan mode. The mode is an allow-list of two, and it has to agree with
47
+ # what the harness itself reports for the session: a hook configured for
48
+ # `default` that finds the session in `plan` stays silent, and so does one with
49
+ # no mode, or with a mode this file does not name. Silence leaves plan's own
50
+ # blocks, and every other check the harness makes, exactly where they were.
51
+ #
52
+ # This approves the call the viewer was shown. It does not unblock anything:
53
+ # another hook that exits 2 still blocks, and this hook's own denials are exit 2.
54
+ _grant() {
55
+ case "${CREW_GATE_MODE:-}" in
56
+ acceptEdits|default) ;;
57
+ *) exit 0 ;;
58
+ esac
59
+ case "$(_field permission_mode || true)" in
60
+ ''|acceptEdits|default) ;;
61
+ *) exit 0 ;;
62
+ esac
63
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"allowed in Crewforth Studio"}}'
64
+ exit 0
65
+ }
66
+
38
67
  TOOL="$(_field tool_name || true)"
39
68
  TUID="$(_field tool_use_id || true)"
40
69
  SID="$(_field session_id || true)"
@@ -50,6 +79,11 @@ REQ="$SPOOL/req"; ANS="$SPOOL/ans"; ALWAYS="$SPOOL/always"
50
79
  mkdir -p "$REQ" "$ANS" "$ALWAYS" 2>/dev/null || exit 0
51
80
 
52
81
  # A tool the panel already blanket-approved for this session skips the round trip.
82
+ # It is NOT approved out loud: nobody saw this call. The hook stays silent and the
83
+ # harness decides as it would have without a panel, so a call it wants a person
84
+ # for — a commit, anything a project's settings mark `ask` — is still refused in
85
+ # a headless session instead of running unseen. _grant is for a call a person
86
+ # answered, and only for that one.
53
87
  case "$TOOL" in
54
88
  *[!A-Za-z0-9_-]*) ;; # odd tool name: never treated as approved
55
89
  *) [ -f "$ALWAYS/$TOOL" ] && exit 0 ;;
@@ -156,7 +190,7 @@ while :; do
156
190
  read -r VERDICT < "$ANS/$TUID" 2>/dev/null || VERDICT=deny
157
191
  rm -f "$ANS/$TUID" "$REQ/$TUID.json" 2>/dev/null
158
192
  case "$VERDICT" in
159
- allow|always) exit 0 ;;
193
+ allow|always) _grant ;;
160
194
  *) printf 'studio: denied in the panel\n' >&2; exit 2 ;;
161
195
  esac
162
196
  fi
@@ -25,7 +25,7 @@ import { parsePeers, askAll, ask } from './lib/peers.js';
25
25
  import {
26
26
  createSession, getSession, listSessionsOwned, reap, stopAll, ALLOWED_MODES,
27
27
  } from './lib/session.js';
28
- import { decide, pending, alwaysList } from './lib/permissions.js';
28
+ import { decide, pending, alwaysList, revoke } from './lib/permissions.js';
29
29
  import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
30
30
  import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
31
31
  import { writeState, clearStateSync, findRunning } from './lib/instance.js';
@@ -387,7 +387,7 @@ async function handle(req, res) {
387
387
 
388
388
  if (url.pathname === '/api/owned' && req.method === 'GET') {
389
389
  reap();
390
- return sendJson(res, 200, { measured: true, modes: ALLOWED_MODES, sessions: listSessionsOwned() });
390
+ return sendJson(res, 200, { measured: true, modes: ALLOWED_MODES, sessions: listSessionsOwned(), now: Date.now() });
391
391
  }
392
392
 
393
393
  if (url.pathname === '/api/owned' && req.method === 'POST') {
@@ -418,17 +418,27 @@ async function handle(req, res) {
418
418
  waitSeconds: s.gate?.waitSeconds ?? null,
419
419
  pending: s.gate ? pending(s.id) : [],
420
420
  always: s.gate ? alwaysList(s.id) : [],
421
+ now: Date.now(),
421
422
  });
422
423
  }
423
424
 
424
425
  const gate = writeAllowed(req);
425
426
  if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
426
- if (req.method !== 'POST' || !permMatch[2]) return sendJson(res, 405, { ok: false, reason: 'POST to a request id' });
427
427
  if (!s.gate) return sendJson(res, 409, { ok: false, reason: 'this session has no gate to answer' });
428
+ // DELETE /permissions/<tool> takes back an "allow this tool for the session".
429
+ if (req.method === 'DELETE' && permMatch[2]) {
430
+ const out = revoke(s.id, decodeURIComponent(permMatch[2]));
431
+ return sendJson(res, out.ok ? 200 : 400, { ...out, always: alwaysList(s.id) });
432
+ }
433
+ if (req.method !== 'POST' || !permMatch[2]) {
434
+ return sendJson(res, 405, { ok: false, reason: 'POST to a request id, or DELETE a tool name' });
435
+ }
428
436
 
429
437
  const body = await readBody(req);
430
438
  if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
431
- const out = decide(s.id, decodeURIComponent(permMatch[2]), body.verdict);
439
+ const toolUseId = decodeURIComponent(permMatch[2]);
440
+ const out = decide(s.id, toolUseId, body.verdict);
441
+ if (out.ok) s.noteDecision(toolUseId, body.verdict);
432
442
  return sendJson(res, out.ok ? 200 : 400, out);
433
443
  }
434
444
 
@@ -405,24 +405,47 @@ export const _internals = { scanMain, harvestCompletions, SESSION_NODE };
405
405
  export async function agentDetail(session, agentId) {
406
406
  if (!/^[A-Za-z0-9_-]+$/.test(agentId)) return null;
407
407
 
408
- const file = path.join(session.subagentsDir, `agent-${agentId}.jsonl`);
409
- try { await fsp.stat(file); } catch { return null; }
408
+ // An agent a workflow started lives one directory down (subagents/workflows/<run>/). The graph finds those;
409
+ // looking only at the top level answered "no such agent" for every one of them.
410
+ let file = path.join(session.subagentsDir, `agent-${agentId}.jsonl`);
411
+ try {
412
+ await fsp.stat(file);
413
+ } catch {
414
+ const nested = (await agentMetaFiles(session.subagentsDir)).find((m) => path.basename(m) === `agent-${agentId}.meta.json`);
415
+ if (!nested) return null;
416
+ file = nested.replace(/\.meta\.json$/, '.jsonl');
417
+ try { await fsp.stat(file); } catch { return null; }
418
+ }
410
419
 
411
420
  const { records, malformed } = await readAll(file);
412
421
 
413
422
  let meta = {};
414
423
  try {
415
- meta = JSON.parse(await fsp.readFile(path.join(session.subagentsDir, `agent-${agentId}.meta.json`), 'utf8'));
424
+ meta = JSON.parse(await fsp.readFile(file.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
416
425
  } catch { /* the transcript is still the source of truth */ }
417
426
 
418
427
  const timeline = [];
419
428
  const texts = []; // every text block, in order
420
429
  let prompt = null;
421
430
  let lastThinking = null;
431
+ // The last tool call that came back as an error: which tool, when, and the end of what it said. Null when
432
+ // none did — which is a finding about this transcript, and not the same as the transcript being unread.
433
+ const toolNames = new Map();
434
+ let lastError = null;
435
+ let errors = 0;
422
436
 
423
437
  for (const r of records) {
424
438
  const at = r?.timestamp ? Date.parse(r.timestamp) : null;
425
439
 
440
+ if (r?.type === 'user' && Array.isArray(r.message?.content)) {
441
+ for (const c of r.message.content) {
442
+ if (c?.type !== 'tool_result' || c.is_error !== true) continue;
443
+ errors += 1;
444
+ const res = toolResult(c);
445
+ lastError = { tool: toolNames.get(c.tool_use_id) ?? null, at, text: res.text, truncated: res.truncated, length: res.length };
446
+ }
447
+ }
448
+
426
449
  if (r?.type === 'user' && prompt === null) {
427
450
  const c = r.message?.content;
428
451
  const t = typeof c === 'string' ? c : (Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null);
@@ -443,6 +466,7 @@ export async function agentDetail(session, agentId) {
443
466
  // through their most identifying input.
444
467
  const i = c.input ?? {};
445
468
  const label = i.description ?? i.file_path ?? i.pattern ?? i.query ?? i.command ?? i.skill ?? null;
469
+ if (typeof c.id === 'string') toolNames.set(c.id, c.name ?? 'unknown');
446
470
  timeline.push({
447
471
  at,
448
472
  name: c.name ?? 'unknown',
@@ -458,6 +482,8 @@ export async function agentDetail(session, agentId) {
458
482
 
459
483
  return {
460
484
  agentId,
485
+ // Where this agent's transcript is, for whoever wants to open it themselves.
486
+ transcriptPath: file,
461
487
  agentType: meta.agentType ?? null,
462
488
  description: meta.description ?? null,
463
489
  spawnDepth: meta.spawnDepth ?? null,
@@ -468,12 +494,32 @@ export async function agentDetail(session, agentId) {
468
494
  narration: texts.slice(0, -1).map((t) => t.text),
469
495
  thinking: lastThinking,
470
496
  timeline,
497
+ lastError,
498
+ errors,
471
499
  records: records.length,
472
500
  malformed,
473
501
  };
474
502
  }
475
503
 
476
504
 
505
+ // How much of a tool's output travels to the page. The END is kept: a test run says how it went in its last
506
+ // lines, and a build says what broke there.
507
+ const RESULT_TAIL_BYTES = 4096;
508
+
509
+ /** One tool result, cut to its tail, with the cut said. */
510
+ export function toolResult(c) {
511
+ const raw = typeof c.content === 'string'
512
+ ? c.content
513
+ : Array.isArray(c.content) ? c.content.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '';
514
+ const truncated = raw.length > RESULT_TAIL_BYTES;
515
+ return {
516
+ error: c.is_error === true,
517
+ text: truncated ? raw.slice(-RESULT_TAIL_BYTES) : raw,
518
+ truncated,
519
+ length: raw.length,
520
+ };
521
+ }
522
+
477
523
  /**
478
524
  * The conversation a transcript holds, as the panel renders conversations.
479
525
  *
@@ -488,6 +534,16 @@ export async function conversation(session, { limit = 400 } = {}) {
488
534
  const { records } = await readAll(session.file);
489
535
  const out = [];
490
536
 
537
+ // What each tool call came back with, by the call's id. A call with no entry here has not returned, or its
538
+ // result is not in this transcript; the block then carries `result: null`, which is not "it returned nothing".
539
+ const results = new Map();
540
+ for (const r of records) {
541
+ if (r?.isSidechain === true || r?.type !== 'user' || !Array.isArray(r.message?.content)) continue;
542
+ for (const c of r.message.content) {
543
+ if (c?.type === 'tool_result' && typeof c.tool_use_id === 'string') results.set(c.tool_use_id, toolResult(c));
544
+ }
545
+ }
546
+
491
547
  for (const r of records) {
492
548
  if (r?.isSidechain === true) continue;
493
549
  if (r?.parent_tool_use_id) continue;
@@ -515,6 +571,11 @@ export async function conversation(session, { limit = 400 } = {}) {
515
571
  kind: 'tool',
516
572
  name: c.name ?? 'tool',
517
573
  label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
574
+ // The call's own id: what a result, and an agent on the graph, are matched to it by.
575
+ id: typeof c.id === 'string' ? c.id : null,
576
+ // Only a delegation has one. The agent's type is what the card in the conversation is named after.
577
+ subagentType: typeof i.subagent_type === 'string' ? i.subagent_type : null,
578
+ result: (typeof c.id === 'string' ? results.get(c.id) : null) ?? null,
518
579
  });
519
580
  }
520
581
  }
@@ -16,6 +16,14 @@ const FEED = process.env.CREW_UPDATE_URL
16
16
  const FEED_TTL_MS = 60 * 60 * 1000; // the hook uses a day; a panel refreshes hourly
17
17
  const FETCH_TIMEOUT_MS = 8000;
18
18
 
19
+ // How an install made of files is updated: the command, as the project's own update notice words it. It lives
20
+ // here and travels in the payload so that the panel never types a command of its own.
21
+ //
22
+ // It is given ONLY to a project that has `.claude/VERSION`. A plugin install leaves no file in the project, so
23
+ // from here it cannot be told from a project without Crewforth — and it does not update through npx. Such a
24
+ // project gets null, and the panel shows the `/crew-update` hint instead of a command it would have to invent.
25
+ const FILE_INSTALL_UPDATE = 'npx crewforth@latest update --here';
26
+
19
27
  let latestCache = null; // { at, value }
20
28
  let inflight = null;
21
29
 
@@ -120,7 +128,12 @@ export async function kitStatus(cwd, latest) {
120
128
  // the other is a dead path.
121
129
  let present = false;
122
130
  try { await fsp.stat(cwd); present = true; } catch { present = false; }
123
- return { installed: false, dirExists: present, reason: present ? 'Crewforth not installed' : 'directory no longer exists' };
131
+ return {
132
+ installed: false,
133
+ dirExists: present,
134
+ reason: present ? 'Crewforth not installed' : 'directory no longer exists',
135
+ updateCommand: null,
136
+ };
124
137
  }
125
138
 
126
139
  const conf = {};
@@ -137,6 +150,7 @@ export async function kitStatus(cwd, latest) {
137
150
  version,
138
151
  stack: conf.stack ?? null,
139
152
  installer: conf.installer ?? null,
153
+ updateCommand: FILE_INSTALL_UPDATE,
140
154
  };
141
155
 
142
156
  if (!latest?.measured) {
@@ -155,4 +169,4 @@ export async function kitStatus(cwd, latest) {
155
169
  return out;
156
170
  }
157
171
 
158
- export const _internals = { sane, FEED };
172
+ export const _internals = { sane, FEED, FILE_INSTALL_UPDATE };
@@ -39,7 +39,7 @@ export function spoolFor(sessionId) {
39
39
  * are never read, written, or merged. Returns null when the hook is missing, so
40
40
  * the caller can say "no gate" instead of quietly running without one.
41
41
  */
42
- export function prepare(sessionId) {
42
+ export function prepare(sessionId, mode = null) {
43
43
  if (!fs.existsSync(HOOK)) return null;
44
44
 
45
45
  const spool = spoolFor(sessionId);
@@ -57,7 +57,10 @@ export function prepare(sessionId) {
57
57
  // Named, not left to the default: on Windows without a detected Git Bash, Claude Code runs a hook through
58
58
  // PowerShell, which cannot read the VAR=… prefix — the gate would fail open.
59
59
  shell: 'bash',
60
- command: `CREW_GATE_WAIT=${HOOK_WAIT_S} bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`,
60
+ // The mode goes to the hook so that an allowance given in the panel can be an approval the harness
61
+ // honours — in the modes that may write, and never in plan. A mode that is not a plain word is not
62
+ // passed at all, and the hook then approves nothing.
63
+ command: `CREW_GATE_WAIT=${HOOK_WAIT_S} ${gateMode(mode)}bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`,
61
64
  timeout: HARNESS_TIMEOUT_S,
62
65
  }],
63
66
  }],
@@ -67,6 +70,10 @@ export function prepare(sessionId) {
67
70
  return { settingsPath, spool, waitSeconds: HOOK_WAIT_S };
68
71
  }
69
72
 
73
+ function gateMode(mode) {
74
+ return typeof mode === 'string' && /^[A-Za-z]+$/.test(mode) ? `CREW_GATE_MODE=${mode} ` : '';
75
+ }
76
+
70
77
  /** One pending request, as the panel needs to show it. */
71
78
  function readRequest(spool, file) {
72
79
  const toolUseId = file.replace(/\.json$/, '');
@@ -85,6 +92,10 @@ function readRequest(spool, file) {
85
92
  detail: input.command ?? input.file_path ?? input.pattern ?? input.query ?? input.description ?? null,
86
93
  input,
87
94
  cwd: payload.cwd ?? null,
95
+ // Who asked. Claude Code puts these in the hook's input only when the call comes from inside a subagent, so
96
+ // their absence means the session itself asked — it is not a missing answer.
97
+ agentId: typeof payload.agent_id === 'string' && /^[A-Za-z0-9_-]+$/.test(payload.agent_id) ? payload.agent_id : null,
98
+ agentType: typeof payload.agent_type === 'string' ? payload.agent_type.slice(0, 120) : null,
88
99
  askedAt: (() => {
89
100
  try { return fs.statSync(path.join(spool, 'req', file)).mtimeMs; } catch { return Date.now(); }
90
101
  })(),
@@ -133,6 +144,70 @@ export function decide(sessionId, toolUseId, verdict) {
133
144
  return { ok: true, verdict, toolName: req?.toolName ?? null };
134
145
  }
135
146
 
147
+ /**
148
+ * Take back an "allow this tool for the session". The hook looks for the tool's file on every call, so the next
149
+ * call of that tool is asked about again. Returns whether there was anything to take back.
150
+ */
151
+ export function revoke(sessionId, tool) {
152
+ if (typeof tool !== 'string' || !/^[A-Za-z0-9_-]+$/.test(tool)) return { ok: false, reason: 'bad tool name' };
153
+ const file = path.join(spoolFor(sessionId), 'always', tool);
154
+ try {
155
+ fs.rmSync(file);
156
+ return { ok: true, tool, revoked: true };
157
+ } catch (e) {
158
+ if (e?.code === 'ENOENT') return { ok: true, tool, revoked: false };
159
+ return { ok: false, reason: String(e?.message ?? e) };
160
+ }
161
+ }
162
+
163
+ // How many answered requests a session remembers. In memory only: a server that restarts starts the record
164
+ // again, and the page says from when the record runs.
165
+ export const APPROVAL_LOG_MAX = 200;
166
+ // A request that vanished this close to the hook's own deadline, with no answer recorded, timed out. The hook
167
+ // polls and so does the watcher; this is their combined slack.
168
+ const TIMEOUT_SLACK_MS = 2500;
169
+ const VERDICT_OUTCOME = { allow: 'allowed', always: 'allowed-session', deny: 'denied' };
170
+
171
+ /**
172
+ * Fold the requests waiting now into a session's record of approvals.
173
+ *
174
+ * A request not seen before is opened with the moment it was asked. One that was open and is no longer waiting
175
+ * is closed with the moment that was noticed and with what became of it: the answer the panel recorded, a
176
+ * timeout if nobody answered and the hook's deadline had come, and otherwise "unanswered" — the hook went away
177
+ * without one, as it does when the session is stopped. Nothing is guessed into an answer.
178
+ *
179
+ * @param log the record so far, oldest first; changed in place and returned
180
+ * @param pending what `pending()` returns now
181
+ * @param decisions Map toolUseId -> verdict the panel recorded
182
+ */
183
+ export function logApprovals(log, pending, decisions, now, waitSeconds) {
184
+ const waiting = new Set(pending.map((r) => r.toolUseId));
185
+ const open = new Set(log.filter((e) => e.endedAt === null).map((e) => e.toolUseId));
186
+ for (const e of log) {
187
+ if (e.endedAt !== null || waiting.has(e.toolUseId)) continue;
188
+ e.endedAt = now;
189
+ const verdict = decisions?.get(e.toolUseId);
190
+ if (verdict && VERDICT_OUTCOME[verdict]) e.outcome = VERDICT_OUTCOME[verdict];
191
+ else if (typeof waitSeconds === 'number' && now >= e.askedAt + waitSeconds * 1000 - TIMEOUT_SLACK_MS) e.outcome = 'timed-out';
192
+ else e.outcome = 'unanswered';
193
+ decisions?.delete(e.toolUseId);
194
+ }
195
+ for (const r of pending) {
196
+ if (open.has(r.toolUseId)) continue;
197
+ log.push({
198
+ toolUseId: r.toolUseId, toolName: r.toolName, agentId: r.agentId ?? null, agentType: r.agentType ?? null,
199
+ askedAt: r.askedAt, endedAt: null, outcome: null,
200
+ });
201
+ }
202
+ // The oldest closed entries go first; a request still waiting is never dropped.
203
+ while (log.length > APPROVAL_LOG_MAX) {
204
+ const i = log.findIndex((e) => e.endedAt !== null);
205
+ if (i === -1) break;
206
+ log.splice(i, 1);
207
+ }
208
+ return log;
209
+ }
210
+
136
211
  /** Watch a session's spool and call back whenever the pending set changes. */
137
212
  export function watch(sessionId, onChange) {
138
213
  let last = '';
@@ -68,15 +68,25 @@ export async function allProjectDirs() {
68
68
  // Reading them means scanning the transcript, and the sidebar refreshes on a
69
69
  // timer over every session in the project — so only the tail is read (the
70
70
  // latest record wins anyway) and the result is cached against size+mtime.
71
+ //
72
+ // The branch comes out of the same read. Every record carries `gitBranch`, and the last one in the tail is the
73
+ // branch the session is on now — a session can change branch, and the navigator names where it is, not where it
74
+ // began. A transcript with no such field, or an empty one (not a repository), yields null: the caller shows
75
+ // nothing rather than a branch nobody read.
71
76
  const TITLE_TAIL_BYTES = 131072;
72
- const titleCache = new Map(); // file -> { sig, title }
77
+ const titleCache = new Map(); // file -> { sig, title, branch }
73
78
 
74
79
  export async function sessionTitle(file, size, mtimeMs) {
80
+ return (await sessionTail(file, size, mtimeMs)).title;
81
+ }
82
+
83
+ export async function sessionTail(file, size, mtimeMs) {
75
84
  const sig = `${size}:${mtimeMs}`;
76
85
  const hit = titleCache.get(file);
77
- if (hit && hit.sig === sig) return hit.title;
86
+ if (hit && hit.sig === sig) return { title: hit.title, branch: hit.branch };
78
87
 
79
88
  let title = null;
89
+ let branch = null;
80
90
  let fh;
81
91
  try {
82
92
  fh = await fsp.open(file, 'r');
@@ -100,15 +110,24 @@ export async function sessionTitle(file, size, mtimeMs) {
100
110
  }
101
111
  }
102
112
  title = found.custom ?? found.agent ?? found.ai ?? null;
113
+
114
+ // In a record the key is followed by a bare quote; inside a quoted message every quote is escaped, so text
115
+ // that merely mentions the field cannot match.
116
+ let last = null;
117
+ for (const m of text.matchAll(/"gitBranch"\s*:\s*"((?:[^"\\]|\\.)*)"/g)) last = m[1];
118
+ if (last) {
119
+ try { branch = JSON.parse(`"${last}"`) || null; } catch { branch = null; }
120
+ }
103
121
  } catch {
104
122
  title = null;
123
+ branch = null;
105
124
  } finally {
106
125
  if (fh !== undefined) try { await fh.close(); } catch { /* already gone */ }
107
126
  }
108
127
 
109
128
  if (titleCache.size > 500) titleCache.clear();
110
- titleCache.set(file, { sig, title });
111
- return title;
129
+ titleCache.set(file, { sig, title, branch });
130
+ return { title, branch };
112
131
  }
113
132
 
114
133
  // The working directory a session ran in. The encoded folder name cannot be
@@ -162,13 +181,16 @@ export async function listSessions(dir) {
162
181
  try { st = await fsp.stat(file); } catch { return null; }
163
182
 
164
183
  const subagentsDir = path.join(dir, sessionId, 'subagents');
165
- const [agentCount, title, cwd] = await Promise.all([
184
+ const [agentCount, tail, cwd] = await Promise.all([
166
185
  countAgentMetas(subagentsDir),
167
- sessionTitle(file, st.size, st.mtimeMs),
186
+ sessionTail(file, st.size, st.mtimeMs),
168
187
  sessionCwd(file, st.size),
169
188
  ]);
170
189
 
171
- return { sessionId, file, subagentsDir, bytes: st.size, modifiedAt: st.mtimeMs, agentCount, title, cwd };
190
+ return {
191
+ sessionId, file, subagentsDir, bytes: st.size, modifiedAt: st.mtimeMs, agentCount,
192
+ title: tail.title, branch: tail.branch, cwd,
193
+ };
172
194
  }));
173
195
 
174
196
  const out = settled.filter(Boolean);
@@ -222,6 +244,34 @@ export async function agentMetaFiles(subagentsDir, depth = 0) {
222
244
  return out;
223
245
  }
224
246
 
247
+ /**
248
+ * The branch a directory is on NOW, read from its `.git/HEAD` and nowhere else.
249
+ *
250
+ * No process is started: a `git` per project per poll is the cost this file exists to avoid. A worktree's `.git`
251
+ * is a file that points at its real git directory, and that pointer is followed once. A detached HEAD, a
252
+ * directory that is not a repository's root, and a file that cannot be read all answer null: the caller then
253
+ * names no branch, which is different from naming the one a session recorded last week.
254
+ */
255
+ export async function headBranch(cwd) {
256
+ if (!cwd) return null;
257
+ const dot = path.join(cwd, '.git');
258
+ let head;
259
+ try {
260
+ const st = await fsp.stat(dot);
261
+ if (st.isDirectory()) {
262
+ head = await fsp.readFile(path.join(dot, 'HEAD'), 'utf8');
263
+ } else {
264
+ const m = (await fsp.readFile(dot, 'utf8')).match(/^gitdir:\s*(.+?)\s*$/m);
265
+ if (!m) return null;
266
+ head = await fsp.readFile(path.join(path.resolve(cwd, m[1]), 'HEAD'), 'utf8');
267
+ }
268
+ } catch {
269
+ return null;
270
+ }
271
+ const ref = head.match(/^ref:\s*refs\/heads\/(\S+)\s*$/m);
272
+ return ref ? ref[1] : null;
273
+ }
274
+
225
275
  /**
226
276
  * Every session on this machine, grouped by the project it ran in.
227
277
  *
@@ -257,6 +307,9 @@ export async function listProjects({ currentCwd = null, limitPerProject = 60, ki
257
307
  // not deleted: the panel says how many it is holding back rather than
258
308
  // quietly shortening the list.
259
309
  exists: kit?.dirExists ?? (g.cwd ? await existsCached(g.cwd) : false),
310
+ // Where a session started here now would be working. Not the branch a session recorded: that one is on
311
+ // each session row, and may be weeks old.
312
+ branch: await headBranch(g.cwd),
260
313
  total: g.sessions.length,
261
314
  agentTotal: g.sessions.reduce((n, s) => n + s.agentCount, 0),
262
315
  modifiedAt: g.sessions[0]?.modifiedAt ?? 0,
@@ -15,7 +15,7 @@ import { spawn } from 'node:child_process';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import fs from 'node:fs';
17
17
 
18
- import { prepare, pending, watch, cleanup } from './permissions.js';
18
+ import { prepare, pending, watch, cleanup, alwaysList, logApprovals } from './permissions.js';
19
19
  import { getFleet } from './fleet.js';
20
20
 
21
21
  export const ALLOWED_MODES = ['plan', 'acceptEdits', 'default'];
@@ -49,12 +49,17 @@ class OwnedSession {
49
49
  // The gate is prepared before the child exists, so its very first tool call
50
50
  // is already covered. A missing hook is reported, never worked around: a
51
51
  // panel that silently ran ungated would be worse than one that cannot run.
52
- this.gate = prepare(this.id);
52
+ this.gate = prepare(this.id, this.permissionMode);
53
53
  this.pendingPermissions = [];
54
54
  this.gateEvents = []; // hook lifecycle, newest last
55
+ // What was asked of the viewer in this session, when, and what became of it. Kept from the moment Studio
56
+ // started the session, in memory: it is what the Timeline's waiting periods are drawn from.
57
+ this.approvals = [];
58
+ this.decisions = new Map();
55
59
  this.unwatch = this.gate
56
60
  ? watch(this.id, (reqs) => {
57
61
  this.pendingPermissions = reqs;
62
+ logApprovals(this.approvals, reqs, this.decisions, Date.now(), this.gate.waitSeconds);
58
63
  this.#emit({ type: 'permissions', pending: reqs });
59
64
  })
60
65
  : null;
@@ -181,6 +186,9 @@ class OwnedSession {
181
186
  }
182
187
  }
183
188
 
189
+ /** The panel recorded an answer to a request. Remembered until the request is seen to have gone. */
190
+ noteDecision(toolUseId, verdict) { this.decisions.set(toolUseId, verdict); }
191
+
184
192
  /** Replay from `after`, then follow. Returns an unsubscribe function. */
185
193
  subscribe(fn, after = 0) {
186
194
  for (const ev of this.events) if (ev.seq > after) fn(ev);
@@ -235,6 +243,12 @@ class OwnedSession {
235
243
  gated: Boolean(this.gate),
236
244
  gateWaitSeconds: this.gate?.waitSeconds ?? null,
237
245
  pendingPermissions: this.pendingPermissions,
246
+ // Tools the viewer allowed for the rest of this session, so they can be listed and taken back.
247
+ alwaysAllowed: this.gate ? alwaysList(this.id) : [],
248
+ approvals: this.approvals,
249
+ // This machine's clock. A countdown is `askedAt + wait - now`, and all three have to come from one clock:
250
+ // a viewer on another machine has a different one.
251
+ now: Date.now(),
238
252
  gateEvents: this.gateEvents.slice(-40),
239
253
  };
240
254
  }