crewforth 3.2.0-rc.1 → 3.2.0-rc.10

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 (74) hide show
  1. package/CHANGELOG.md +190 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +37 -3
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +81 -3
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-default.sh +36 -0
  28. package/kit/eval/lib/settings-json.awk +14 -4
  29. package/kit/eval/lib/statusline.sh +4 -19
  30. package/kit/eval/lib/verify-cmd.sh +17 -0
  31. package/kit/eval/lib/version.sh +41 -0
  32. package/kit/eval/review-bench.sh +26 -0
  33. package/kit/eval/smoke-test.sh +424 -26
  34. package/kit/hooks/agent-outcome.sh +38 -9
  35. package/kit/hooks/board-sync.sh +2 -23
  36. package/kit/hooks/guard-agent-model.sh +163 -20
  37. package/kit/hooks/guard-bash.sh +69 -36
  38. package/kit/hooks/guard-commit-scan.sh +10 -2
  39. package/kit/hooks/guard-write.sh +3 -7
  40. package/kit/hooks/precompact-snapshot.sh +75 -0
  41. package/kit/hooks/prompt-approval.sh +74 -5
  42. package/kit/hooks/session-rehydrate.sh +25 -2
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/hooks/trace-blocklist.txt +9 -0
  45. package/kit/settings.json +18 -0
  46. package/kit/skills/crew-code-review/SKILL.md +16 -0
  47. package/kit/skills/crew-map/SKILL.md +16 -5
  48. package/kit/skills/crew-review/SKILL.md +2 -1
  49. package/kit/skills/crew-ship/SKILL.md +3 -1
  50. package/kit/studio/README.md +21 -10
  51. package/kit/studio/server/index.js +85 -22
  52. package/kit/studio/server/lib/code-stamp.js +35 -0
  53. package/kit/studio/server/lib/graph.js +9 -1
  54. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  55. package/kit/studio/server/lib/lan.js +87 -0
  56. package/kit/studio/server/lib/owned-stream.js +56 -0
  57. package/kit/studio/server/lib/projects.js +11 -4
  58. package/kit/studio/server/lib/redact.js +60 -0
  59. package/kit/studio/server/lib/remote.js +104 -18
  60. package/kit/studio/server/lib/session.js +1 -1
  61. package/kit/studio/server/lib/terminal.js +51 -13
  62. package/kit/studio/server/lib/transcript.js +32 -0
  63. package/kit/studio/web/app.js +79 -11
  64. package/kit/studio/web/canvas.js +14 -2
  65. package/kit/studio/web/chat.js +114 -24
  66. package/kit/studio/web/graph-plan.js +3 -2
  67. package/kit/studio/web/liveness.js +14 -0
  68. package/kit/studio/web/map-plan.js +72 -3
  69. package/kit/studio/web/map.js +103 -10
  70. package/kit/studio/web/qr.js +200 -0
  71. package/kit/studio/web/style.css +40 -0
  72. package/kit/studio/web/tokens.css +6 -0
  73. package/package.json +1 -1
  74. package/start.sh +14 -1
