claude-code-kanban 5.4.0 → 6.1.0

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/lib/terminal.js CHANGED
@@ -7,6 +7,10 @@
7
7
  // (net-guard upgradeVerdict), refusal when the server is exposed, and a per-launch
8
8
  // token sent in the first message. The token is the only one that stops a
9
9
  // non-browser local process.
10
+ //
11
+ // The service runs in its own process (lib/terminal-host.js): cck checks the first two
12
+ // gates and hands the upgraded socket over, so no keystroke or output byte waits on
13
+ // cck's main thread.
10
14
 
11
15
  const crypto = require('node:crypto');
12
16
  const fs = require('node:fs');
@@ -26,6 +30,17 @@ const PICK_START_SLACK_MS = 2000;
26
30
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
27
31
  const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
28
32
  const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
33
+ // Extra claude args reach the command line inside plain quotes (quoteArg), so a character
34
+ // that ends or expands those quotes in any of the shells is refused.
35
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: refuses control characters on purpose
36
+ const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f'"%]*$/;
37
+ const MAX_EXTRA_ARGS = 64;
38
+ const MAX_EXTRA_ARG = 4096;
39
+ // cck sets these itself, or they would start something other than a new session.
40
+ const OWNED_FLAGS = new Set([
41
+ '--session-id', '-n', '--name', '--model', '-w', '--worktree',
42
+ '-r', '--resume', '-c', '--continue', '--fork-session', '-p', '--print',
43
+ ]);
29
44
  const MAX_PROMPT = 32 * 1024;
30
45
  const PROMPT_QUIET_MS = 400;
31
46
  // Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
@@ -118,8 +133,8 @@ function resolveShell(value, which, platform = process.platform, env = process.e
118
133
  }
119
134
 
120
135
  // claudeArgs is null for a plain shell. Its items are fixed flags, a validated UUID, or
