claude-code-kanban 6.1.2 → 7.0.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/README.md CHANGED
@@ -107,9 +107,10 @@ Run `claude` in any project. You do not configure anything per project. Claude C
107
107
 
108
108
  With no subcommand, `claude-code-kanban` starts the server. Subcommands talk to a server that already runs:
109
109
 
110
- - `session list|search|open|view|plan|agents|pin|pins|peek` to read and focus sessions.
110
+ - `session list|search|open|view|plan|agents|pin` to read and focus sessions.
111
111
  - `task list` and `project list` to read tasks and projects.
112
- - `preview-doc` and `link-doc` to show or link a file on the board.
112
+ - `doc link|unlink|list|preview` to link a file or URL to a session, or show it in the preview.
113
+ - `pane add|rm|list` to add live panes to a session's view.
113
114
  - `dispatch start|list` to start sessions with a task in the board's terminal.
114
115
 
115
116
  Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.blog/claude-code-kanban/reference/cli/).
package/cli.js CHANGED
@@ -4,36 +4,99 @@ const { getClaudeDir, displayPath } = require('./lib/claude-dir');
4
4
  const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
5
5
  const { linkUrl } = require('./public/link-url');
6
6
  // Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
7
+ const SESSION_FLAG = '--session <id>';
8
+ const SESSION_FLAG_HELP = 'Session, full id or unique prefix (default: $PREVIEW_SESSION, else $CLAUDE_CODE_SESSION_ID)';
9
+
7
10
  const COMMANDS = {
8
- 'preview-doc': {
9
- summary: 'Open a markdown or HTML file in the preview modal on connected browser tabs',
10
- usage: 'claude-code-kanban preview-doc <file.md|file.html|url> [--session <id>]',
11
- flags: {
12
- '--session <id>': 'Switch focused session in the browser (does not link the file). Required for a URL.',
11
+ doc: {
12
+ summary: 'Show documents to the user: link them to a session, or open the preview',
13
+ verbs: {
14
+ link: {
15
+ summary: 'Link a file or URL to a session, with no modal',
16
+ usage: 'claude-code-kanban doc link <file|url> [--session <id>]',
17
+ flags: {
18
+ '<file|url>': 'Any file type; one the preview cannot render opens in the editor. An http(s) URL opens in a new tab.',
19
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
20
+ },
21
+ notes: 'The server keeps the link, so it shows when a browser tab opens later. Prefer this over `doc preview` while the user works.',
22
+ examples: [
23
+ 'claude-code-kanban doc link ./design.md',
24
+ 'claude-code-kanban doc link https://github.com/org/repo/pull/12',
25
+ ],
26
+ run: runDocLinkCli,
27
+ },
28
+ unlink: {
29
+ summary: 'Remove a linked file or URL from a session',
30
+ usage: 'claude-code-kanban doc unlink <file|url> [--session <id>]',
31
+ flags: {
32
+ '<file|url>': 'The linked path or URL; the file need not exist',
33
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
34
+ },
35
+ examples: ['claude-code-kanban doc unlink ./design.md'],
36
+ run: runDocLinkCli,
37
+ },
38
+ list: {
39
+ summary: 'Print the docs linked to a session',
40
+ usage: 'claude-code-kanban doc list [--session <id>] [--json]',
41
+ flags: {
42
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
43
+ '--json': 'Output JSON',
44
+ },
45
+ run: runDocListCli,
46
+ },
47
+ preview: {
48
+ summary: 'Open a markdown or HTML file in the preview modal on connected browser tabs',
49
+ usage: 'claude-code-kanban doc preview <file.md|file.html|url> [--session <id>]',
50
+ flags: {
51
+ [SESSION_FLAG]: 'Switch focused session in the browser (does not link the file; default: $PREVIEW_SESSION). For a URL, the session it links to (default: $PREVIEW_SESSION, else $CLAUDE_CODE_SESSION_ID).',
52
+ },
53
+ notes: 'The modal opens on the user\'s screen: the only command that does. HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir. An http(s) URL is linked to the session instead, and the tab on screen shows an Open button.',
54
+ examples: [
55
+ 'claude-code-kanban doc preview ./notes.md --session $CLAUDE_SESSION_ID',
56
+ ],
57
+ run: runPreviewCli,
58
+ },
13
59
  },
14
- notes: 'HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir. An http(s) URL is linked to the session instead, and the tab on screen shows an Open button.',
15
- examples: [
16
- 'claude-code-kanban preview-doc ./notes.md --session $CLAUDE_SESSION_ID',
17
- ],
18
- run: runPreviewCli,
19
60
  },
20
- 'link-doc': {
21
- summary: 'Link a file or URL to a session in the sidebar without opening the preview modal',
22
- usage: 'claude-code-kanban link-doc <file|url> --session <id> [--unlink] | link-doc --list --session <id> [--json]',
23
- flags: {
24
- '<file|url>': 'Any file type; one the preview cannot render opens in the editor. An http(s) URL opens in a new tab.',
25
- '--session <id>': 'Session to link the file to (required unless $PREVIEW_SESSION is set); full id or unique prefix',
26
- '--unlink': 'Remove the link instead of adding it (the file need not exist)',
27
- '--list': 'Print the docs the server holds for the session',
28
- '--json': 'With --list: output JSON',
61
+ pane: {
62
+ summary: 'Add live panes (a URL or a local file) to a session\'s view, without switching to them',
63
+ verbs: {
64
+ add: {
65
+ summary: 'Add a pane to a session; it opens as a tab next to Board, in the background',
66
+ usage: 'claude-code-kanban pane add <url|file> [--title <text>] [--session <id>] [--json]',
67
+ flags: {
68
+ '<url|file>': 'An http(s) URL, or a local HTML, markdown, text or image file (relative paths resolve against the current dir)',
69
+ '--title <text>': 'Tab title (default: the host or file name)',
70
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
71
+ '--json': 'Output JSON (the new pane, with frameable: true, false, or null when unknown)',
72
+ },
73
+ notes: 'The board does not switch to the new pane; the user opens it. Prints the pane id. The same target added again prints the pane it already has. A URL on the board\'s or the hub\'s own origin is refused, and a file must be one the board can preview. A site whose headers refuse framing (X-Frame-Options, CSP frame-ancestors) is still added, but its pane shows an "Open in new tab" card, and a note line says so.',
74
+ examples: [
75
+ 'claude-code-kanban pane add http://localhost:5173',
76
+ 'claude-code-kanban pane add ./report.html --title Report',
77
+ ],
78
+ run: runPaneAddCli,
79
+ },
80
+ rm: {
81
+ summary: 'Remove a pane from a session',
82
+ usage: 'claude-code-kanban pane rm <pane-id> [--session <id>]',
83
+ flags: {
84
+ '<pane-id>': 'The id `pane add` or `pane list` printed',
85
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
86
+ },
87
+ examples: ['claude-code-kanban pane rm p3'],
88
+ run: runPaneRmCli,
89
+ },
90
+ list: {
91
+ summary: 'List the panes of a session, in tab order',
92
+ usage: 'claude-code-kanban pane list [--session <id>] [--json]',
93
+ flags: {
94
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
95
+ '--json': 'Output JSON ({rev, panes: [{id, kind, target, title, addedAt}], updatedAt})',
96
+ },
97
+ run: runPaneListCli,
98
+ },
29
99
  },
30
- notes: 'The server keeps the link, so it shows when a browser tab opens later.',
31
- examples: [
32
- 'claude-code-kanban link-doc ./design.md --session $CLAUDE_SESSION_ID',
33
- 'claude-code-kanban link-doc https://github.com/org/repo/pull/12 --session $CLAUDE_SESSION_ID',
34
- 'claude-code-kanban link-doc --list --session $CLAUDE_SESSION_ID',
35
- ],
36
- run: runLinkDocCli,
37
100
  },
38
101
  session: {
39
102
  summary: 'List, search, open and inspect Claude Code sessions',
@@ -78,12 +141,13 @@ const COMMANDS = {
78
141
  run: runSessionOpenCli,
79
142
  },
80
143
  view: {
81
- summary: 'Show full session stats (metadata + context window + cost)',
144
+ summary: 'Show full session stats (metadata + context window + cost) and the transcript path',
82
145
  usage: 'claude-code-kanban session view <id> [--json]',
83
146
  flags: {
84
147
  '<id>': 'Full session id, or a unique prefix',
85
148
  '--json': 'Output JSON instead of formatted sections',
86
149
  },
150
+ notes: 'To learn what a session did, read its transcript (a .jsonl file, newest lines last).',
87
151
  examples: ['claude-code-kanban session view $CLAUDE_SESSION_ID'],
88
152
  run: runSessionViewCli,
89
153
  },
@@ -122,26 +186,6 @@ const COMMANDS = {
122
186
  ],
123
187
  run: runSessionPinCli,
124
188
  },
125
- pins: {
126
- summary: 'List sessions pinned/stickied via the dashboard or CLI',
127
- usage: 'claude-code-kanban session pins [--sticky] [--json]',
128
- flags: {
129
- '--sticky': 'Only sessions in sticky state',
130
- '--json': 'Output JSON instead of a table',
131
- },
132
- run: runSessionPinsCli,
133
- },
134
- peek: {
135
- summary: 'Show the last N messages from a session',
136
- usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
137
- flags: {
138
- '<id>': 'Full session id, or a unique prefix',
139
- '--limit <n>': 'Number of messages (default: 10, max: 50)',
140
- '--json': 'Output JSON instead of formatted lines',
141
- },
142
- examples: ['claude-code-kanban session peek 3fa9c1 --limit 20'],
143
- run: runSessionPeekCli,
144
- },
145
189
  },
146
190
  },
147
191
  task: {
@@ -231,7 +275,6 @@ for (const [noun, cmd] of Object.entries(COMMANDS)) {
231
275
  cmd.name = noun;
232
276
  for (const [verb, v] of Object.entries(cmd.verbs || {})) v.name = `${noun} ${verb}`;
233
277
  }
234
-
235
278
  function runCli(argv) {
236
279
  if (argv.includes('--version') || argv.includes('-v')) {
237
280
  console.log(require('./package.json').version);
@@ -266,7 +309,7 @@ function runCli(argv) {
266
309
  printLeafHelp(cli.entry);
267
310
  process.exit(0);
268
311
  }
269
- cli.entry.run(cli.args)
312
+ cli.entry.run(cli.args, cli.entry)
270
313
  .then(code => { process.exitCode = code; })
271
314
  .catch(e => { console.error(e.message); process.exitCode = 1; });
272
315
  return true;
@@ -423,12 +466,14 @@ async function cliFetch(urlPath, init) {
423
466
  }
424
467
  }
425
468
 
426
- // Every write verb posts JSON and reports failure the same way; `label` names the verb
427
- // in the error line. Returns false when the server refused, so callers just return 1.
428
- // Returns the parsed response body ({} when empty), or null after printing the failure.
429
- async function cliPostJson(urlPath, body, label, headers = {}) {
469
+ const cliPostJson = (urlPath, body, label, headers) => cliSendJson('POST', urlPath, body, label, headers);
470
+
471
+ // Every write verb sends JSON and reports failure the same way; `label` names the verb
472
+ // in the error line. Returns the parsed response body ({} when empty), or null after
473
+ // printing the failure, so callers just return 1.
474
+ async function cliSendJson(method, urlPath, body, label, headers = {}) {
430
475
  const res = await cliFetch(urlPath, {
431
- method: 'POST',
476
+ method,
432
477
  headers: { 'Content-Type': 'application/json', ...headers },
433
478
  body: JSON.stringify(body)
434
479
  });
@@ -447,13 +492,15 @@ function reportCliError(e) {
447
492
  async function runPreviewCli(args) {
448
493
  const filePathArg = args.find(a => !a.startsWith('--'));
449
494
  if (!filePathArg) {
450
- printLeafHelp(COMMANDS['preview-doc']);
495
+ printLeafHelp(COMMANDS.doc.verbs.preview);
451
496
  return 1;
452
497
  }
453
- const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
454
498
  const url = linkUrl(filePathArg);
455
- if (url) return previewUrlCli(url, sessionId);
456
- const abs = path.resolve(filePathArg);
499
+ if (url) return previewUrlCli(url, args);
500
+ // No $CLAUDE_CODE_SESSION_ID fallback: a session here switches the board's focus, and the
501
+ // agent must not move the board unless asked.
502
+ const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
503
+ const abs = cliPath(filePathArg);
457
504
  try {
458
505
  if (!await cliPostJson('/api/preview', { path: abs, sessionId }, 'Preview')) return 1;
459
506
  console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId})` : ''}`);
@@ -463,12 +510,9 @@ async function runPreviewCli(args) {
463
510
 
464
511
  // The modal cannot show a web page, so a URL is linked instead and the tab on screen
465
512
  // offers to open it.
466
- async function previewUrlCli(url, sessionArg) {
467
- const entry = COMMANDS['preview-doc'];
468
- if (!sessionArg) return usageError(entry, '--session is required for a URL: it is linked to the session.');
469
- const resolved = await resolveSessionByIdOrPrefix(sessionArg);
470
- if (!resolved) return 1;
471
- return postDocLink(url, resolved.id, { open: true });
513
+ async function previewUrlCli(url, args) {
514
+ const resolved = await resolveSessionArg(COMMANDS.doc.verbs.preview, args, 'a URL is linked to the session.');
515
+ return resolved ? postDocLink(url, resolved.id, { open: true }) : 1;
472
516
  }
473
517
 
474
518
  async function postDocLink(target, sessionId, { unlink = false, open = false } = {}) {
@@ -482,22 +526,94 @@ async function postDocLink(target, sessionId, { unlink = false, open = false } =
482
526
  } catch (e) { reportCliError(e); return 1; }
483
527
  }
484
528
 
485
- async function runLinkDocCli(args) {
486
- const entry = COMMANDS['link-doc'];
529
+ // Resolved to the full id because the browser keys linked docs by it, so a prefix won't match.
530
+ async function resolveSessionArg(entry, args, why) {
531
+ const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || process.env.CLAUDE_CODE_SESSION_ID;
532
+ if (!sessionArg) {
533
+ usageError(entry, `--session is required: ${why}`);
534
+ return null;
535
+ }
536
+ return resolveSessionByIdOrPrefix(sessionArg);
537
+ }
538
+
539
+ async function runDocLinkCli(args, entry) {
487
540
  const [filePathArg] = positionals(args, ['--session']);
488
- const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
489
- const list = args.includes('--list');
490
- if (!filePathArg && !list) {
541
+ if (!filePathArg) {
491
542
  printLeafHelp(entry);
492
543
  return 1;
493
544
  }
494
- if (!sessionArg) return usageError(entry, '--session is required: linked docs are stored per session.');
495
- const unlink = args.includes('--unlink');
496
- // Resolved here because the browser keys linked docs by full id, so a prefix won't match.
497
- const resolved = await resolveSessionByIdOrPrefix(sessionArg);
545
+ const resolved = await resolveSessionArg(entry, args, 'linked docs are stored per session.');
498
546
  if (!resolved) return 1;
499
- if (list) return printLinkedDocs(resolved.id, args.includes('--json'));
500
- return postDocLink(linkUrl(filePathArg) || path.resolve(filePathArg), resolved.id, { unlink });
547
+ const unlink = entry === COMMANDS.doc.verbs.unlink;
548
+ return postDocLink(cliTarget(filePathArg), resolved.id, { unlink });
549
+ }
550
+
551
+ async function runDocListCli(args, entry) {
552
+ const resolved = await resolveSessionArg(entry, args, 'linked docs are stored per session.');
553
+ return resolved ? printLinkedDocs(resolved.id, args.includes('--json')) : 1;
554
+ }
555
+
556
+ // The server resolves a path against its own cwd, so a relative one is made absolute here.
557
+ const cliTarget = (arg) => linkUrl(arg) || cliPath(arg);
558
+
559
+ const PANES_WHY = 'panes are stored per session.';
560
+
561
+ const panesPath = (sessionId, paneId) =>
562
+ `/api/panes/${encodeURIComponent(sessionId)}${paneId ? `/${encodeURIComponent(paneId)}` : ''}`;
563
+
564
+ async function runPaneAddCli(args, entry) {
565
+ const [target] = positionals(args, ['--session', '--title']);
566
+ if (!target) {
567
+ printLeafHelp(entry);
568
+ return 1;
569
+ }
570
+ const resolved = await resolveSessionArg(entry, args, PANES_WHY);
571
+ if (!resolved) return 1;
572
+ try {
573
+ const body = { target: cliTarget(target), title: getArgValue(args, 'title') || '' };
574
+ const out = await cliPostJson(panesPath(resolved.id), body, 'Pane add');
575
+ if (!out) return 1;
576
+ const { pane } = out;
577
+ let frameable;
578
+ if (pane.kind === 'url') {
579
+ try {
580
+ ({ frameable } = await cliGetJson(`${panesPath(resolved.id, pane.id)}/framing`, 'Pane framing'));
581
+ } catch {}
582
+ }
583
+ if (args.includes('--json')) console.log(JSON.stringify({ ...pane, frameable }, null, 2));
584
+ else console.log(`${pane.id} ${pane.title}${out.added ? '' : ' (already there)'}`);
585
+ if (frameable === false && !args.includes('--json')) {
586
+ console.log(`note: ${new URL(pane.target).host} refuses framing, so the pane shows an "Open in new tab" card. Look for a URL of the same content that allows framing.`);
587
+ }
588
+ return 0;
589
+ } catch (e) { reportCliError(e); return 1; }
590
+ }
591
+
592
+ async function runPaneRmCli(args, entry) {
593
+ const [paneId] = positionals(args, ['--session']);
594
+ if (!paneId) {
595
+ printLeafHelp(entry);
596
+ return 1;
597
+ }
598
+ const resolved = await resolveSessionArg(entry, args, PANES_WHY);
599
+ if (!resolved) return 1;
600
+ try {
601
+ if (!await cliSendJson('DELETE', panesPath(resolved.id, paneId), undefined, 'Pane rm')) return 1;
602
+ console.log(`Removed pane ${paneId} from session ${resolved.id.slice(0, 8)}`);
603
+ return 0;
604
+ } catch (e) { reportCliError(e); return 1; }
605
+ }
606
+
607
+ async function runPaneListCli(args, entry) {
608
+ const resolved = await resolveSessionArg(entry, args, PANES_WHY);
609
+ if (!resolved) return 1;
610
+ try {
611
+ const layout = await cliGetJson(panesPath(resolved.id), 'Pane list');
612
+ if (args.includes('--json')) console.log(JSON.stringify(layout, null, 2));
613
+ else if (!layout.panes.length) console.log(`No panes for session ${resolved.id.slice(0, 8)}.`);
614
+ else printTable(['ID', 'KIND', 'TITLE', 'TARGET'], layout.panes.map((p) => [p.id, p.kind, p.title, p.target]));
615
+ return 0;
616
+ } catch (e) { reportCliError(e); return 1; }
501
617
  }
502
618
 
503
619
  async function printLinkedDocs(sessionId, asJson) {
@@ -693,53 +809,6 @@ async function runSessionPinCli(args) {
693
809
  } catch (e) { reportCliError(e); return 1; }
694
810
  }
695
811
 
696
- async function runSessionPinsCli(args) {
697
- const stickyOnly = args.includes('--sticky');
698
- const asJson = args.includes('--json');
699
- const pinsMap = await fetchPinsMap();
700
- const items = Object.entries(pinsMap)
701
- .filter(([, state]) => !stickyOnly || state === 'sticky')
702
- .map(([id, state]) => ({ id, state }));
703
- if (!items.length) {
704
- if (asJson) console.log('[]'); else console.log('No pinned sessions.');
705
- return 0;
706
- }
707
- let sessions;
708
- try {
709
- sessions = await fetchSessionsList(items.length, items.map(p => p.id));
710
- } catch (e) { reportCliError(e); return 1; }
711
- const byId = new Map(sessions.map(s => [s.id, s]));
712
- const rows = items
713
- .map(p => {
714
- const s = byId.get(p.id) || {};
715
- return {
716
- id: p.id,
717
- state: p.state,
718
- status: s.id ? sessionStatus(s) : '-',
719
- age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
720
- project: path.basename(s.project || ''),
721
- title: s.customTitle || s.name || s.slug || '',
722
- };
723
- })
724
- .sort((a, b) => (a.state === b.state ? 0 : a.state === 'sticky' ? -1 : 1));
725
- if (asJson) {
726
- console.log(JSON.stringify(rows, null, 2));
727
- return 0;
728
- }
729
- const w = {
730
- id: 8,
731
- state: Math.max(5, ...rows.map(r => r.state.length)),
732
- status: Math.max(6, ...rows.map(r => r.status.length)),
733
- age: Math.max(3, ...rows.map(r => r.age.length)),
734
- project: Math.max(7, ...rows.map(r => r.project.length)),
735
- };
736
- console.log(`${'ID'.padEnd(w.id)} ${'STATE'.padEnd(w.state)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'PROJECT'.padEnd(w.project)} TITLE`);
737
- for (const r of rows) {
738
- console.log(`${r.id.slice(0, 8).padEnd(w.id)} ${r.state.padEnd(w.state)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.project.padEnd(w.project)} ${r.title}`);
739
- }
740
- return 0;
741
- }
742
-
743
812
  async function runSessionViewCli(args) {
744
813
  const idArg = args.find(a => !a.startsWith('--'));
745
814
  if (!idArg) {
@@ -796,49 +865,11 @@ async function runSessionViewCli(args) {
796
865
  if (rl.five_hour || rl.seven_day) {
797
866
  lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
798
867
  }
868
+ if (s.jsonlPath) lines.push(` Transcript: ${s.jsonlPath}`);
799
869
  console.log(lines.join('\n'));
800
870
  return 0;
801
871
  }
802
872
 
803
- async function runSessionPeekCli(args) {
804
- const idArg = args.find(a => !a.startsWith('--'));
805
- if (!idArg) {
806
- printLeafHelp(COMMANDS.session.verbs.peek);
807
- return 1;
808
- }
809
- const parsed = parseLimit(args, { fallback: 10 });
810
- if (!parsed.ok) return usageError(COMMANDS.session.verbs.peek, parsed.error);
811
- const limit = parsed.limit;
812
- const asJson = args.includes('--json');
813
- const resolved = await resolveSessionByIdOrPrefix(idArg);
814
- if (!resolved) return 1;
815
- try {
816
- const res = await cliFetch(`/api/sessions/${resolved.id}/messages?limit=${Math.min(limit, 50)}`);
817
- if (!res.ok) {
818
- console.error(`Peek failed (${res.status}): ${await res.text()}`);
819
- return 1;
820
- }
821
- const { messages } = await res.json();
822
- const ordered = [...messages].reverse();
823
- if (asJson) {
824
- console.log(JSON.stringify(ordered, null, 2));
825
- return 0;
826
- }
827
- if (!ordered.length) {
828
- console.log(`No messages for session ${resolved.id.slice(0, 8)}.`);
829
- return 0;
830
- }
831
- console.log(`Session ${resolved.id.slice(0, 8)}${resolved.customTitle ? ` — ${resolved.customTitle}` : ''}`);
832
- for (const m of ordered) {
833
- const ts = m.timestamp ? new Date(m.timestamp).toLocaleTimeString('en-GB', { hour12: false }) : '--:--:--';
834
- const label = (m.type === 'tool_use' ? (m.tool || 'tool') : m.type).padEnd(10);
835
- const body = (m.text || m.detail || m.description || '').replace(/\s+/g, ' ').trim();
836
- console.log(`[${ts}] ${label} ${body.slice(0, 120)}${body.length > 120 ? '…' : ''}`);
837
- }
838
- return 0;
839
- } catch (e) { reportCliError(e); return 1; }
840
- }
841
-
842
873
  function printTable(header, rows) {
843
874
  const last = header.length - 1;
844
875
  const width = header.map((h, i) => Math.max(h.length, ...rows.map(r => String(r[i]).length)));
@@ -1008,12 +1039,17 @@ function positionals(args, valueFlags) {
1008
1039
  // The server matches the folder against known project paths by string, so an 8.3 short name
1009
1040
  // or a differently cased drive letter from the shell must become the long, canonical form.
1010
1041
  function canonicalDir(dir) {
1011
- try { return fs.realpathSync.native(path.resolve(dir)); } catch (_) { return path.resolve(dir); }
1042
+ try { return fs.realpathSync.native(cliPath(dir)); } catch (_) { return cliPath(dir); }
1043
+ }
1044
+
1045
+ // Every path argument goes through here. Git Bash hands a native node `/c/x` for `C:\x`.
1046
+ function cliPath(arg) {
1047
+ return path.resolve(process.platform === 'win32' ? arg.replace(/^\/([a-zA-Z])(?=\/|$)/, '$1:/') : arg);
1012
1048
  }
1013
1049
 
1014
1050
  function textArg(args, name) {
1015
1051
  const file = getArgValue(args, `${name}-file`);
1016
- return file ? fs.readFileSync(path.resolve(file), 'utf8') : getArgValue(args, name);
1052
+ return file ? fs.readFileSync(cliPath(file), 'utf8') : getArgValue(args, name);
1017
1053
  }
1018
1054
 
1019
1055
  async function runDispatchStartCli(argv) {
@@ -1086,4 +1122,4 @@ async function runSkillsGetCli(args) {
1086
1122
  return 0;
1087
1123
  }
1088
1124
 
1089
- module.exports = { runCli };
1125
+ module.exports = { runCli, COMMANDS };
@@ -0,0 +1,79 @@
1
+ // Whether a page lets the board frame it, from its response headers. The parent page gets no
2
+ // signal when a cross-origin frame is refused (Chrome fires `load` on its error page), so the
3
+ // server reads the headers instead. Rules: CSP3 frame-ancestors (it wins over X-Frame-Options
4
+ // when present) and the HTML spec's X-Frame-Options processing.
5
+
6
+ const { portOf } = require('./panes');
7
+
8
+ const PROBE_TIMEOUT_MS = 5000;
9
+
10
+ function parseOrigin(value) {
11
+ try {
12
+ const u = new URL(value);
13
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ // CSP3 lets an http source match https too.
20
+ const schemeOk = (want, got) => want === got || (want === 'http:' && got === 'https:');
21
+
22
+ function matchesSource(source, ancestor, self) {
23
+ const s = source.toLowerCase();
24
+ if (s === "'self'") return ancestor.origin === self.origin;
25
+ if (s === '*') return true;
26
+ if (/^[a-z][a-z0-9+.-]*:$/.test(s)) return schemeOk(s, ancestor.protocol);
27
+ const m = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[^:/]+)(?::(\d+|\*))?(?:\/.*)?$/.exec(s);
28
+ if (!m) return false;
29
+ const [, scheme, host, port] = m;
30
+ const want = scheme ? `${scheme}:` : self.protocol;
31
+ if (!schemeOk(want, ancestor.protocol)) return false;
32
+ const hostOk = host === '*' || (host.startsWith('*.') ? ancestor.hostname.endsWith(host.slice(1)) : host === ancestor.hostname);
33
+ if (!hostOk) return false;
34
+ if (port === '*' || (!port && ancestor.port === '')) return true;
35
+ return Number(port) === portOf(ancestor) || (port === '80' && ancestor.protocol === 'https:' && ancestor.port === '');
36
+ }
37
+
38
+ function frameAncestorsLists(csp) {
39
+ const lists = [];
40
+ for (const policy of String(csp || '').split(',')) {
41
+ for (const directive of policy.split(';')) {
42
+ const [name, ...sources] = directive.trim().split(/\s+/);
43
+ if (name?.toLowerCase() === 'frame-ancestors') {
44
+ lists.push(sources);
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ return lists;
50
+ }
51
+
52
+ // headers: Fetch Headers. target: the final URL of the response. ancestors: every origin above
53
+ // the frame. Answers true, false, or null when it cannot tell.
54
+ function framingVerdict(headers, { target, ancestors }) {
55
+ const self = parseOrigin(target);
56
+ const chain = ancestors.map(parseOrigin).filter(Boolean);
57
+ if (!self || !chain.length) return null;
58
+ const lists = frameAncestorsLists(headers.get('content-security-policy'));
59
+ if (lists.length) return lists.every((sources) => chain.every((a) => sources.some((src) => matchesSource(src, a, self))));
60
+ const xfo = String(headers.get('x-frame-options') || '')
61
+ .split(',')
62
+ .map((v) => v.trim().toLowerCase());
63
+ if (xfo.includes('deny')) return false;
64
+ return !xfo.includes('sameorigin') || chain.every((a) => a.origin === self.origin);
65
+ }
66
+
67
+ // GETs the page and drops the body once the headers arrive. A network error or a timeout
68
+ // answers null, and the board frames the page anyway.
69
+ async function probeFraming(target, ancestors, { fetchImpl = fetch } = {}) {
70
+ try {
71
+ const res = await fetchImpl(target, { redirect: 'follow', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
72
+ res.body?.cancel().catch(() => {});
73
+ return framingVerdict(res.headers, { target: res.url || target, ancestors });
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ module.exports = { framingVerdict, parseOrigin, probeFraming };
@@ -0,0 +1,6 @@
1
+ // An error a route answers with `status`; `code` is the machine-readable reason, where one is set.
2
+ function httpError(status, message, code) {
3
+ return Object.assign(new Error(message), { status, code });
4
+ }
5
+
6
+ module.exports = { httpError };
@@ -1,10 +1,8 @@
1
1
  // Server copy of each session's linked docs: file paths and http(s) URLs. The browser keeps its own list in localStorage
2
- // and merges this one in when it connects, so a `link-doc` sent while no tab is open is not lost.
2
+ // and merges this one in when it connects, so a `doc link` sent while no tab is open is not lost.
3
3
  // Newest first, capped like the browser list.
4
4
 
5
- const { linkUrl } = require('../public/link-url');
6
-
7
- const MAX_PER_SESSION = 20;
5
+ const { linkUrl, MAX_LINKED_DOCS } = require('../public/link-url');
8
6
 
9
7
  // Same rule as `canonicalPath` in public/app.js: one Windows file is reached as `C:\a` and `c:/a`.
10
8
  function canonicalPath(p) {
@@ -23,7 +21,7 @@ function createLinkedDocStore({ load, save }) {
23
21
  if (saved && typeof saved === 'object') {
24
22
  for (const [id, paths] of Object.entries(saved)) {
25
23
  const clean = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p) : [];
26
- if (clean.length) bySession.set(id, clean.slice(0, MAX_PER_SESSION));
24
+ if (clean.length) bySession.set(id, clean.slice(0, MAX_LINKED_DOCS));
27
25
  }
28
26
  }
29
27
 
@@ -32,7 +30,7 @@ function createLinkedDocStore({ load, save }) {
32
30
  function link(sessionId, filePath) {
33
31
  const key = canonicalPath(filePath);
34
32
  const rest = (bySession.get(sessionId) || []).filter((p) => canonicalPath(p) !== key);
35
- bySession.set(sessionId, [filePath, ...rest].slice(0, MAX_PER_SESSION));
33
+ bySession.set(sessionId, [filePath, ...rest].slice(0, MAX_LINKED_DOCS));
36
34
  persist();
37
35
  }
38
36
 
@@ -55,4 +53,4 @@ function createLinkedDocStore({ load, save }) {
55
53
  return { link, unlink, get, all };
56
54
  }
57
55
 
58
- module.exports = { createLinkedDocStore, canonicalPath, linkUrl, MAX_PER_SESSION };
56
+ module.exports = { createLinkedDocStore, canonicalPath, linkUrl, MAX_LINKED_DOCS };
@@ -0,0 +1,6 @@
1
+ function oneLine(text, max) {
2
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
3
+ return String(text ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, max);
4
+ }
5
+
6
+ module.exports = { oneLine };