@@ -0,0 +1,56 @@
1
+ // owned-stream.js — the conversations of several owned sessions, replayed then followed, on ONE connection.
2
+ //
3
+ // A browser holds six connections to one host over HTTP/1.1, and a pane per stream reached them at five panes
4
+ // beside the page's own: the panel kept painting, but a message sent and every request the page made itself waited
5
+ // until a pane was closed (measured on Windows, Claude Code 2.1.301: a send did not arrive in 35 s, then in 8 s once
6
+ // two panes closed). Each event and state carries the session id it belongs to.
7
+ // ids=a,b,c the sessions (at most MAX_STREAM_IDS; an unknown id is answered with `gone`)
8
+ // after=a:12,b:3 resume each where the client last was, so a reconnect neither replays nor drops
9
+
10
+ export const MAX_STREAM_IDS = 32;
11
+
12
+ /** Read `ids` and `after` from a URL: the ids, de-duplicated and capped, and each one's resume point. */
13
+ export function parseOwnedQuery(url) {
14
+ const ids = [...new Set((url.searchParams.get('ids') ?? '').split(',').filter(Boolean))].slice(0, MAX_STREAM_IDS);
15
+ const after = new Map();
16
+ for (const part of (url.searchParams.get('after') ?? '').split(',')) {
17
+ const at = part.lastIndexOf(':');
18
+ if (at > 0) after.set(part.slice(0, at), Number(part.slice(at + 1)) || 0);
19
+ }
20
+ return { ids, after };
21
+ }
22
+
23
+ /**
24
+ * @param getSession id -> an owned session ({ summary(), subscribe(fn, after) }) or null
25
+ * @param stateAfter the record types after which a session's summary is sent again
26
+ * @param beatMs how often every summary is sent regardless
27
+ */
28
+ export function streamOwned(req, res, url, { getSession, stateAfter = new Set(), beatMs = 15000 }) {
29
+ const { ids, after } = parseOwnedQuery(url);
30
+ res.writeHead(200, {
31
+ 'content-type': 'text/event-stream; charset=utf-8',
32
+ 'cache-control': 'no-store',
33
+ connection: 'keep-alive',
34
+ 'x-accel-buffering': 'no',
35
+ });
36
+ const write = (event, data) => {
37
+ try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
38
+ };
39
+ const unsubscribes = [];
40
+ for (const id of ids) {
41
+ const s = getSession(id);
42
+ if (!s) { write('gone', { id }); continue; }
43
+ write('state', { id, session: s.summary() });
44
+ unsubscribes.push(s.subscribe((ev) => {
45
+ write('event', { id, ev });
46
+ if (stateAfter.has(ev.rec?.type)) write('state', { id, session: s.summary() });
47
+ }, after.get(id) ?? 0));
48
+ }
49
+ const beat = setInterval(() => {
50
+ for (const id of ids) { const s = getSession(id); if (s) write('state', { id, session: s.summary() }); }
51
+ }, beatMs);
52
+ const stop = () => { clearInterval(beat); for (const u of unsubscribes) u(); try { res.end(); } catch { /* gone */ } };
53
+ req.on('close', stop);
54
+ req.on('error', stop);
55
+ return stop;
56
+ }
@@ -24,10 +24,17 @@ import fsp from 'node:fs/promises';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
26
 
27
- // Overridable so a second instance can be pointed at a different transcript
28
- // store — which is also the only way to exercise the peer relay on one machine.
29
- export const PROJECTS_ROOT = process.env.CREW_STUDIO_PROJECTS_ROOT
30
- || path.join(os.homedir(), '.claude', 'projects');
27
+ // Where Claude Code writes its transcripts: <config dir>/projects, the config dir being CLAUDE_CONFIG_DIR when it is
28
+ // set and ~/.claude otherwise. Reading ~/.claude alone, sessions started under a moved config dir (Studio's own
29
+ // included) were not listed and read a cost of 0 (measured on Windows). The studio gate hook reads it the same way.
30
+ // CREW_STUDIO_PROJECTS_ROOT still wins: a second instance pointed at a different store, which is also the only way to
31
+ // exercise the peer relay on one machine.
32
+ export function projectsRoot(env = process.env, home = os.homedir()) {
33
+ if (env.CREW_STUDIO_PROJECTS_ROOT) return env.CREW_STUDIO_PROJECTS_ROOT;
34
+ const config = env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(home, '.claude');
35
+ return path.join(config, 'projects');
36
+ }
37
+ export const PROJECTS_ROOT = projectsRoot();
31
38
 
32
39
  /** Fold a working directory into the directory name Claude Code uses. */
