claude-code-kanban 7.4.0 → 7.6.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.
Files changed (48) hide show
  1. package/README.md +5 -3
  2. package/cli.js +95 -29
  3. package/hub-app.json +5 -0
  4. package/lib/panes.js +41 -6
  5. package/lib/parsers.js +42 -3
  6. package/lib/retention.js +54 -6
  7. package/lib/scratchpad-dir.js +19 -2
  8. package/lib/session-cache.js +1 -1
  9. package/lib/session-events.js +174 -47
  10. package/lib/show.js +385 -0
  11. package/lib/task-dir.js +26 -0
  12. package/lib/terminal-client.js +14 -1
  13. package/lib/terminal-host.js +2 -1
  14. package/lib/terminal.js +28 -8
  15. package/package.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
  17. package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +136 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/index.ts +4 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
  20. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
  21. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +3 -3
  22. package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
  23. package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
  24. package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
  25. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
  26. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
  27. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
  28. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
  29. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
  30. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
  31. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
  32. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
  33. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
  34. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
  35. package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
  36. package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
  37. package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +133 -0
  38. package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
  39. package/public/app.js +2151 -1108
  40. package/public/index.html +19 -36
  41. package/public/style.css +1047 -125
  42. package/public/terminal-frame.js +0 -2
  43. package/public/tree-highlight.js +137 -15
  44. package/server.js +237 -178
  45. package/lib/dispatch.js +0 -32
  46. package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
  47. package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
  48. package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