121
- // values that passed NAME_RE / WORKTREE_RE, so plain quoting is enough to keep a space
122
- // from splitting an argument and nothing can break out of the quotes.
136
+ // values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE, so plain quoting is enough to
137
+ // keep a space from splitting an argument and nothing can break out of the quotes.
123
138
  function quoteArg(arg, family) {
124
139
  if (/^[A-Za-z0-9._-]+$/.test(arg)) return arg;
125
140
  return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
@@ -142,7 +157,7 @@ function claudeArgsFor(mode, id, spec) {
142
157
  if (mode === 'fork') return ['--resume', id, '--fork-session'];
143
158
  if (mode === 'pick') return ['--resume'];
144
159
  if (mode === 'new') {
145
- const args = ['--session-id', id];
160
+ const args = ['--session-id', id, ...(spec.extraArgs || [])];
146
161
  if (spec.name) args.push('--name', spec.name);
147
162
  if (spec.model) args.push('--model', spec.model);
148
163
  // Last, because its value is optional and a following flag must not be taken for it.
@@ -153,6 +168,16 @@ function claudeArgsFor(mode, id, spec) {
153
168
  return ['--resume', id];
154
169
  }
155
170
 
171
+ function extraArgsError(args) {
172
+ if (!Array.isArray(args) || args.length > MAX_EXTRA_ARGS) return `claude args (at most ${MAX_EXTRA_ARGS})`;
173
+ for (const a of args) {
174
+ if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no quotes, % or control characters';
175
+ const flag = a.split('=')[0];
176
+ if (OWNED_FLAGS.has(flag)) return `claude arg: cck sets ${flag}`;
177
+ }
178
+ return null;
179
+ }
180
+
156
181
  // Returns the new-session options from a hello, or a string naming the bad field.
157
182
  function parseNewSpec(msg) {
158
183
  if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
@@ -165,7 +190,10 @@ function parseNewSpec(msg) {
165
190
  // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
166
191
  const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
167
192
  if (prompt.length > MAX_PROMPT) return 'prompt';
168
- return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null };
193
+ const extraArgs = msg.extraArgs ?? [];
194
+ const bad = extraArgsError(extraArgs);
195
+ if (bad) return bad;
196
+ return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null, extraArgs };
169
197
  }
170
198
 
171
199
  function samePath(a, b) {
@@ -225,15 +253,43 @@ function refuse(socket, status, reason) {
225
253
  socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${reason}\n`);
226
254
  }
227
255
 
256
+ function localUnavailableReason(config, net) {
257
+ if (!config.enabled) return 'disabled';
258
+ if (net.EXPOSED) return 'refused while listening on a non-loopback address';
259
+ return null;
260
+ }
261
+
262
+ function refuseUpgrade(req, socket, reasonFor) {
263
+ let pathname;
264
+ try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
265
+ if (pathname !== WS_PATH) {
266
+ refuse(socket, '404 Not Found', 'no such endpoint');
267
+ return true;
268
+ }
269
+ const reason = reasonFor();
270
+ if (reason) refuse(socket, '403 Forbidden', reason);
271
+ return !!reason;
272
+ }
273
+
274
+ function clientConfigFor(config, available) {
275
+ return {
276
+ available,
277
+ fontFamily: config.fontFamily,
278
+ fontSize: config.fontSize,
279
+ scrollback: config.scrollback,
280
+ maxSessions: config.maxSessions,
281
+ };
282
+ }
283
+
228
284
  /**
229
285
  * @param {object} o
230
286
  * @param {ReturnType<typeof readTerminalConfig>} o.config
231
287
  * @param {{EXPOSED: boolean, upgradeVerdict: (req: any) => string|null}} o.net
232
288
  * @param {string} o.claudeDir
233
289
  * @param {boolean} o.isDefaultDir
234
- * @param {(id: string) => string|null} o.resolveCwd null = unknown session
290
+ * @param {(id: string) => string|null|Promise<string|null>} o.resolveCwd null = unknown session
235
291
  * @param {(id: string, exceptPid?: number) => boolean} o.isLiveElsewhere
236
- * @param {(dir: string) => boolean} o.isAllowedFolder where a new session may start
292
+ * @param {(dir: string) => boolean|Promise<boolean>} o.isAllowedFolder where a new session may start
237
293
  * @param {() => {pid: number|null, sessionId: string, cwd: string|null, startedAt: number}[]} o.liveSessions
238
294
  * claude's live-session registry, read fresh
239
295
  * @param {(cmd: string) => string|null} o.which
@@ -286,10 +342,7 @@ function createTerminalService(o) {
286
342
  }
287
343
 
288
344
  function unavailableReason() {
289
- if (!config.enabled) return 'disabled';
290
- if (net.EXPOSED) return 'refused while listening on a non-loopback address';
291
- if (!load()) return loadError;
292
- return null;
345
+ return localUnavailableReason(config, net) || (load() ? null : loadError);
293
346
  }
294
347
 
295
348
  function changed() {
@@ -319,11 +372,11 @@ function createTerminalService(o) {
319
372
  function restore() {
320
373
  if (!restoreQueue.length || unavailableReason()) return;
321
374
  console.log(`Restoring ${restoreQueue.length} terminal(s)`);
322
- const next = () => {
375
+ const next = async () => {
323
376
  while (restoreQueue.length && !shuttingDown) {
324
377
  const id = restoreQueue.shift();
325
378
  if (sessions.has(id) || o.isLiveElsewhere(id)) continue;
326
- const r = start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
379
+ const r = await start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
327
380
  if (!r.error) break;
328
381
  console.log(`Could not restore terminal ${id}: ${r.error}`);
329
382
  }
@@ -474,7 +527,8 @@ function createTerminalService(o) {
474
527
  // Claude's registry entry keeps its pid and swaps its sessionId to the picked one; the entry
475
528
  // is found by folder and start time, and the pick shows as an id with a transcript behind it.
476
529
  function watchPick(s) {
477
- s.pickTimer = setInterval(() => {
530
+ s.pickTimer = setInterval(async () => {
531
+ if (s.pickChecking) return;
478
532
  const live = o.liveSessions();
479
533
  const entry = s.claudePid && live.find((l) => l.pid === s.claudePid);
480
534
  if (!entry) {
@@ -485,7 +539,11 @@ function createTerminalService(o) {
485
539
  return;
486
540
  }
487
541
  const picked = entry.sessionId;
488
- if (picked === s.id || o.resolveCwd(picked) === null) return;
542
+ if (picked === s.id) return;
543
+ s.pickChecking = true;
544
+ const known = await o.resolveCwd(picked);
545
+ s.pickChecking = false;
546
+ if (known === null || s.exited || s.mode !== 'pick') return;
489
547
  clearInterval(s.pickTimer);
490
548
  // Two claude processes on one session write the same transcript. When cck already runs it,
491
549
  // the pick is ended and its viewers move to that terminal; the sockets leave first so no
@@ -538,7 +596,7 @@ function createTerminalService(o) {
538
596
  });
539
597
  }
540
598
 
541
- function onHello(ws, msg) {
599
+ async function onHello(ws, msg) {
542
600
  if (!msg || msg.t !== 'hello' || !tokenMatches(token, msg.token)) {
543
601
  send(ws, { t: 'error', msg: 'The terminal token is out of date. Reload the hub window.' });
544
602
  return ws.close(4001);
@@ -560,36 +618,43 @@ function createTerminalService(o) {
560
618
  send(ws, { t: 'live' });
561
619
  return ws.close(1000);
562
620
  }
563
- const r = start(id, mode, msg, cols, rows);
621
+ const r = await start(id, mode, msg, cols, rows);
622
+ if (r.running) return attach(ws, r.running, true);
564
623
  if (r.error) {
565
624
  send(ws, { t: 'error', msg: r.error });
566
625
  return ws.close(r.code);
567
626
  }
627
+ // The viewer left while the PTY started; it runs headless until the next attach.
628
+ if (ws.readyState !== 1) return;
568
629
  attach(ws, r.session, false);
569
630
  }
570
631
 
571
632
  // The admission rules shared by the socket hello and startNew. A refusal carries both a
572
633
  // WebSocket close code and an HTTP status, so each caller maps it its own way.
573
- function start(id, mode, msg, cols, rows, extraEnv) {
574
- if (sessions.size >= config.maxSessions) {
575
- return { code: 4003, status: 429, error: `${config.maxSessions} terminals are open; end one first` };
634
+ // resolveCwd and isAllowedFolder may answer later, so the limits are checked again after them.
635
+ async function start(id, mode, msg, cols, rows, extraEnv) {
636
+ const full = { code: 4003, status: 429, error: `${config.maxSessions} terminals are open; end one first` };
637
+ if (sessions.size >= config.maxSessions) return full;
638
+ let spec = null;
639
+ if (mode === 'new') {
640
+ spec = parseNewSpec(msg);
641
+ if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
576
642
  }
577
- const known = o.resolveCwd(id);
643
+ const known = await o.resolveCwd(id);
578
644
  let cwd = known;
579
- let spec = null;
580
645
  if (mode === 'new' || mode === 'pick') {
581
- if (mode === 'new') {
582
- spec = parseNewSpec(msg);
583
- if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
584
- }
585
646
  if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
586
- if (typeof msg.cwd !== 'string' || !o.isAllowedFolder(msg.cwd)) {
647
+ if (typeof msg.cwd !== 'string' || !(await o.isAllowedFolder(msg.cwd))) {
587
648
  return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
588
649
  }
589
650
  cwd = msg.cwd;
590
651
  } else if (mode !== 'shell' && known === null) {
591
652
  return { code: 4004, status: 404, error: 'unknown session' };
592
653
  }
654
+ if (shuttingDown) return { code: 1001, status: 503, error: 'the terminal is shutting down' };
655
+ const running = sessions.get(id);
656
+ if (running && !running.ended) return { running, code: 4006, status: 409, error: 'session id already in use' };
657
+ if (sessions.size >= config.maxSessions) return full;
593
658
  try {
594
659
  return { session: spawnSession(id, mode === 'auto' ? 'resume' : mode, cwd, cols, rows, spec, extraEnv) };
595
660
  } catch (e) {
@@ -598,31 +663,24 @@ function createTerminalService(o) {
598
663
  }
599
664
 
600
665
  function handleUpgrade(req, socket, head) {
601
- let pathname;
602
- try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
603
- if (pathname !== WS_PATH) return refuse(socket, '404 Not Found', 'no such endpoint');
604
- const reason = unavailableReason() || net.upgradeVerdict(req);
605
- if (reason) return refuse(socket, '403 Forbidden', reason);
666
+ if (refuseUpgrade(req, socket, () => unavailableReason() || net.upgradeVerdict(req))) return;
606
667
  wss.handleUpgrade(req, socket, head, (ws) => {
607
668
  const timer = setTimeout(() => ws.close(4001), HELLO_TIMEOUT_MS);
608
669
  ws.once('message', (raw, isBinary) => {
609
670
  clearTimeout(timer);
610
671
  let msg = null;
611
672
  if (!isBinary) { try { msg = JSON.parse(raw.toString()); } catch { /* treated as bad hello */ } }
612
- onHello(ws, msg);
673
+ onHello(ws, msg).catch((e) => {
674
+ send(ws, { t: 'error', msg: `failed to start the terminal: ${e.message}` });
675
+ ws.close(1011);
676
+ });
613
677
  });
614
678
  ws.on('error', () => {});
615
679
  });
616
680
  }
617
681
 
618
682
  function clientConfig() {
619
- return {
620
- available: !unavailableReason(),
621
- fontFamily: config.fontFamily,
622
- fontSize: config.fontSize,
623
- scrollback: config.scrollback,
624
- maxSessions: config.maxSessions,
625
- };
683
+ return clientConfigFor(config, !unavailableReason());
626
684
  }
627
685
 
628
686
  // kill() returns before the process is gone, so an ended session leaves list() at once.
@@ -669,10 +727,11 @@ function createTerminalService(o) {
669
727
 
670
728
  // A 'new' session for a caller with no socket. The PTY runs headless until a browser
671
729
  // attaches, and a later attach resizes it.
672
- function startNew(msg, extraEnv) {
730
+ async function startNew(msg, extraEnv) {
673
731
  const reason = unavailableReason();
674
732
  if (reason) return { status: 403, error: reason };
675
- const r = start(crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
733
+ if (msg.id != null && !(typeof msg.id === 'string' && UUID_RE.test(msg.id))) return { status: 400, error: 'invalid session id' };
734
+ const r = await start(msg.id || crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
676
735
  return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
677
736
  }
678
737
 
@@ -700,4 +759,4 @@ function createTerminalService(o) {
700
759
  return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
701
760
  }
702
761
 
703
- module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
762
+ module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "5.4.0",
3
+ "version": "6.1.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.21.4",
3
+ "version": "2.23.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
5
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -12,9 +12,10 @@ export function displayName(model: string) {
12
12
  const toEpochSeconds = (iso?: string) => (iso ? Math.floor(Date.parse(iso) / 1000) : undefined)
13
13
 
14
14
  // The board reads the statusLine's JSON shape, so the file keeps its field names.
15
- export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined) {
15
+ export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined, requestAt?: number) {
16
16
  const { context } = m
17
17
  return {
18
+ ...(requestAt && { cache: { last_request_at: requestAt } }),
18
19
  model: { id: model, display_name: displayName(model) },
19
20
  cost: { total_cost_usd: m.cost?.usd ?? 0 },
20
21
  context_window: {
@@ -43,6 +44,7 @@ function cckDir($: EngineInterface) {
43
44
 
44
45
  let lastUsage: ModelUsage | undefined
45
46
  let lastModel: string | undefined
47
+ let lastRequestAt: number | undefined
46
48
  const lastWritten = new Map<string, string>()
47
49
 
48
50
  async function write($: EngineInterface, m: Measure) {
@@ -51,7 +53,7 @@ async function write($: EngineInterface, m: Measure) {
51
53
  lastModel ?? $.session.model(),
52
54
  cckDir($),
53
55
  ])
54
- const text = JSON.stringify(toStatus(model, m, lastUsage))
56
+ const text = JSON.stringify(toStatus(model, m, lastUsage, lastRequestAt))
55
57
  const file = `${dir}/context-status/${sessionId}.json`
56
58
  if (lastWritten.get(file) === text) return
57
59
  await $.fs.write(file, text)
@@ -65,6 +67,7 @@ export const register: Register = on => {
65
67
  const { model, ...usage } = result.usage
66
68
  lastUsage = usage
67
69
  lastModel = model
70
+ lastRequestAt = Date.now()
68
71
  await write($, await $.session.usage())
69
72
  }
70
73
  return result
@@ -4,11 +4,5 @@
4
4
  "description": "Notifies this session when its tasks are moved on the kanban board or the user sends it review comments.",
5
5
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\"",
6
6
  "when": "on-skill-invoke:claude-code-kanban:follow"
7
- },
8
- {
9
- "name": "kanban-dispatch-inbox",
10
- "description": "Notifies this session when a session it dispatched through cck reports or exits.",
11
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\" --topic dispatch --keep-backlog",
12
- "when": "on-skill-invoke:claude-code-kanban:dispatch"
13
7
  }
14
8
  ]
@@ -29,10 +29,6 @@ const RETRY_MS = 15000;
29
29
  // Windows sometimes fails a loopback connect with ETIMEDOUT, or resets it once open, while the
30
30
  // board is up, so those errors get a few short waits before the normal one.
31
31
  const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
32
- // `--topic dispatch` is the dispatch inbox: reports from sessions this one started.
33
- const TOPIC = process.argv.includes('--topic') ? process.argv[process.argv.indexOf('--topic') + 1] : null;
34
- // A dispatch report is a result, not an instruction, so a late attach still wants it.
35
- const KEEP_BACKLOG = process.argv.includes('--keep-backlog');
36
32
 
37
33
  if (!SESSION_ID) process.exit(0);
38
34
 
@@ -53,12 +49,11 @@ function serverUrl() {
53
49
  // Once per process, not once per poll: the grant means "follow the board from here on", so
54
50
  // the first attach throws away whatever queued up before it. A later reconnect must not
55
51
  // discard again -- by then the queue holds events the user is owed.
56
- let firstAttach = !KEEP_BACKLOG;
52
+ let firstAttach = true;
57
53
 
58
54
  async function poll(base) {
59
55
  const first = firstAttach ? '&first=1' : '';
60
- const topic = TOPIC ? `&topic=${encodeURIComponent(TOPIC)}` : '';
61
- const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}${topic}`;
56
+ const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}`;
62
57
  const res = await fetch(url, { signal: AbortSignal.timeout((WAIT_SEC + 15) * 1000) });
63
58
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
64
59
  firstAttach = false;
@@ -1,23 +1,29 @@
1
1
  ---
2
2
  name: dispatch
3
- description: Dispatch a task to another Claude Code session through the kanban board (cck), fire-and-forget or with a report back. Use when the user asks to dispatch, delegate, or start a session for a task, or to collect or check on a dispatched session's result.
4
- argument-hint: '<task> [--no-report] [--group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]]'
3
+ description: Dispatch tasks to new Claude Code sessions in the kanban board's terminal. Use when the user asks to dispatch or delegate a task to another session.
4
+ argument-hint: '<task> [--handoff] [--group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]] [-- <claude args>]'
5
5
  ---
6
6
 
7
7
  # Kanban dispatch
8
8
 
9
- This file only points at the guide. The guide ships with the `claude-code-kanban` binary, so it always matches the commands that binary accepts.
10
-
11
- Invoking this skill also arms this session's dispatch inbox: when a session you dispatched with `--report` reports or exits, a line arrives here:
12
-
13
- ```
14
- [kanban board] Dispatch <dispatch-id> (session <uuid>) <reported success|reported failure|ended without a report>. Summary: <text>
15
- ```
16
-
17
- Load the guide before running any dispatch command:
9
+ The guide ships with the `claude-code-kanban` binary, so it always matches the commands that binary accepts. Load it before running any dispatch command:
18
10
 
19
11
  ```bash
20
12
  claude-code-kanban skills get dispatch
21
13
  ```
22
14
 
23
15
  Fall back to `npx claude-code-kanban` when the bare binary is not on PATH. If `skills get` is unknown, the installed cck is too old: tell the user to update it, and do not guess commands.
16
+
17
+ ## Reply
18
+
19
+ End every spec with the reply line, because you usually need the result to continue:
20
+
21
+ ```text
22
+ When you are done, or cannot finish, send the result to <your peer name> with the SendMessage tool, then stop.
23
+ ```
24
+
25
+ `--handoff` is a skill argument that drops the reply line: the session owns the task, and the user follows it on the board. Keep it out of the `dispatch start` command.
26
+
27
+ ## Orchestration patterns
28
+
29
+ Use request-reply, handoff or orchestrator-workers directly. When another shape fits better (a separate reviewer, steps that feed each other, a sub-orchestrator, a decision for the user), propose it in one line, the pattern and why, and dispatch only after the user agrees, unless the user named it. Details: [references/orchestration-patterns.md](references/orchestration-patterns.md).
@@ -0,0 +1,50 @@
1
+ # Orchestration patterns
2
+
3
+ Each pattern is only `dispatch start` plus `SendMessage`; the specs decide who talks to whom. A **worker** is a session you dispatch.
4
+
5
+ ## Select
6
+
7
+ Use one of these directly:
8
+
9
+ | Pattern | When |
10
+ |---|---|
11
+ | Request-reply | The default |
12
+ | Handoff | The user passed `--handoff` or asked for no reply |
13
+ | Orchestrator-workers | The task splits into parts that do not depend on each other |
14
+
15
+ Propose these first, unless the user named one. Each adds steps, rounds or sessions the user did not ask for.
16
+
17
+ | Pattern | Fits when |
18
+ |---|---|
19
+ | Prompt chaining | A step needs the previous step's output |
20
+ | Evaluator-optimizer | There is a clear acceptance bar and a second look improves the result |
21
+ | Hierarchical | A part is itself large enough to split |
22
+ | Human-in-the-loop | The spec leaves a choice the user should make |
23
+
24
+ ## Request-reply
25
+
26
+ One worker, one reply. The reply can go to any peer. When another session owns the work and should act on the result, name it in the reply line, and ask for a one-line note to you as well, so you know the worker finished.
27
+
28
+ ## Handoff
29
+
30
+ One worker, no reply line. The worker owns the task, and you save the turn a reply costs.
31
+
32
+ ## Orchestrator-workers
33
+
34
+ One worker per independent part, all started before you wait on any. They run at the same time, and each holds only its own part in context. Give each a unique `--name` and one shared `--group`, then combine the replies.
35
+
36
+ ## Prompt chaining
37
+
38
+ Workers in sequence; each spec carries the previous reply. You check each output before you start the next step, and stop the chain when a check fails.
39
+
40
+ ## Evaluator-optimizer
41
+
42
+ A generator and an evaluator that message each other until the evaluator accepts. A separate evaluator judges the work with fresh eyes. Give each the other's name, and cap the rounds in both specs: `stop after 3 rounds and send what you have`.
43
+
44
+ ## Hierarchical
45
+
46
+ A worker that dispatches its own workers and replies once they all have. Your context then holds one reply per part instead of one per leaf. Its spec says to wait for its workers and to give them its own peer name.
47
+
48
+ ## Human-in-the-loop
49
+
50
+ Add to the worker's spec: `When you need a decision, ask <your peer name> with the SendMessage tool and keep working on what does not depend on the answer.` Ask the user when the decision is theirs, and reply with `SendMessage`. You can steer a running worker the same way at any time.
@@ -1,6 +1,6 @@
1
1
  import type { On, SessionMeasureInput } from 'claude-code'
2
2
  import { expect, test } from 'claude-code/testing'
3
- import { displayName } from '../hooks/context'
3
+ import { displayName, toStatus } from '../hooks/context'
4
4
 
5
5
  const MEASURE: SessionMeasureInput = {
6
6
  context: { tokens: 129_174, window: 1_000_000, percent: 13 },
@@ -45,6 +45,19 @@ test('session.measure writes the statusLine shape the board reads', async ($, on
45
45
  expect(status.rate_limits.seven_day.used_percentage).toBe(7.5)
46
46
  })
47
47
 
48
+ test('session.measure writes no cache entry before the first request', async ($, on) => {
49
+ const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-4')
50
+
51
+ await $.session.measure(MEASURE)
52
+
53
+ expect(JSON.parse(writes[0]?.text ?? '{}').cache).toBeUndefined()
54
+ })
55
+
56
+ test('toStatus adds the last request time', async () => {
57
+ const status = toStatus('claude-opus-5-5', MEASURE, undefined, 1_790_000_000_000)
58
+ expect(status.cache).toEqual({ last_request_at: 1_790_000_000_000 })
59
+ })
60
+
48
61
  test('skips a write when the status has not changed', async ($, on) => {
49
62
  const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-3')
50
63