33
40
  export function encodeCwd(cwd) {
@@ -0,0 +1,60 @@
1
+ // redact.js — what a viewer whose link hides transcripts is sent.
2
+ //
3
+ // Text that came from a transcript (a prompt, a reply, a tool's input or output, the short task an agent was given,
4
+ // a session's title) is not sent to such a viewer at all. Hiding it in the page would not do: it would still be on
5
+ // the wire and in the phone's memory.
6
+ //
7
+ // AN ALLOW-LIST, NOT A DENY-LIST. Transcript text only ever travels as a string, so every string in an answer is
8
+ // dropped unless its key is one of the few below, which name things that do not come from a transcript: ids,
9
+ // states, model and agent-type names, tool names, times as text. A field added to an answer tomorrow is therefore
10
+ // hidden until someone decides it is safe and adds its key here. Numbers, booleans and the shape of an answer pass,
11
+ // so the viewer still sees the agents, their states, their timing and what they cost.
12
+ //
13
+ // A few routes are nothing but transcript (a conversation, an agent's detail, the live conversation of a session
14
+ // the panel runs): those are refused outright rather than sent empty.
15
+
16
+ /** Keys whose string values are not transcript text. Checked by key name, wherever in the answer they are. */
17
+ export const SAFE_STRING_KEYS = new Set([
18
+ // identity and links between things
19
+ 'id', 'sessionId', 'agentId', 'parentId', 'toolUseId', 'workflowId', 'workflow', 'source', 'target', 'origin',
20
+ // kinds and states
21
+ 'kind', 'status', 'launchStatus', 'trigger', 'role', 'reason',
22
+ // which model and which kind of agent, and which tool (a tool's NAME, never its input)
23
+ 'model', 'modelAsked', 'models', 'unpriced', 'agentType', 'toolName', 'lastTool', 'version',
24
+ // where, as the machine knows it rather than as anybody wrote it
25
+ 'cwd', 'gitBranch', 'dir', 'url', 'read', 'at',
26
+ ]);
27
+
28
+ // `label` is safe only where the server writes it from one of the safe things above (a session's short id, an agent
29
+ // type, 'workflow'); measured in lib/graph.js. Anywhere else it is dropped like any other string.
30
+ const LABEL_SAFE_KINDS = new Set(['session', 'agent', 'workflow']);
31
+
32
+ /** Routes that are nothing but transcript: refused for a viewer whose link hides transcripts. */
33
+ export function transcriptOnly(pathname) {
34
+ return /^\/api\/session\/[^/]+\/(conversation|agent\/[^/]+)$/.test(pathname)
35
+ || pathname === '/api/owned' || pathname === '/api/owned-events' || /^\/api\/owned\/[^/]+(\/.*)?$/.test(pathname);
36
+ }
37
+
38
+ /** The answer with every string that may be transcript text removed. A new object; the input is not touched. */
39
+ export function redact(value, key = null, parent = null) {
40
+ if (typeof value === 'string') {
41
+ if (SAFE_STRING_KEYS.has(key)) return value;
42
+ if (key === 'label' && parent && LABEL_SAFE_KINDS.has(parent.kind)) return value;
43
+ // A machine's name (an origin: no sessionId of its own). A session's `name` is its title, written from its
44
+ // first prompt, and is dropped.
45
+ if (key === 'name' && parent && parent.sessionId === undefined && typeof parent.local === 'boolean') return value;
46
+ return undefined;
47
+ }
48
+ if (Array.isArray(value)) {
49
+ return value.map((x) => redact(x, key, parent)).filter((x) => x !== undefined);
50
+ }
51
+ if (value && typeof value === 'object') {
52
+ const out = {};
53
+ for (const [k, v] of Object.entries(value)) {
54
+ const r = redact(v, k, value);
55
+ if (r !== undefined) out[k] = r;
56
+ }
57
+ return out;
58
+ }
59
+ return value;
60
+ }
@@ -1,60 +1,129 @@
1
1
  // remote.js — read-only viewers.
2
2
  //
3
- // The panel's own token can start a session and answer a tool call. Handing that token to a phone, over
4
- // whatever tunnel reaches this machine, hands the phone all of it. A viewer token is the smaller thing: it
5
- // reads what the panel reads and can change nothing. The server decides that, not the page — a viewer's
6
- // request is refused here before any handler sees it.
3
+ // The panel's own token can start a session and answer a tool call. Handing that token to a phone hands the phone
4
+ // all of it. A viewer token is the smaller thing: it reads what the panel reads and can change nothing. The server
5
+ // decides that, not the page — a viewer's request is refused here before any handler sees it.
7
6
  //
8
- // THE SERVER STILL LISTENS ON LOOPBACK ONLY. A viewer does not widen that. The way in from another device is a
9
- // tunnel the user runs (an ssh forward, a tailnet), and this module knows nothing about it.
7
+ // THE WAY IN FROM ANOTHER DEVICE IS THE SAME WI-FI. While at least one viewer is live, the server also listens on
8
+ // this machine's Wi-Fi address (index.js), and there it answers a viewer's token and nothing else: the panel's own
9
+ // token is not accepted from the network at all. With no viewer, nothing listens beyond 127.0.0.1.
10
10
  //
11
- // Kept in memory, like the owner's token: a viewer lasts as long as the server that made it. A link that
12
- // survived a restart would be a credential nobody remembers handing out.
11
+ // A viewer lasts EIGHT HOURS, or until it is revoked, or until the server stops — whichever comes first. Kept in
12
+ // memory, like the owner's token: a link that survived a restart would be a credential nobody remembers handing out.
13
+ // An expired token is remembered as expired (not as unknown) so the phone can say "Session expired" rather than
14
+ // "no access", and only for as long as the server runs.
15
+ //
16
+ // TRANSCRIPTS ARE HIDDEN by default: a viewer made without `transcripts` is answered with no text that came from a
17
+ // transcript (lib/redact.js does that on the server; the page hiding it would not be enough).
13
18
  import { randomUUID } from 'node:crypto';
14
19
 
15
20
  export const MAX_VIEWERS = 8;
21
+ export const VIEWER_TTL_MS = 8 * 60 * 60 * 1000;
16
22
  const LABEL_MAX = 40;
23
+ const LOG_MAX = 200;
17
24
 
18
25
  const viewers = new Map(); // id -> viewer
19
26
  const byToken = new Map(); // token -> viewer
27
+ const expired = new Map(); // token -> label, for "Session expired"
28
+ const log = []; // what happened to viewers' links, newest last
29
+
30
+ let clock = () => Date.now();
31
+ let onChange = () => {};
32
+ let note = () => {};
33
+
34
+ /** For the self-check: a clock it can move (eight hours are not waited for). */
35
+ export function _setClock(fn) { clock = fn ?? (() => Date.now()); }
36
+ /** index.js opens and closes the Wi-Fi listener when the number of live viewers changes. */
37
+ export function onViewersChange(fn) { onChange = fn ?? (() => {}); }
38
+ /** Where a log line also goes (the server's own output). */
39
+ export function onLog(fn) { note = fn ?? (() => {}); }
40
+
41
+ function record(entry) {
42
+ const e = { at: clock(), ...entry };
43
+ log.push(e);
44
+ if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX);
45
+ note(e);
46
+ }
20
47
 