package/README.md CHANGED
@@ -82,7 +82,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
82
82
  - **Embedded terminal.** Run a real Claude Code process for any session next to its board (<kbd>Ctrl</kbd>+<kbd>&#96;</kbd>). <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> resumes a past session and <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> swaps to the previous one. The terminal is off by default when the board runs alone. Start it with `--enable-terminal` and open the `#t=<token>` link the server prints. [Embedded terminal](https://nikiforovall.blog/claude-code-kanban/guides/embedded-terminal/)
83
83
  - **New session.** <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd> opens a dialog to pick a folder, a name, a model, an optional git worktree and a first prompt. Needs the terminal.
84
84
  - **Dispatch.** Hand a written task to a new session with `claude-code-kanban dispatch start`, or ask Claude to do it with the `dispatch` skill. Args after `--` go to `claude` as they are, and the started session reports back with `SendMessage`. Needs the terminal. [Dispatch tasks to other sessions](https://nikiforovall.blog/claude-code-kanban/guides/dispatch/)
85
- - **Steer with card moves.** Run `/claude-code-kanban:follow` in a session, then drag its cards. Claude starts, parks or stops the task. [Claude Code plugin skills](https://nikiforovall.blog/claude-code-kanban/guides/plugin-skills/)
85
+ - **Steer with card moves.** Drag a session's cards. Claude starts, parks or stops the task. Turn it off with `boardEvents.enabled: false` in `.cck/config.json`. [Claude Code plugin skills](https://nikiforovall.blog/claude-code-kanban/guides/plugin-skills/)
86
86
  - **Review comments.** Select text in a previewed file or the plan, add comments and send them to the session in one step. [Review comments](https://nikiforovall.blog/claude-code-kanban/guides/review-comments/)
87
87
 
88
88
  <picture>
@@ -98,7 +98,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
98
98
  ### Organize
99
99
 
100
100
  - **Session groups.** Drag sessions and projects from different folders into one named group in the sidebar. [Session groups](https://nikiforovall.blog/claude-code-kanban/guides/session-groups/)
101
- - **Filters, search and pins.** Filter by project and activity, search across sessions and tasks, pin a session or make it sticky. <kbd>Shift</kbd>+<kbd>P</kbd> opens the session picker.
101
+ - **Filters, search and pins.** Filter by project and activity, search across sessions and tasks, pin a session or make it sticky. <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd> opens the session picker, and <kbd>Shift</kbd>+<kbd>P</kbd> the project picker.
102
102
  - **Scratchpad and linked documents.** Keep a note per session, project or group (<kbd>N</kbd>), and link files or web URLs to a session to open them in one click.
103
103
  - **Themes.** 17 color themes, each in light and dark. <kbd>T</kbd> switches the mode.
104
104
  - **Keyboard first.** Arrow keys move through tasks, and <kbd>Tab</kbd> moves between the sidebar and the board. Press <kbd>?</kbd> for the full list. [Keyboard shortcuts](https://nikiforovall.blog/claude-code-kanban/reference/keyboard-shortcuts/)
@@ -111,7 +111,8 @@ With no subcommand, `claude-code-kanban` starts the server. Subcommands talk to
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
113
  - `pane add|rm|list` to add live panes to a session's view.
114
- - `dispatch start|list` to start sessions with a task in the board's terminal.
114
+ - `dispatch start|list|end` to start sessions with a task in the board's terminal, and end them.
115
+ - `skills get <name>` to print the guide for a skill bundled with this version.
115
116
 
116
117
  Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.blog/claude-code-kanban/reference/cli/).
117
118
 
@@ -126,6 +127,7 @@ EDITOR="code -w" claude-code-kanban # Command for Open in editor (default
126
127
 
127
128
  - The server listens on `127.0.0.1` only and has no authentication. To reach it from another machine, use `--host` and `--allowed-hosts`, and do it only on a network you trust.
128
129
  - UI approvals are on by default. Turn them off or tune them in `<config-dir>/.cck/config.json`.
130
+ - Board events (card moves and reviews sent to the session as prompts) are on by default. Turn them off with `"boardEvents": {"enabled": false}` in the same file.
129
131
  - Terminal settings, such as the shell, font size and scrollback, go in the `CCK_TERMINAL` JSON variable.
130
132
 
131
133
  See [Configuration](https://nikiforovall.blog/claude-code-kanban/reference/configuration/) for every setting, and [Troubleshooting](https://nikiforovall.blog/claude-code-kanban/troubleshooting/) for common problems.
package/cli.js CHANGED
@@ -224,7 +224,7 @@ const COMMANDS = {
224
224
  },
225
225
  },
226
226
  dispatch: {
227
- summary: 'Start a Claude Code session for a task in cck\'s terminal',
227
+ summary: 'Start, list and end Claude Code sessions for tasks in cck\'s terminal',
228
228
  verbs: {
229
229
  start: {
230
230
  summary: 'Start claude in cck\'s terminal with a task; prints the session id',
@@ -257,6 +257,18 @@ const COMMANDS = {
257
257
  },
258
258
  run: runDispatchListCli,
259
259
  },
260
+ end: {
261
+ summary: 'End the terminal of a session this session started',
262
+ usage: 'claude-code-kanban dispatch end (<session id or prefix> | --name <n>) [--json]',
263
+ flags: {
264
+ '<session id or prefix>': 'A session from `dispatch list`',
265
+ '--name <n>': 'The session\'s --name from dispatch start',
266
+ '--json': 'Output JSON',
267
+ },
268
+ notes: 'Needs the terminal token, like dispatch start. The worktree stays.',
269
+ examples: ['claude-code-kanban dispatch end --name api-worker'],
270
+ run: runDispatchEndCli,
271
+ },
260
272
  },
261
273
  },
262
274
  skills: {
@@ -333,6 +345,20 @@ function resolveCliCommand(argv) {
333
345
  return { kind: 'leaf', name: `${noun} ${verb}`, entry: entry.verbs[verb], args: argv.slice(4) };
334
346
  }
335
347
 
348
+ // The website's reference/cli.md has the same table; change both.
349
+ const SERVER_FLAGS = [
350
+ ['--port <n>', 'PORT', 'Port (default 3541)'],
351
+ ['--open', '', 'Open the board in the browser'],
352
+ ['--dir <path>', 'CLAUDE_CONFIG_DIR', 'Config dir (default ~/.claude); also for --install'],
353
+ ['--enable-terminal', '', 'Turn on the embedded terminal'],
354
+ ['--terminal-shell <shell>', 'CCK_TERMINAL_SHELL', 'gitbash, a program on PATH, or a path'],
355
+ ['--host <addr>', 'HOST', 'Listen address (default 127.0.0.1)'],
356
+ ['--allowed-hosts <list>', 'ALLOWED_HOSTS', 'Extra Host header values, comma-separated'],
357
+ ['--<app>-url <url>', '<APP>_URL', 'Link to app: marketplace, cost, memory'],
358
+ ['--install, --uninstall', '', 'Install or remove the plugin'],
359
+ ['--yes', '', 'With --install: no prompt'],
360
+ ];
361
+
336
362
  function printTopHelp() {
337
363
  console.log('Usage: claude-code-kanban <command> [args] [--flags]\n');
338
364
  console.log('Commands:');
@@ -344,13 +370,11 @@ function printTopHelp() {
344
370
  console.log('\nFlags:');
345
371
  console.log(' --help, -h Show help (top-level, noun-level, or leaf-level)');
346
372
  console.log(' --version, -v Print version and exit');
347
- console.log('\nServer mode (no subcommand):');
348
- console.log(' --port <n> Port to listen on (default 3541)');
349
- console.log(' --dir <path> Override Claude config dir (default ~/.claude); also targets --install/--uninstall');
350
- console.log(' --open Open browser on start');
351
- console.log(' --install, --uninstall Install or remove the plugin');
352
- console.log(' --yes With --install: install without a prompt');
353
- console.log('\nEnvironment:');
373
+ console.log('\nServer (no command; a flag wins over its env var):');
374
+ const flagPad = Math.max(...SERVER_FLAGS.map(([flag]) => flag.length)) + 2;
375
+ const envPad = Math.max(...SERVER_FLAGS.map(([, env]) => env.length)) + 2;
376
+ for (const [flag, env, desc] of SERVER_FLAGS) console.log(` ${flag.padEnd(flagPad)}${env.padEnd(envPad)}${desc}`);
377
+ console.log('\nCommand env:');
354
378
  console.log(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
355
379
  console.log(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
356
380
  console.log(' CLAUDE_CONFIG_DIR Claude config dir whose board to use');
@@ -492,7 +516,7 @@ function reportCliError(e) {
492
516
  }
493
517
 
494
518
  async function runPreviewCli(args) {
495
- const filePathArg = args.find(a => !a.startsWith('--'));
519
+ const [filePathArg] = positionals(args, ['--session']);
496
520
  if (!filePathArg) {
497
521
  printLeafHelp(COMMANDS.doc.verbs.preview);
498
522
  return 1;
@@ -620,9 +644,8 @@ async function runPaneListCli(args, entry) {
620
644
 
621
645
  async function printLinkedDocs(sessionId, asJson) {
622
646
  try {
623
- const res = await cliFetch(`/api/document/links?session=${encodeURIComponent(sessionId)}`);
624
- if (!res.ok) throw new Error(`Failed to fetch linked docs (${res.status})`);
625
- const paths = (await res.json())[sessionId] || [];
647
+ const links = await cliGetJson(`/api/document/links?session=${encodeURIComponent(sessionId)}`, 'Linked docs');
648
+ const paths = links[sessionId] || [];
626
649
  if (asJson) console.log(JSON.stringify(paths, null, 2));
627
650
  else if (!paths.length) console.log(`No linked docs for session ${sessionId.slice(0, 8)}.`);
628
651
  else for (const p of paths) console.log(p);
@@ -655,9 +678,11 @@ async function fetchSessionsList(limit, pinnedIds = [], project = null) {
655
678
  const q = limit === null ? 'all' : String(limit);
656
679
  const pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
657
680
  const projectQ = project ? `&project=${encodeURIComponent(project)}` : '';
658
- const res = await cliFetch(`/api/sessions?limit=${q}${pinnedQ}${projectQ}`);
659
- if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
660
- return res.json();
681
+ return cliGetJson(`/api/sessions?limit=${q}${pinnedQ}${projectQ}`, 'Session list');
682
+ }
683
+
684
+ function fetchSessionsByIds(ids, label) {
685
+ return cliGetJson(`/api/sessions?limit=1&include=${ids.map(encodeURIComponent).join(',')}`, label);
661
686
  }
662
687
 
663
688
  async function fetchPinsMap() {
@@ -823,7 +848,7 @@ async function runSessionViewCli(args) {
823
848
  if (!resolved) return 1;
824
849
  let list;
825
850
  try {
826
- list = await fetchSessionsList(null);
851
+ list = await fetchSessionsByIds([resolved.id], 'Session view');
827
852
  } catch (e) { reportCliError(e); return 1; }
828
853
  const s = list.find(x => x.id === resolved.id);
829
854
  if (!s) {
@@ -900,7 +925,7 @@ async function runSessionSearchCli(args) {
900
925
  if (!parsed.ok) return usageError(entry, parsed.error);
901
926
  try {
902
927
  const ids = (await cliGetJson(`/api/sessions/search?q=${encodeURIComponent(text)}`, 'Search')).slice(0, parsed.limit);
903
- const list = ids.length ? await cliGetJson(`/api/sessions?limit=1&include=${ids.join(',')}`, 'Search') : [];
928
+ const list = ids.length ? await fetchSessionsByIds(ids, 'Search') : [];
904
929
  const byId = new Map(list.map(s => [s.id, s]));
905
930
  const rows = ids.map(id => byId.get(id) || { id });
906
931
  if (args.includes('--json')) {
@@ -1054,20 +1079,26 @@ function textArg(args, name) {
1054
1079
  return file ? fs.readFileSync(cliPath(file), 'utf8') : getArgValue(args, name);
1055
1080
  }
1056
1081
 
1057
- async function runDispatchStartCli(argv) {
1058
- const sep = argv.indexOf('--');
1059
- const args = sep === -1 ? argv : argv.slice(0, sep);
1060
- const claudeArgs = sep === -1 ? [] : argv.slice(sep + 1);
1082
+ function readTerminalToken() {
1061
1083
  const port = cliTargetPort();
1062
1084
  if (port === null) {
1063
1085
  console.error(unreachable());
1064
- return 1;
1086
+ return null;
1065
1087
  }
1066
1088
  const token = port && readCckJson(`terminal-tokens/${port}.json`)?.token;
1067
1089
  if (!token) {
1068
1090
  console.error(`No terminal token for ${displayPath(getClaudeDir())} at ${cliBaseUrl()}. The cck server must be running with the terminal enabled.`);
1069
- return 1;
1091
+ return null;
1070
1092
  }
1093
+ return token;
1094
+ }
1095
+
1096
+ async function runDispatchStartCli(argv) {
1097
+ const sep = argv.indexOf('--');
1098
+ const args = sep === -1 ? argv : argv.slice(0, sep);
1099
+ const claudeArgs = sep === -1 ? [] : argv.slice(sep + 1);
1100
+ const token = readTerminalToken();
1101
+ if (!token) return 1;
1071
1102
  let spec;
1072
1103
  try { spec = textArg(args, 'spec'); } catch (e) { console.error(e.message); return 1; }
1073
1104
  if (!spec) {
@@ -1107,15 +1138,50 @@ async function runDispatchStartCli(argv) {
1107
1138
  } catch (e) { reportCliError(e); return 1; }
1108
1139
  }
1109
1140
 
1110
- async function runDispatchListCli(args) {
1141
+ async function fetchDispatches(all) {
1111
1142
  const q = new URLSearchParams();
1112
- if (!args.includes('--all') && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
1143
+ if (!all && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
1144
+ return (await cliGetJson(`/api/dispatch?${q}`, 'Dispatch list')).running.sort((a, b) => b.startedAt - a.startedAt);
1145
+ }
1146
+
1147
+ const dispatchLine = (r) => `${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`;
1148
+
1149
+ async function runDispatchListCli(args) {
1113
1150
  try {
1114
- const res = await cliFetch(`/api/dispatch?${q}`);
1115
- const rows = (await res.json()).running.sort((a, b) => b.startedAt - a.startedAt);
1151
+ const rows = await fetchDispatches(args.includes('--all'));
1116
1152
  if (args.includes('--json')) console.log(JSON.stringify(rows, null, 2));
1117
1153
  else if (!rows.length) console.log('No dispatches.');
1118
- else for (const r of rows) console.log(`${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`);
1154
+ else for (const r of rows) console.log(dispatchLine(r));
1155
+ return 0;
1156
+ } catch (e) { reportCliError(e); return 1; }
1157
+ }
1158
+
1159
+ async function runDispatchEndCli(args) {
1160
+ const entry = COMMANDS.dispatch.verbs.end;
1161
+ const name = getArgValue(args, 'name');
1162
+ const id = positionals(args, ['--name'])[0];
1163
+ if (!id && !name) {
1164
+ printLeafHelp(entry);
1165
+ return 1;
1166
+ }
1167
+ if (id && name) return usageError(entry, 'Give one of <session id or prefix> or --name.');
1168
+ const token = readTerminalToken();
1169
+ if (!token) return 1;
1170
+ try {
1171
+ const matches = (await fetchDispatches(false)).filter(r => name ? r.name === name : r.session.startsWith(id));
1172
+ if (!matches.length) {
1173
+ console.error(`No running dispatch matches "${name || id}". Run "claude-code-kanban dispatch list".`);
1174
+ return 1;
1175
+ }
1176
+ if (matches.length > 1) {
1177
+ console.error(`"${name || id}" matches ${matches.length} dispatches:`);
1178
+ for (const r of matches) console.error(` ${dispatchLine(r)}`);
1179
+ return 1;
1180
+ }
1181
+ const { session } = matches[0];
1182
+ if (!await cliSendJson('DELETE', `/api/terminals/${session}`, undefined, 'Dispatch end', { 'x-terminal-token': token })) return 1;
1183
+ if (args.includes('--json')) console.log(JSON.stringify({ session }, null, 2));
1184
+ else console.log(`Ended session ${session}`);
1119
1185
  return 0;
1120
1186
  } catch (e) { reportCliError(e); return 1; }
1121
1187
  }
@@ -1131,4 +1197,4 @@ async function runSkillsGetCli(args) {
1131
1197
  return 0;
1132
1198
  }
1133
1199
 
1134
- module.exports = { runCli, COMMANDS };
1200
+ module.exports = { runCli, COMMANDS, SERVER_FLAGS };
package/hub-app.json CHANGED
@@ -6,6 +6,11 @@
6
6
  "run": { "entry": "server.js", "defaultPort": 3541 },
7
7
  "publishes": ["session.changed"],
8
8
  "keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s"] },
9
+ "plugin": {
10
+ "id": "claude-code-kanban@claude-code-kanban",
11
+ "path": "plugin/plugins/claude-code-kanban",
12
+ "install": "claude-code-kanban --install --yes"
13
+ },
9
14
  "provides": {
10
15
  "projects": { "path": "/api/projects" },
11
16
  "terminal": { "liveWork": "/api/terminals" }
package/lib/panes.js CHANGED
@@ -11,7 +11,10 @@ const { pruneMap } = require('./retention');
11
11
 
12
12
  const MAX_PANES = 50;
13
13
  const MAX_TITLE = 100;
14
- const KINDS = new Set(['url', 'html', 'markdown', 'text', 'image']);
14
+ const FILE_KINDS = new Set(['html', 'markdown', 'text', 'image']);
15
+ const KINDS = new Set([...FILE_KINDS, 'url', 'message']);
16
+ const MAX_MESSAGE_TARGET = 300;
17
+ const isFileKind = (kind) => FILE_KINDS.has(kind);
15
18
 
16
19
  const portOf = (u) => Number(u.port || (u.protocol === 'https:' ? 443 : 80));
17
20
 
@@ -39,9 +42,10 @@ function isOwnOrigin(url, { boardPort, boardHosts = [], hubUrl }) {
39
42
 
40
43
  const emptyLayout = () => ({ rev: 0, nextId: 1, panes: [], updatedAt: null });
41
44
 
42
- const targetKey = (kind, target) => (kind === 'url' ? `url:${target}` : `file:${canonicalPath(target)}`);
45
+ const targetKey = (kind, target) => (isFileKind(kind) ? `file:${canonicalPath(target)}` : `${kind}:${target}`);
43
46
 
44
47
  function defaultTitle(kind, target) {
48
+ if (kind === 'message') return 'Message';
45
49
  if (kind !== 'url') return path.basename(target);
46
50
  try {
47
51
  return new URL(target).host;
@@ -50,6 +54,8 @@ function defaultTitle(kind, target) {
50
54
  }
51
55
  }
52
56
 
57
+ const paneTitle = (kind, target, title) => oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE);
58
+
53
59
  const idNumber = (id) => Number(id.slice(1)) || 0;
54
60
 
55
61
  function cleanLayout(raw) {
@@ -118,8 +124,12 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
118
124
  }
119
125
 
120
126
  // The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
121
- function add(sessionId, { kind, target, title }) {
127
+ // `show` marks a show card's file, which the board renders with the card's theme.
128
+ function add(sessionId, { kind, target, title, show }) {
122
129
  if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
130
+ if (kind === 'message' && target.length > MAX_MESSAGE_TARGET) {
131
+ throw httpError(400, `A message target holds at most ${MAX_MESSAGE_TARGET} characters`, 'bad_target');
132
+ }
123
133
  reload();
124
134
  const layout = bySession.get(sessionId) || emptyLayout();
125
135
  const key = targetKey(kind, target);
@@ -130,7 +140,8 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
130
140
  id: `p${layout.nextId++}`,
131
141
  kind,
132
142
  target,
133
- title: oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE),
143
+ title: paneTitle(kind, target, title),
144
+ ...(show && { show: true }),
134
145
  addedAt: now(),
135
146
  };
136
147
  layout.panes.push(pane);
@@ -148,6 +159,30 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
148
159
  return write(sessionId, layout);
149
160
  }
150
161
 
162
+ // Drops the show panes of these files; returns the new layout, or null when none matched.
163
+ function removeShow(sessionId, files) {
164
+ reload();
165
+ const layout = bySession.get(sessionId);
166
+ if (!layout) return null;
167
+ const keys = new Set(files.map((f) => targetKey('html', f)));
168
+ const kept = layout.panes.filter((p) => !(p.show && keys.has(targetKey(p.kind, p.target))));
169
+ if (kept.length === layout.panes.length) return null;
170
+ layout.panes = kept;
171
+ return write(sessionId, layout);
172
+ }
173
+
174
+ // An empty title gives the pane its default title back. Returns null when the session has no such pane.
175
+ function rename(sessionId, paneId, title) {
176
+ reload();
177
+ const layout = bySession.get(sessionId);
178
+ const pane = layout?.panes.find((p) => p.id === paneId);
179
+ if (!pane) return null;
180
+ const next = paneTitle(pane.kind, pane.target, title);
181
+ if (next === pane.title) return get(sessionId);
182
+ pane.title = next;
183
+ return write(sessionId, layout);
184
+ }
185
+
151
186
  // `order` is every pane id of the session, in the new tab order.
152
187
  function reorder(sessionId, order) {
153
188
  reload();
@@ -171,12 +206,12 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
171
206
  function fileTargets() {
172
207
  const out = new Set();
173
208
  for (const layout of bySession.values()) {
174
- for (const p of layout.panes) if (p.kind !== 'url') out.add(p.target);
209
+ for (const p of layout.panes) if (isFileKind(p.kind)) out.add(p.target);
175
210
  }
176
211
  return out;
177
212
  }
178
213
 
179
- return { get, add, remove, reorder, prune, reload, fileTargets };
214
+ return { get, add, remove, removeShow, rename, reorder, prune, reload, fileTargets };
180
215
  }
181
216
 
182
217
  module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
package/lib/parsers.js CHANGED
@@ -383,6 +383,11 @@ const modeOf = (data) => {
383
383
  return (data.type === 'user' || data.type === 'permission-mode') && data.permissionMode || null;
384
384
  };
385
385
 
386
+ // Claude Code writes its environment, scratchpad dir included, at each process start, so a resume
387
+ // in a worktree adds a second one: the last wins.
388
+ const scratchpadOf = (data) =>
389
+ (data.type === 'attachment' && data.attachment?.type === 'environment' && data.attachment.snapshot?.scratchpadDirectory) || null;
390
+
386
391
  const isMainReply = (data) =>
387
392
  data.type === 'assistant' && !data.isSidechain && data.message?.usage && data.message.model !== '<synthetic>';
388
393
 
@@ -426,7 +431,7 @@ const withoutUsage = (reply) => reply && { ...reply, usage: null };
426
431
  // change or truncation (size < scannedUpTo).
427
432
 
428
433
  function readSessionInfoFromJsonl(jsonlPath) {
429
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, agentName: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null, cacheTtl: null, lastReply: null };
434
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, agentName: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null, cacheTtl: null, lastReply: null, scratchpadDir: null };
430
435
  let stat;
431
436
  let fd;
432
437
  try {
@@ -449,6 +454,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
449
454
  permissionMode: cached.permissionMode || null,
450
455
  cacheTtl: cached.cacheTtl || null,
451
456
  lastReply: cached.lastReply || null,
457
+ scratchpadDir: cached.scratchpadDir || null,
452
458
  ...pickTitles(readTitles(jsonlPath, stat))
453
459
  };
454
460
  }
@@ -463,6 +469,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
463
469
  result.permissionMode = cached.permissionMode || null;
464
470
  result.cacheTtl = cached.cacheTtl || null;
465
471
  result.lastReply = cached.lastReply || null;
472
+ result.scratchpadDir = cached.scratchpadDir || null;
466
473
  }
467
474
 
468
475
  let lastCwdSeen = result.cwd;
@@ -480,6 +487,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
480
487
  if (mode) result.permissionMode = mode;
481
488
  const ttl = cacheTtlOf(data);
482
489
  if (ttl) result.cacheTtl = ttl;
490
+ const scratchpad = scratchpadOf(data);
491
+ if (scratchpad) result.scratchpadDir = scratchpad;
483
492
  if (isMainUser(data)) lastUserTs = data.timestamp;
484
493
  else if (isMainReply(data)) result.lastReply = replyOf(data, lastUserTs);
485
494
  if (data.subtype === 'compact_boundary') result.lastReply = withoutUsage(result.lastReply);
@@ -556,6 +565,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
556
565
  let latestTailCwd = null;
557
566
  let latestTailMode = null;
558
567
  let latestTailTtl = null;
568
+ let latestTailScratchpad = null;
559
569
  let tailReply = null;
560
570
  let tailRequestTs = null;
561
571
  let compactedAfterReply = false;
@@ -583,6 +593,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
583
593
  try {
584
594
  const data = JSON.parse(lines[i]);
585
595
  if (!latestTailTtl) latestTailTtl = cacheTtlOf(data);
596
+ if (!latestTailScratchpad) latestTailScratchpad = scratchpadOf(data);
586
597
  if (!result.slug && data.slug) result.slug = data.slug;
587
598
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
588
599
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
@@ -601,6 +612,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
601
612
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
602
613
  if (latestTailMode) result.permissionMode = latestTailMode;
603
614
  if (latestTailTtl) result.cacheTtl = latestTailTtl;
615
+ // A record in the unread middle would be newer than the head's, so the head's cannot be trusted.
616
+ if (latestTailScratchpad || !reachedHead) result.scratchpadDir = latestTailScratchpad;
604
617
  if (latestTailUserTs || !reachedHead) lastUserTs = latestTailUserTs;
605
618
  if (tailReply) {
606
619
  const reply = replyOf(tailReply, tailRequestTs);
@@ -635,6 +648,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
635
648
  permissionMode: result.permissionMode,
636
649
  cacheTtl: result.cacheTtl,
637
650
  lastReply: result.lastReply,
651
+ scratchpadDir: result.scratchpadDir,
638
652
  lastUserTs
639
653
  });
640
654
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
@@ -1620,7 +1634,7 @@ async function readCompactSummaries(jsonlPath) {
1620
1634
  if (!text) continue;
1621
1635
  // Strip the "This session is being continued..." preamble if present.
1622
1636
  text = text.replace(/^This session is being continued[^\n]*\n+(The summary below[^\n]*\n+)?/i, '').trim();
1623
- if (text) results.push({ timestamp: obj.timestamp, summary: text });
1637
+ if (text) results.push({ timestamp: obj.timestamp, summary: text, source: 'inline' });
1624
1638
  } catch (_) {}
1625
1639
  }
1626
1640
  } catch (_) {}
@@ -1651,7 +1665,7 @@ async function readCompactSummaries(jsonlPath) {
1651
1665
  for (const b of blocks) {
1652
1666
  if (b.type !== 'text' || !b.text) continue;
1653
1667
  const match = b.text.match(/<summary>([\s\S]*?)(?:<\/summary>|$)/);
1654
- if (match) { results.push({ timestamp: lastTs, summary: match[1].trim() }); found = true; break; }
1668
+ if (match) { results.push({ timestamp: lastTs, summary: match[1].trim(), source: 'subagent' }); found = true; break; }
1655
1669
  }
1656
1670
  if (found) break;
1657
1671
  } catch (_) {}
@@ -1661,6 +1675,30 @@ async function readCompactSummaries(jsonlPath) {
1661
1675
  return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1662
1676
  }
1663
1677
 
1678
+ // A subagent summary's timestamp is the compact agent's last record, written just
1679
+ // before the parent's isCompactSummary record; compactions are minutes apart.
1680
+ const COMPACT_SUMMARY_MATCH_MS = 2 * 60 * 1000;
1681
+
1682
+ function fillCompactSummaries(chips, summaries) {
1683
+ const candidates = summaries.filter(s => s.source === 'subagent' && s.timestamp);
1684
+ const used = new Set();
1685
+ for (const chip of chips) {
1686
+ if (chip.compactSummary || !chip.timestamp) continue;
1687
+ const at = Date.parse(chip.timestamp);
1688
+ let best = null;
1689
+ let bestGap = COMPACT_SUMMARY_MATCH_MS;
1690
+ for (const s of candidates) {
1691
+ if (used.has(s)) continue;
1692
+ const gap = Math.abs(Date.parse(s.timestamp) - at);
1693
+ if (gap <= bestGap) { best = s; bestGap = gap; }
1694
+ }
1695
+ if (best) {
1696
+ used.add(best);
1697
+ chip.compactSummary = best.summary;
1698
+ }
1699
+ }
1700
+ }
1701
+
1664
1702
  async function findTerminatedTeammates(jsonlPath) {
1665
1703
  return (await buildSessionDigest(jsonlPath)).terminated;
1666
1704
  }
@@ -2014,6 +2052,7 @@ module.exports = {
2014
2052
  buildAgentProgressMap,
2015
2053
  buildSessionDigest,
2016
2054
  readCompactSummaries,
2055
+ fillCompactSummaries,
2017
2056
  readArtifactLinks,
2018
2057
  readScratchpadCreations,
2019
2058
  findTerminatedTeammates,
package/lib/retention.js CHANGED
@@ -38,11 +38,24 @@ function pruneMap(map, atOf, { known, maxAgeMs, now }) {
38
38
  return removed;
39
39
  }
40
40
 
41
+ const textOrNull = (v) => (typeof v === 'string' && v ? v : null);
42
+
43
+ function dispatchedEntry(e) {
44
+ return {
45
+ parent: textOrNull(e.parent),
46
+ at: e.at,
47
+ name: textOrNull(e.name),
48
+ group: textOrNull(e.group),
49
+ cwd: textOrNull(e.cwd),
50
+ worktree: e.worktree === true ? true : textOrNull(e.worktree),
51
+ };
52
+ }
53
+
41
54
  /**
42
55
  * Sessions started through `dispatch start`, kept after their terminal ends so the card
43
- * keeps its marker.
56
+ * keeps its marker, and across a restart so `dispatch list` finds a session cck resumes.
44
57
  * @param {object} o
45
- * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at}}}` or null
58
+ * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at, name, group, cwd, worktree}}}` or null
46
59
  * @param {(data: object) => void} o.save
47
60
  */
48
61
  function createDispatchedStore({ load, save, now = Date.now }) {
@@ -52,15 +65,15 @@ function createDispatchedStore({ load, save, now = Date.now }) {
52
65
  if (saved && typeof saved === 'object') {
53
66
  for (const [id, e] of Object.entries(saved)) {
54
67
  if (!e || !Number.isFinite(e.at)) continue;
55
- entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, at: e.at });
68
+ entries.set(id, dispatchedEntry(e));
56
69
  }
57
70
  }
58
71
 
59
72
  const persist = () => save({ version: 1, sessions: Object.fromEntries(entries) });
60
73
 
61
- function record(id, parent) {
74
+ function record(id, info = {}) {
62
75
  entries.delete(id);
63
- entries.set(id, { parent: parent || null, at: now() });
76
+ entries.set(id, dispatchedEntry({ ...info, at: now() }));
64
77
  if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
65
78
  persist();
66
79
  }
@@ -72,7 +85,17 @@ function createDispatchedStore({ load, save, now = Date.now }) {
72
85
  return removed;
73
86
  }
74
87
 
75
- return { record, prune, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
88
+ // Derived, not kept: cck resumes every terminal on restart, and a list kept in memory
89
+ // would lose those sessions.
90
+ function running(isRunning, { parent } = {}) {
91
+ const out = [];
92
+ for (const [session, { at, ...m }] of entries) {
93
+ if (isRunning(session) && (!parent || m.parent === parent)) out.push({ session, ...m, startedAt: at });
94
+ }
95
+ return out;
96
+ }
97
+
98
+ return { record, prune, running, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
76
99
  }
77
100
 
78
101
  // Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
@@ -163,12 +186,37 @@ async function pruneContextStatus(dir, { known, maxAgeMs, now = Date.now(), max
163
186
  return removed;
164
187
  }
165
188
 
189
+ // `<dir>/<task list id>.json` maps session id → `{project, updatedAt}`, written by the plugin mod.
190
+ // A file with no sessions left is deleted.
191
+ async function pruneTaskMaps(dir, { known, maxAgeMs, now = Date.now() }) {
192
+ let names;
193
+ try {
194
+ names = await fs.readdir(dir);
195
+ } catch {
196
+ return 0;
197
+ }
198
+ let removed = 0;
199
+ for (const f of names.filter((n) => n.endsWith('.json'))) {
200
+ const file = path.join(dir, f);
201
+ try {
202
+ const map = new Map(Object.entries(JSON.parse(await fs.readFile(file, 'utf8'))));
203
+ const n = pruneMap(map, (e) => Date.parse(e?.updatedAt), { known, maxAgeMs, now });
204
+ if (!n) continue;
205
+ removed += n;
206
+ if (map.size) await fs.writeFile(file, JSON.stringify(Object.fromEntries(map)));
207
+ else await fs.rm(file, { force: true });
208
+ } catch {}
209
+ }
210
+ return removed;
211
+ }
212
+
166
213
  module.exports = {
167
214
  pruneMap,
168
215
  createDispatchedStore,
169
216
  scanTranscripts,
170
217
  pruneSessionDirs,
171
218
  pruneContextStatus,
219
+ pruneTaskMaps,
172
220
  retentionMs,
173
221
  GRACE_MS,
174
222
  MAX_DISPATCHED,
@@ -4,6 +4,15 @@ const path = require('node:path');
4
4
  const { existsSync, opendirSync } = require('node:fs');
5
5
  const { encodeProjectDirName } = require('./claude-dir');
6
6
 
7
+ // Claude Code's rule, from code.claude.com/docs/en/env-vars (CLAUDE_CODE_TMPDIR) and its 2.1.294
8
+ // macOS, Linux and Windows builds. `overrides` are
9
+ // candidate CLAUDE_CODE_TMPDIR values, first wins; a relative one is skipped.
10
+ function scratchpadRoot({ platform, overrides, tmpdir, uid }) {
11
+ const p = platform === 'win32' ? path.win32 : path.posix;
12
+ const base = overrides.find((v) => typeof v === 'string' && p.isAbsolute(v)) || (platform === 'darwin' ? '/tmp' : tmpdir);
13
+ return p.join(base, platform === 'win32' || uid == null ? 'claude' : `claude-${uid}`);
14
+ }
15
+
7
16
  function isEmptyDir(dir) {
8
17
  let handle;
9
18
  try {
@@ -16,11 +25,19 @@ function isEmptyDir(dir) {
16
25
  }
17
26
  }
18
27
 
19
- function createScratchpadDirResolver({ root, resolveWorktree, exists = existsSync, isEmpty = isEmptyDir }) {
28
+ function createScratchpadDirResolver({ root, resolveWorktree, toLong = (p) => p, exists = existsSync, isEmpty = isEmptyDir }) {
20
29
  // A by-project dir that holds an entry always wins, so that answer is kept and never probed again.
21
30
  const settled = new Map();
31
+ // Long form per root, not per session: the recorded dir is <root>/<project>/<id>/scratchpad.
32
+ const longRoots = new Map();
22
33
 
23
34
  return function getScratchpadDir(id, meta) {
35
+ const recorded = meta.scratchpadDir;
36
+ if (recorded) {
37
+ const root = path.dirname(path.dirname(path.dirname(recorded)));
38
+ if (!longRoots.has(root)) longRoots.set(root, toLong(root));
39
+ return longRoots.get(root) + recorded.slice(root.length);
40
+ }
24
41
  if (!meta.jsonlPath) return null;
25
42
  const byProject = path.join(root, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
26
43
  if (settled.get(id) === byProject) return byProject;
@@ -38,4 +55,4 @@ function createScratchpadDirResolver({ root, resolveWorktree, exists = existsSyn
38
55
  };
39
56
  }
40
57
 
41
- module.exports = { createScratchpadDirResolver };
58
+ module.exports = { createScratchpadDirResolver, scratchpadRoot };
@@ -4,7 +4,7 @@ const { exportParentVerdicts, importParentVerdicts, parentVerdictsDirty } = requ
4
4
  const { exportTaskCounts, importTaskCounts, taskCountsDirty } = require('./task-counts');
5
5
 
6
6
  // Bump when an entry's shape or meaning changes: a file with another version is ignored.
7
- const VERSION = 6;
7
+ const VERSION = 7;
8
8
  // A cache this large is not one cck wrote for a normal set of transcripts. Rebuild it cold.
9
9
  const MAX_BYTES = 8 * 1024 * 1024;
10
10