claude-code-kanban 7.4.0 → 7.5.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
@@ -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/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) {
@@ -120,6 +126,9 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
120
126
  // The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
121
127
  function add(sessionId, { kind, target, title }) {
122
128
  if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
129
+ if (kind === 'message' && target.length > MAX_MESSAGE_TARGET) {
130
+ throw httpError(400, `A message target holds at most ${MAX_MESSAGE_TARGET} characters`, 'bad_target');
131
+ }
123
132
  reload();
124
133
  const layout = bySession.get(sessionId) || emptyLayout();
125
134
  const key = targetKey(kind, target);
@@ -130,7 +139,7 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
130
139
  id: `p${layout.nextId++}`,
131
140
  kind,
132
141
  target,
133
- title: oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE),
142
+ title: paneTitle(kind, target, title),
134
143
  addedAt: now(),
135
144
  };
136
145
  layout.panes.push(pane);
@@ -148,6 +157,18 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
148
157
  return write(sessionId, layout);
149
158
  }
150
159
 
160
+ // An empty title gives the pane its default title back. Returns null when the session has no such pane.
161
+ function rename(sessionId, paneId, title) {
162
+ reload();
163
+ const layout = bySession.get(sessionId);
164
+ const pane = layout?.panes.find((p) => p.id === paneId);
165
+ if (!pane) return null;
166
+ const next = paneTitle(pane.kind, pane.target, title);
167
+ if (next === pane.title) return get(sessionId);
168
+ pane.title = next;
169
+ return write(sessionId, layout);
170
+ }
171
+
151
172
  // `order` is every pane id of the session, in the new tab order.
152
173
  function reorder(sessionId, order) {
153
174
  reload();
@@ -171,12 +192,12 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
171
192
  function fileTargets() {
172
193
  const out = new Set();
173
194
  for (const layout of bySession.values()) {
174
- for (const p of layout.panes) if (p.kind !== 'url') out.add(p.target);
195
+ for (const p of layout.panes) if (isFileKind(p.kind)) out.add(p.target);
175
196
  }
176
197
  return out;
177
198
  }
178
199
 
179
- return { get, add, remove, reorder, prune, reload, fileTargets };
200
+ return { get, add, remove, rename, reorder, prune, reload, fileTargets };
180
201
  }
181
202
 