21
48
  const publicOf = (v) => ({
22
49
  id: v.id,
23
50
  label: v.label,
24
51
  createdAt: v.createdAt,
52
+ expiresAt: v.expiresAt,
53
+ transcripts: v.transcripts,
25
54
  lastSeenAt: v.lastSeenAt,
26
55
  requests: v.requests,
27
56
  agent: v.agent,
28
57
  open: v.conns.size,
58
+ addresses: [...v.addresses],
29
59
  });
30
60
 
31
61
  /** Make a viewer. The token is in this answer and in no later one. */
32
- export function addViewer(label) {
62
+ export function addViewer(label, { transcripts = false } = {}) {
63
+ sweep();
33
64
  if (viewers.size >= MAX_VIEWERS) {
34
65
  return { ok: false, reason: `${MAX_VIEWERS} viewers already; revoke one first` };
35
66
  }
36
67
  const named = typeof label === 'string' ? label.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) : '';
68
+ const now = clock();
37
69
  const v = {
38
70
  id: randomUUID().slice(0, 8),
39
71
  label: named || `Viewer ${viewers.size + 1}`,
40
72
  token: randomUUID(),
41
- createdAt: Date.now(),
73
+ createdAt: now,
74
+ expiresAt: now + VIEWER_TTL_MS,
75
+ transcripts: transcripts === true,
42
76
  lastSeenAt: null,
43
77
  requests: 0,
44
78
  agent: null,
45
79
  conns: new Set(),
80
+ addresses: new Set(),
46
81
  };
47
82
  viewers.set(v.id, v);
48
83
  byToken.set(v.token, v);
84
+ record({ event: 'added', viewer: v.label, transcripts: v.transcripts });
85
+ onChange(viewers.size);
49
86
  return { ok: true, viewer: { ...publicOf(v), token: v.token } };
50
87
  }
51
88
 
52
89
  export function listViewers() {
90
+ sweep();
53
91
  return [...viewers.values()].map(publicOf);
54
92
  }
