claude-code-kanban 7.3.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 +5 -3
- package/cli.js +95 -29
- package/lib/panes.js +26 -5
- package/lib/parsers.js +15 -1
- package/lib/retention.js +25 -0
- package/lib/scratchpad-dir.js +19 -2
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +33 -18
- package/lib/task-dir.js +26 -0
- package/lib/terminal.js +21 -22
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +80 -0
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -3
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +74 -0
- package/public/app.js +1180 -1099
- package/public/index.html +23 -38
- package/public/style.css +199 -37
- package/server.js +171 -159
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
- 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>`</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.**
|
|
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
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
console.log(
|
|
351
|
-
console.log('
|
|
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
|
|
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
|
|
624
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1141
|
+
async function fetchDispatches(all) {
|
|
1111
1142
|
const q = new URLSearchParams();
|
|
1112
|
-
if (!
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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,
|
package/lib/scratchpad-dir.js
CHANGED
|
@@ -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 };
|
package/lib/session-cache.js
CHANGED
|
@@ -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 =
|
|
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
|
|
package/lib/session-events.js
CHANGED
|
@@ -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
|
|
9
|
-
// (plugin/plugins/claude-code-kanban/
|
|
10
|
-
//
|
|
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
|
|
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
|
-
//
|
|
32
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
82
|
-
//
|
|
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
|
|
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
|
|
99
|
-
//
|
|
100
|
-
// than no instruction, so
|
|
101
|
-
//
|
|
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,
|
package/lib/task-dir.js
ADDED
|
@@ -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 };
|