182
203
  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) {
package/lib/retention.js CHANGED
@@ -163,12 +163,37 @@ async function pruneContextStatus(dir, { known, maxAgeMs, now = Date.now(), max
163
163
  return removed;
164
164
  }
165
165
 
166
+ // `<dir>/<task list id>.json` maps session id → `{project, updatedAt}`, written by the plugin mod.
167
+ // A file with no sessions left is deleted.
168
+ async function pruneTaskMaps(dir, { known, maxAgeMs, now = Date.now() }) {
169
+ let names;
170
+ try {
171
+ names = await fs.readdir(dir);
172
+ } catch {
173
+ return 0;
174
+ }
175
+ let removed = 0;
176
+ for (const f of names.filter((n) => n.endsWith('.json'))) {
177
+ const file = path.join(dir, f);
178
+ try {
179
+ const map = new Map(Object.entries(JSON.parse(await fs.readFile(file, 'utf8'))));
180
+ const n = pruneMap(map, (e) => Date.parse(e?.updatedAt), { known, maxAgeMs, now });
181
+ if (!n) continue;
182
+ removed += n;
183
+ if (map.size) await fs.writeFile(file, JSON.stringify(Object.fromEntries(map)));
184
+ else await fs.rm(file, { force: true });
185
+ } catch {}
186
+ }
187
+ return removed;
188
+ }
189
+
166
190
  module.exports = {
167
191
  pruneMap,
168
192
  createDispatchedStore,
169
193
  scanTranscripts,
170
194
  pruneSessionDirs,
171
195
  pruneContextStatus,
196
+ pruneTaskMaps,
172
197
  retentionMs,
173
198
  GRACE_MS,
174
199
  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
 
@@ -5,13 +5,13 @@
5
5
 
6
6
  const { oneLine } = require('./one-line');
7
7
 
8
- // Tells a live session that the board moved one of its tasks. The postman monitor
9
- // (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
10
- // each line, which Claude Code delivers into that session as a task notification.
8
+ // Tells a live session that the board moved one of its tasks. The plugin's doorbell mod
9
+ // (plugin/plugins/claude-code-kanban/hooks/doorbell.ts) drains this queue and submits the
10
+ // lines to that session as a prompt.
11
11
  //
12
12
  // Deliberately in-memory and lossy. The task file is the durable command -- a dropped
13
13
  // event only means the agent notices on its next turn instead of immediately -- so a
14
- // disk queue would buy nothing. Reading consumes, so a restarted postman never replays
14
+ // disk queue would buy nothing. Reading consumes, so a restarted session never replays
15
15
  // a backlog and acts on the same move twice.
16
16
  //
17
17
  // A bucket exists only while it holds something: an undelivered line or a waiting
@@ -28,20 +28,36 @@ const sessionEventBuckets = new Map();
28
28
  // last: truncation eats its tail first and leaves the machine-readable head intact.
29
29
  const sanitizeEventLine = (line) => oneLine(line, 1500);
30
30
 
31
- // Lines are plain sentences so they still read right once the skill that explains them has
32
- // left the agent's context. EVENT_PREFIX marks the board as the sender.
31
+ // No skill explains the lines, so each one says what the user means by it. EVENT_PREFIX
32
+ // marks the board as the sender.
33
33
  const EVENT_PREFIX = '[kanban board]';
34
34
 
35
+ function moveMeaning(from, to) {
36
+ if (to === 'in_progress') {
37
+ return 'Start this task now, and set it to completed with TaskUpdate when the work is done.';
38
+ }
39
+ if (from === 'in_progress' && to === 'pending') return 'Stop working on it and park it.';
40
+ if (to === 'completed') return 'The user considers it done: do not keep working on it.';
41
+ if (to === 'cancelled') return 'Abandon it. Undo nothing unless asked.';
42
+ return '';
43
+ }
44
+
35
45
  // Everything after `Description:` is the description verbatim to end of line, so no amount
36
46
  // of board text can pose as a further part of the line. That leaves the subject as the only
37
47
  // value that needs delimiting.
38
48
  function formatTaskMoved(taskId, prevStatus, task) {
39
49
  const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
40
50
  const from = prevStatus ? ` from ${prevStatus}` : '';
41
- const head = `${EVENT_PREFIX} The user moved task ${taskId} "${subject}"${from} to ${task.status}.`;
51
+ const meaning = moveMeaning(prevStatus, task.status);
52
+ const head = `${EVENT_PREFIX} The user moved task ${taskId} "${subject}"${from} to ${task.status}.${meaning ? ` ${meaning}` : ''}`;
42
53
  return task.description ? `${head} Description: ${task.description}` : head;
43
54
  }
44
55
 
56
+ // The `boardEvents` section of <CCK_DIR>/config.json. On unless it says enabled: false.
57
+ function boardEventsOn(cfg) {
58
+ return cfg?.boardEvents?.enabled !== false;
59
+ }
60
+
45
61
  // The comments themselves live in the review file; the line only points at it, because a
46
62
  // batch of quotes would not survive the one-line cap. The path comes last and runs to end
47
63
  // of line, so a space in the config dir cannot split it.
@@ -50,7 +66,7 @@ function formatReviewSubmitted(count, label, reviewFile) {
50
66
  return `${EVENT_PREFIX} The user left ${comments} on ${label}. Address them: ${reviewFile}`;
51
67
  }
52
68
 
53
- // A postman between two polls has no waiter for a moment, so this can miss a live one.
69
+ // A doorbell between two polls has no waiter for a moment, so this can miss a live one.
54
70
  // The caller then falls back to another route, which only costs a duplicate route, never
55
71
  // a lost review.
56
72
  function hasSessionListener(sessionId) {
@@ -66,7 +82,7 @@ function enqueueSessionEvent(sessionId, line) {
66
82
  sessionEventBuckets.set(sessionId, bucket);
67
83
  }
68
84
  bucket.queue.push(text);
69
- // A session with no postman attached must not grow without bound.
85
+ // A session with no doorbell attached must not grow without bound.
70
86
  if (bucket.queue.length > 50) bucket.queue.splice(0, bucket.queue.length - 50);
71
87
  for (const wake of [...bucket.waiters]) wake();
72
88
  }
@@ -78,14 +94,13 @@ function clampWait(sec) {
78
94
  return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
79
95
  }
80
96
 
81
- // Each topic rides its own bucket, so a postman on a topic never prints task moves the
82
- // user did not grant with the follow skill. No topic is the task-move bucket. Plugins
83
- // before 2.22.0 run a `--topic dispatch` postman, which must keep getting nothing.
97
+ // Each topic rides its own bucket. No topic is the task-move bucket. Plugins before 2.22.0
98
+ // run a `--topic dispatch` monitor, which must keep getting nothing.
84
99
  function topicKey(topic, sessionId) {
85
100
  return topic ? `${topic}:${sessionId}` : sessionId;
86
101
  }
87
102
 
88
- // Long-poll drained by the postman monitor. Routing stays in server.js; this is the handler.
103
+ // Long-poll drained by the doorbell mod. Routing stays in server.js; this is the handler.
89
104
  function handleSessionEvents(req, res) {
90
105
  const topic = req.query.topic;
91
106
  if (topic !== undefined && !(typeof topic === 'string' && TOPIC_RE.test(topic))) {
@@ -95,11 +110,10 @@ function handleSessionEvents(req, res) {
95
110
  const bucket = sessionEventBuckets.get(sessionId);
96
111
  const wait = clampWait(req.query.wait);
97
112
 
98
- // A postman is armed by a skill invocation, so it can attach long after the board moved
99
- // something. Those lines are read as instructions, and an hours-old instruction is worse
100
- // than no instruction, so the grant starts the session's history rather than inheriting
101
- // it: `first=1` drops the whole backlog. drain() (not a bare truncate) so an emptied
102
- // bucket is still evicted from the map.
113
+ // A doorbell can attach long after the board moved something (a resumed session, a board
114
+ // that came up later). Those lines are read as instructions, and an hours-old instruction
115
+ // is worse than no instruction, so `first=1` drops the whole backlog. drain() (not a bare
116
+ // truncate) so an emptied bucket is still evicted from the map.
103
117
  if (bucket && req.query.first === '1') drain(sessionId, bucket);
104
118
 
105
119
  if (bucket?.queue.length) return res.json({ events: drain(sessionId, bucket) });
@@ -133,6 +147,7 @@ module.exports = {
133
147
  sessionEventBuckets,
134
148
  sanitizeEventLine,
135
149
  formatTaskMoved,
150
+ boardEventsOn,
136
151
  formatReviewSubmitted,
137
152
  hasSessionListener,
138
153
  enqueueSessionEvent,
@@ -0,0 +1,26 @@
1
+ const fs = require('node:fs').promises;
2
+ const path = require('node:path');
3
+
4
+ async function readTaskDir(dir) {
5
+ let files;
6
+ try {
7
+ files = (await fs.readdir(dir)).filter((f) => f.endsWith('.json'));
8
+ } catch (e) {
9
+ if (e.code === 'ENOENT') return [];
10
+ throw e;
11
+ }
12
+ const entries = await Promise.all(
13
+ files.map(async (file) => {
14
+ try {
15
+ const task = JSON.parse(await fs.readFile(path.join(dir, file), 'utf8'));
16
+ return task && typeof task === 'object' ? { file, task } : null;
17
+ } catch (e) {
18
+ console.error(`Error parsing ${path.join(dir, file)}:`, e.message);
19
+ return null;
20
+ }
21
+ }),
22
+ );
23
+ return entries.filter(Boolean);
24
+ }
25
+
26
+ module.exports = { readTaskDir };