55
93
 
94
+ export function liveCount() {
95
+ sweep();
96
+ return viewers.size;
97
+ }
98
+
99
+ export function remoteLog(n = 50) {
100
+ return log.slice(-n);
101
+ }
102
+
103
+ /** The viewer this token belongs to, if it is live; `{ expired: label }` if it was one and its eight hours are up. */
56
104
  export function viewerByToken(token) {
57
- return (typeof token === 'string' && byToken.get(token)) || null;
105
+ if (typeof token !== 'string') return null;
106
+ const v = byToken.get(token);
107
+ if (v && clock() >= v.expiresAt) { end(v, 'expired'); return { expired: v.label }; }
108
+ if (v) return v;
109
+ if (expired.has(token)) return { expired: expired.get(token) };
110
+ return null;
111
+ }
112
+
113
+ function end(v, why) {
114
+ viewers.delete(v.id);
115
+ byToken.delete(v.token);
116
+ if (why === 'expired') expired.set(v.token, v.label);
117
+ for (const res of v.conns) { try { res.destroy(); } catch { /* already gone */ } }
118
+ v.conns.clear();
119
+ record({ event: why, viewer: v.label });
120
+ onChange(viewers.size);
121
+ }
122
+
123
+ /** End every viewer whose eight hours are up. Called on every read of the list and by index.js's timer. */
124
+ export function sweep() {
125
+ const now = clock();
126
+ for (const v of [...viewers.values()]) if (now >= v.expiresAt) end(v, 'expired');
58
127
  }
59
128
 
60
129
  /**
@@ -64,23 +133,38 @@ export function viewerByToken(token) {
64
133
  export function revokeViewer(id) {
65
134
  const v = viewers.get(id);
66
135
  if (!v) return { ok: true, revoked: false };
67
- viewers.delete(id);
68
- byToken.delete(v.token);
69
- for (const res of v.conns) { try { res.destroy(); } catch { /* already gone */ } }
70
- v.conns.clear();
136
+ end(v, 'revoked');
71
137
  return { ok: true, revoked: true };
72
138
  }
73
139
 
74
- /** Count a viewer's request and remember its response for as long as it is open. */
140
+ /** Count a viewer's request and remember its response for as long as it is open. A new address is logged once. */
75
141
  export function noteRequest(v, req, res) {
76
142
  v.requests += 1;
77
- v.lastSeenAt = Date.now();
143
+ v.lastSeenAt = clock();
78
144
  const ua = req.headers['user-agent'];
79
145
  v.agent = typeof ua === 'string' ? ua.slice(0, 160) : null;
146
+ const ip = req.socket?.remoteAddress ?? null;
147
+ if (ip && !v.addresses.has(ip)) {
148
+ v.addresses.add(ip);
149
+ record({ event: 'connected', viewer: v.label, ip, over: req.crewLan ? 'wifi' : 'loopback', result: 'ok' });
150
+ }
80
151
  v.conns.add(res);
81
152
  res.on('close', () => v.conns.delete(res));
82
153
  }
83
154
 
155
+ /**
156
+ * A request that was turned away: logged with who it claimed to be, from where, and why. The same refusal again
157
+ * (same viewer, address, path and answer) is counted on its entry rather than written again, so a page that keeps
158
+ * asking cannot push everything else out of the log.
159
+ */
160
+ export function noteRefusal(req, { viewer = null, status, reason }) {
161
+ const e = { event: 'refused', viewer, ip: req.socket?.remoteAddress ?? null, over: req.crewLan ? 'wifi' : 'loopback',
162
+ result: `${status} ${reason}`, path: String(req.url ?? '').split('?')[0].slice(0, 120) };
163
+ const same = log.findLast((x) => x.event === 'refused' && x.viewer === e.viewer && x.ip === e.ip && x.path === e.path && x.result === e.result);
164
+ if (same) { same.count = (same.count ?? 1) + 1; same.lastAt = clock(); return; }
165
+ record(e);
166
+ }
167
+
84
168
  /**
85
169
  * What a viewer may ask for: a GET of anything the panel reads, and nothing about viewers themselves.
86
170
  * The list of viewers is the owner's; a viewer that could read it could count who else is watching.
@@ -95,5 +179,7 @@ export function viewerMay(method, pathname) {
95
179
 
96
180
  /** For the self-check: start from nothing. */
