claude-code-kanban 6.1.2 → 7.0.1
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 +3 -2
- package/cli.js +200 -164
- package/lib/frame-policy.js +79 -0
- package/lib/http-error.js +6 -0
- package/lib/linked-docs.js +5 -7
- package/lib/one-line.js +6 -0
- package/lib/panes.js +174 -0
- package/lib/retention.js +14 -8
- package/lib/scratchpad-dir.js +41 -0
- package/lib/session-events.js +3 -4
- package/lib/terminal.js +46 -6
- package/lib/worktrees.js +11 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +30 -22
- package/public/app.js +940 -111
- package/public/index.html +37 -0
- package/public/link-url.js +6 -3
- package/public/style.css +452 -8
- package/public/tree-highlight.js +74 -0
- package/server.js +133 -40
- package/skill-guides/dispatch.md +1 -1
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
|
|
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
|
-
- `
|
|
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
|
-
|
|
9
|
-
summary: '
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
21
|
-
summary: '
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
|
|
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
|
|
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
|
|
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,
|
|
456
|
-
|
|
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,
|
|
467
|
-
const
|
|
468
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
const list = args.includes('--list');
|
|
490
|
-
if (!filePathArg && !list) {
|
|
541
|
+
if (!filePathArg) {
|
|
491
542
|
printLeafHelp(entry);
|
|
492
543
|
return 1;
|
|
493
544
|
}
|
|
494
|
-
|
|
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
|
-
|
|
500
|
-
return postDocLink(
|
|
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(
|
|
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(
|
|
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 };
|
package/lib/linked-docs.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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,
|
|
56
|
+
module.exports = { createLinkedDocStore, canonicalPath, linkUrl, MAX_LINKED_DOCS };
|
package/lib/one-line.js
ADDED