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.
- package/CHANGELOG.md +200 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/smoke-test.sh +2572 -373
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1545 -104
- package/kit/hooks/guard-commit-scan.sh +75 -19
- package/kit/hooks/guard-powershell.sh +65 -15
- package/kit/hooks/guard-write.sh +74 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +124 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +81 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +100 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- 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)
|
|
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
|
|
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
|
-
|
|
409
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
184
|
+
const [agentCount, tail, cwd] = await Promise.all([
|
|
166
185
|
countAgentMetas(subagentsDir),
|
|
167
|
-
|
|
186
|
+
sessionTail(file, st.size, st.mtimeMs),
|
|
168
187
|
sessionCwd(file, st.size),
|
|
169
188
|
]);
|
|
170
189
|
|
|
171
|
-
return {
|
|
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
|
}
|