97
181
  export function _reset() {
98
- for (const id of [...viewers.keys()]) revokeViewer(id);
182
+ for (const v of [...viewers.values()]) end(v, 'revoked');
183
+ expired.clear();
184
+ log.length = 0;
99
185
  }
@@ -290,7 +290,7 @@ class OwnedSession {
290
290
  * answer is "yes": an unreadable fleet must not be the reason two processes
291
291
  * end up writing one transcript.
292
292
  */
293
- async function isHeldOpen(id) {
293
+ export async function isHeldOpen(id) {
294
294
  try {
295
295
  const fleet = await getFleet();
296
296
  const rows = fleet?.sessions ?? fleet ?? [];
@@ -17,21 +17,60 @@ function q(s) {
17
17
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
18
18
  }
19
19
 
20
+ /** PowerShell literal string: a quote is doubled, nothing else is special inside one. */
21
+ function psq(s) {
22
+ return `'${String(s).replace(/'/g, "''")}'`;
23
+ }
24
+
25
+ /**
26
+ * A session id is a UUID. It comes from the URL, and on Windows it ends up in a line cmd.exe interprets, where a
27
+ * quote is not a quote (cmd hands `'` to the program as part of the id) and `&` starts a second command. So
28
+ * nothing is built for anything else, and an id that passes needs no quoting in any shell.
29
+ */
30
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
31
+ export function isSessionId(s) {
32
+ return typeof s === 'string' && SESSION_ID.test(s);
33
+ }
34
+
20
35
  /** AppleScript string literal: backslashes and quotes, nothing else. */
21
36
  function osaQuote(s) {
22
37
  return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
23
38
  }
24
39
 
40
+ /**
41
+ * The command that opens the session in the Claude Desktop app again (`claude --desktop`, CLI 2.1.299), from its
42
+ * own directory: `--resume <id>` looks for the session among that directory's. On Windows it is a PowerShell line
43
+ * (5.1 and 7; Windows Terminal opens PowerShell): cmd needs `&&` and does not take single quotes, PowerShell 5.1
44
+ * rejects `&&`, and no one line works in both. `shell` says which, so the panel can say so.
45
+ */
46
+ export function desktopLine({ cwd, sessionId, platform = process.platform }) {
47
+ if (!isSessionId(sessionId)) return null;
48
+ if (platform === 'win32') {
49
+ return { shell: 'PowerShell', line: `Set-Location -LiteralPath ${psq(cwd)}; claude --desktop --resume ${sessionId}` };
50
+ }
51
+ return { shell: 'sh', line: `cd ${q(cwd)} && claude --desktop --resume ${q(sessionId)}` };
52
+ }
53
+
25
54
  /**
26
55
  * Build the launch for this platform.
27
56
  * Returns null when nothing here knows how to open a terminal, so the caller
28
57
  * can say so rather than fail silently.
29
58
  */
30
- export function plan({ cwd, sessionId, command }) {
31
- const inner = command ?? `claude --resume ${q(sessionId)}`;
59
+ export function plan({ cwd, sessionId, platform = process.platform }) {
60
+ if (!isSessionId(sessionId)) return null;
61
+
62
+ if (platform === 'win32') {
63
+ // Windows Terminal when present, the console host otherwise. wt sets the directory itself; what runs inside is
64
+ // cmd.exe, so the line shown is a cmd line: double quotes, `cd /d` for another drive, and the id bare (a UUID).
65
+ const inner = `claude --resume ${sessionId}`;
66
+ const wt = ['wt.exe', '-d', cwd, 'cmd.exe', '/k', inner];
67
+ return { platform: 'win32', via: 'wt.exe', shell: 'cmd', line: `cd /d "${cwd}" && ${inner}`, argv: wt };
68
+ }
69
+
70
+ const inner = `claude --resume ${q(sessionId)}`;
32
71
  const line = `cd ${q(cwd)} && ${inner}`;
33
72
 
34
- if (process.platform === 'darwin') {
73
+ if (platform === 'darwin') {
35
74
  return {
36
75
  platform: 'darwin',
37
76
  via: 'Terminal.app',
@@ -41,12 +80,6 @@ export function plan({ cwd, sessionId, command }) {
41
80
  };
42
81
  }
43
82
 
44
- if (process.platform === 'win32') {
45
- // Windows Terminal when present, the console host otherwise.
46
- const wt = ['wt.exe', '-d', cwd, 'cmd.exe', '/k', inner];
47
- return { platform: 'win32', via: 'wt.exe', line, argv: wt };
48
- }
49
-
50
83
  // Linux and the rest: whichever emulator is actually installed. Names are
51
84
  // tried by running them, not by resolving them — a name on PATH that fails
52
85
  // to start is the failure mode this ordering exists for.
@@ -57,14 +90,19 @@ export function plan({ cwd, sessionId, command }) {
57
90
  ['xfce4-terminal', ['--working-directory', cwd, '-e', `bash -lc ${q(line)}`]],
58
91
  ['xterm', ['-e', 'bash', '-lc', line]],
59
92
  ];
60
- return { platform: process.platform, via: 'candidates', line, candidates };
93
+ return { platform, via: 'candidates', line, candidates };
61
94
  }
62
95
 
63
- /** Launch it, detached, and report what was run. */
64
- export function open({ cwd, sessionId, command }) {
96
+ /**
97
+ * Launch it, detached, and report what was run. What runs is always `claude --resume <id>`: the route used to take a
98
+ * `command` from the request body and run it in place of that, with no id check, and the panel never sent one. An
99
+ * unused way to run anything in a terminal, behind a token that remote access puts on the network, was removed.
100
+ */
101
+ export function open({ cwd, sessionId }) {
65
102
  if (!cwd || !fs.existsSync(cwd)) return { ok: false, reason: `no such directory: ${cwd}` };
66
103
 
67
- const p = plan({ cwd, sessionId, command });
104
+ if (!isSessionId(sessionId)) return { ok: false, reason: `not a session id: ${sessionId}` };
105
+ const p = plan({ cwd, sessionId });
68
106
  if (!p) return { ok: false, reason: `no terminal launcher known for ${process.platform}` };
69
107
 
70
108
  const attempts = p.argv ? [p.argv] : p.candidates.map(([bin, args]) => [bin, ...args]);
@@ -98,3 +98,35 @@ export function contextFill(records) {
98
98
  }
99
99
  return total;
100
100
  }
101
+
102
+ /**
103
+ * What the context is made of since the last compaction: the share of its text that is tool output, and how many
104
+ * times the session was compacted, with the last one's tokens before and after. Measured in characters of the main
105
+ * session's records (text, a tool call's input, a tool's result), not tokens: the transcript keeps no per-block
106
+ * token count. A compaction is the transcript's own `compact_boundary` record.
107
+ */
108
+ export function contextMix(records) {
109
+ let start = 0; let compactions = 0; let last = null;
110
+ records.forEach((r, i) => {
111
+ if (r?.isSidechain !== true && r?.type === 'system' && r?.subtype === 'compact_boundary') { start = i + 1; compactions += 1; last = r.compactMetadata ?? null; }
112
+ });
113
+ const size = (b) => {
114
+ if (typeof b === 'string') return b.length;
115
+ if (b?.type === 'text') return String(b.text ?? '').length;
116
+ if (b?.type === 'tool_use') return JSON.stringify(b.input ?? {}).length;
117
+ if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : (Array.isArray(b.content) ? b.content.reduce((n, x) => n + (x?.type === 'text' ? String(x.text ?? '').length : 0), 0) : 0);
118
+ return 0;
119
+ };
120
+ let tool = 0; let all = 0;
121
+ for (let i = start; i < records.length; i += 1) {
122
+ const r = records[i];
123
+ if (r?.isSidechain === true || (r?.type !== 'user' && r?.type !== 'assistant')) continue;
124
+ const c = r?.message?.content;
125
+ for (const b of Array.isArray(c) ? c : [c]) { const n = size(b); all += n; if (b?.type === 'tool_result') tool += n; }
126
+ }
127
+ return {
128
+ toolShare: all ? Math.round((100 * tool) / all) : null,
129
+ compactions,
130
+ lastCompact: last ? { trigger: last.trigger ?? null, before: last.preTokens ?? null, after: last.postTokens ?? null } : null,
131
+ };
132
+ }