claude-code-kanban 5.4.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli.js +20 -103
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +16 -1
- package/lib/retention.js +5 -17
- package/lib/session-events.js +3 -2
- package/lib/terminal.js +28 -4
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -7
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/public/app.js +249 -66
- package/public/index.html +12 -2
- package/public/project-match.js +6 -1
- package/public/style.css +99 -0
- package/server.js +43 -55
- package/skill-guides/dispatch.md +16 -54
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
|
|
|
71
71
|
- **Answer prompts from the board.** When Claude asks for permission, asks a question or waits for plan approval, the session gets an amber highlight and the ask shows with Allow and Deny buttons or an answer form. The terminal prompt stays open, and the first answer wins. [Answer prompts from the board](https://nikiforovall.blog/claude-code-kanban/guides/waiting-prompts/)
|
|
72
72
|
- **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/)
|
|
73
73
|
- **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.
|
|
74
|
-
- **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.
|
|
74
|
+
- **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/)
|
|
75
75
|
- **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/)
|
|
76
76
|
- **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/)
|
|
77
77
|
|
|
@@ -100,7 +100,7 @@ With no subcommand, `claude-code-kanban` starts the server. Subcommands talk to
|
|
|
100
100
|
- `session list|search|open|view|plan|agents|pin|pins|peek` to read and focus sessions.
|
|
101
101
|
- `task list` and `project list` to read tasks and projects.
|
|
102
102
|
- `preview-doc` and `link-doc` to show or link a file on the board.
|
|
103
|
-
- `dispatch start|
|
|
103
|
+
- `dispatch start|list` to start sessions with a task in the board's terminal.
|
|
104
104
|
|
|
105
105
|
Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.blog/claude-code-kanban/reference/cli/).
|
|
106
106
|
|
package/cli.js
CHANGED
|
@@ -180,58 +180,30 @@ const COMMANDS = {
|
|
|
180
180
|
},
|
|
181
181
|
},
|
|
182
182
|
dispatch: {
|
|
183
|
-
summary: 'Start a Claude Code session for a task in cck
|
|
183
|
+
summary: 'Start a Claude Code session for a task in cck\'s terminal',
|
|
184
184
|
verbs: {
|
|
185
185
|
start: {
|
|
186
|
-
summary: 'Start
|
|
187
|
-
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--
|
|
186
|
+
summary: 'Start claude in cck\'s terminal with a task; prints the session id',
|
|
187
|
+
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--model <m>] [--worktree [name]] [--json] [-- <claude args>...]',
|
|
188
188
|
flags: {
|
|
189
189
|
'--cwd <dir>': 'Folder to run in (a known project, default: current dir)',
|
|
190
|
-
'--spec <text>': 'The task, self-contained',
|
|
190
|
+
'--spec <text>': 'The task, self-contained; sent as the first message',
|
|
191
191
|
'--spec-file <path>': 'Read the task from a file',
|
|
192
|
-
'--name <n>': 'Session name',
|
|
192
|
+
'--name <n>': 'Session name; also its peer name for SendMessage',
|
|
193
193
|
'--group <g>': 'Show it with this session in a kebab-case group (default: this session\'s group)',
|
|
194
|
-
'--report': 'Ask it to report its outcome back to this session',
|
|
195
|
-
'--peer <name>': 'This session\'s peer name; it sends questions and findings there with SendMessage',
|
|
196
194
|
'--model <m>': 'fable, opus, sonnet or haiku',
|
|
197
195
|
'--worktree [name]': 'Run in a new git worktree',
|
|
198
196
|
'--json': 'Output JSON',
|
|
197
|
+
'-- <claude args>': 'Passed to claude as they are, e.g. --permission-mode auto. No quotes, % or control characters; cck sets --session-id, --name, --model and --worktree',
|
|
199
198
|
},
|
|
200
|
-
notes: 'Needs the terminal token, so it runs on the machine of the cck server. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
|
|
199
|
+
notes: 'Needs the terminal token, so it runs on the machine of the cck server. cck sends no report: say in the spec how the session reports back. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
|
|
201
200
|
examples: [
|
|
202
|
-
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --
|
|
201
|
+
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --model sonnet -- --permission-mode auto',
|
|
203
202
|
],
|
|
204
203
|
run: runDispatchStartCli,
|
|
205
204
|
},
|
|
206
|
-
done: {
|
|
207
|
-
summary: 'Report the outcome of a dispatch (run by the started session)',
|
|
208
|
-
usage: 'claude-code-kanban dispatch done <id> --cap <cap> --outcome succeeded|failed (--summary <text> | --summary-file <path>)',
|
|
209
|
-
flags: {
|
|
210
|
-
'<id>': 'Dispatch id from the preamble',
|
|
211
|
-
'--cap <cap>': 'Capability from the preamble',
|
|
212
|
-
'--outcome <o>': 'succeeded or failed',
|
|
213
|
-
'--summary <text>': 'What changed, what was found, what remains',
|
|
214
|
-
'--summary-file <path>': 'Read the summary from a file',
|
|
215
|
-
},
|
|
216
|
-
examples: [
|
|
217
|
-
'claude-code-kanban dispatch done d_1a2b3c --cap <cap> --outcome succeeded --summary-file summary.md',
|
|
218
|
-
],
|
|
219
|
-
run: runDispatchDoneCli,
|
|
220
|
-
},
|
|
221
|
-
wait: {
|
|
222
|
-
summary: 'Wait until a dispatch settles; a timeout is a checkpoint, not a failure',
|
|
223
|
-
usage: 'claude-code-kanban dispatch wait [<id>...] [--timeout <dur>] [--json]',
|
|
224
|
-
flags: {
|
|
225
|
-
'<id>': 'Dispatches to wait on (default: all started by this session)',
|
|
226
|
-
'--timeout <dur>': 'How long to wait, e.g. 90s, 15m, 1h (default: 10m)',
|
|
227
|
-
'--json': 'Output JSON',
|
|
228
|
-
},
|
|
229
|
-
notes: 'Returns as soon as any watched dispatch settles, with settled, running and timeout.',
|
|
230
|
-
examples: ['claude-code-kanban dispatch wait --timeout 15m --json'],
|
|
231
|
-
run: runDispatchWaitCli,
|
|
232
|
-
},
|
|
233
205
|
list: {
|
|
234
|
-
summary: 'List
|
|
206
|
+
summary: 'List sessions this session started that still run in cck\'s terminal',
|
|
235
207
|
usage: 'claude-code-kanban dispatch list [--all] [--json]',
|
|
236
208
|
flags: {
|
|
237
209
|
'--all': 'Every dispatch on this board',
|
|
@@ -1042,19 +1014,10 @@ function textArg(args, name) {
|
|
|
1042
1014
|
return file ? fs.readFileSync(path.resolve(file), 'utf8') : getArgValue(args, name);
|
|
1043
1015
|
}
|
|
1044
1016
|
|
|
1045
|
-
function
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
return Number(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2] || 's']);
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
function printDispatch(r) {
|
|
1053
|
-
const head = `${r.id} ${r.status.padEnd(9)} session=${r.session}${r.name ? ` ${r.name}` : ''}`;
|
|
1054
|
-
console.log(r.summary ? `${head}\n ${r.summary}` : head);
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
async function runDispatchStartCli(args) {
|
|
1017
|
+
async function runDispatchStartCli(argv) {
|
|
1018
|
+
const sep = argv.indexOf('--');
|
|
1019
|
+
const args = sep === -1 ? argv : argv.slice(0, sep);
|
|
1020
|
+
const claudeArgs = sep === -1 ? [] : argv.slice(sep + 1);
|
|
1058
1021
|
const port = cliTargetPort();
|
|
1059
1022
|
if (port === null) {
|
|
1060
1023
|
console.error(unreachable());
|
|
@@ -1085,73 +1048,27 @@ async function runDispatchStartCli(args) {
|
|
|
1085
1048
|
model: getArgValue(args, 'model'),
|
|
1086
1049
|
worktree,
|
|
1087
1050
|
group,
|
|
1088
|
-
|
|
1089
|
-
peer: getArgValue(args, 'peer') || null,
|
|
1051
|
+
claudeArgs,
|
|
1090
1052
|
parent: process.env.CLAUDE_CODE_SESSION_ID || null,
|
|
1091
1053
|
};
|
|
1092
1054
|
try {
|
|
1093
1055
|
const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
|
|
1094
1056
|
if (!out) return 1;
|
|
1095
1057
|
if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
|
|
1096
|
-
else console.log(`Started
|
|
1097
|
-
return 0;
|
|
1098
|
-
} catch (e) { reportCliError(e); return 1; }
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
async function runDispatchDoneCli(args) {
|
|
1102
|
-
const [id] = positionals(args, ['--cap', '--outcome', '--summary', '--summary-file']);
|
|
1103
|
-
let summary;
|
|
1104
|
-
try { summary = textArg(args, 'summary'); } catch (e) { console.error(e.message); return 1; }
|
|
1105
|
-
const body = { cap: getArgValue(args, 'cap'), outcome: getArgValue(args, 'outcome'), summary };
|
|
1106
|
-
if (!id || !body.cap || !body.outcome) {
|
|
1107
|
-
printLeafHelp(COMMANDS.dispatch.verbs.done);
|
|
1108
|
-
return 1;
|
|
1109
|
-
}
|
|
1110
|
-
try {
|
|
1111
|
-
if (!await cliPostJson(`/api/dispatch/${encodeURIComponent(id)}/done`, body, 'Report')) return 1;
|
|
1112
|
-
console.log(`Reported ${id}: ${body.outcome}`);
|
|
1113
|
-
return 0;
|
|
1114
|
-
} catch (e) { reportCliError(e); return 1; }
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
function dispatchQuery(ids, all = false) {
|
|
1118
|
-
const q = new URLSearchParams();
|
|
1119
|
-
if (ids.length) q.set('ids', ids.join(','));
|
|
1120
|
-
else if (!all && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
|
|
1121
|
-
return q;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
async function runDispatchWaitCli(args) {
|
|
1125
|
-
const timeoutRaw = getArgValue(args, 'timeout');
|
|
1126
|
-
const timeoutSec = parseDuration(timeoutRaw, 600);
|
|
1127
|
-
if (timeoutSec === null) return usageError(COMMANDS.dispatch.verbs.wait, `Invalid --timeout value: ${timeoutRaw}`);
|
|
1128
|
-
const q = dispatchQuery(positionals(args, ['--timeout']));
|
|
1129
|
-
const deadline = Date.now() + timeoutSec * 1000;
|
|
1130
|
-
try {
|
|
1131
|
-
let out;
|
|
1132
|
-
do {
|
|
1133
|
-
q.set('wait', String(Math.max(1, Math.min(120, Math.ceil((deadline - Date.now()) / 1000)))));
|
|
1134
|
-
const res = await cliFetch(`/api/dispatch?${q}`);
|
|
1135
|
-
out = await res.json();
|
|
1136
|
-
} while (out.timeout && Date.now() < deadline);
|
|
1137
|
-
if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
|
|
1138
|
-
else {
|
|
1139
|
-
for (const r of out.settled) printDispatch(r);
|
|
1140
|
-
if (out.running.length) console.log(`${out.timeout ? 'Timed out; still running' : 'Still running'}: ${out.running.map(r => r.id).join(' ')}`);
|
|
1141
|
-
if (!out.settled.length && !out.running.length) console.log('No dispatches to wait on.');
|
|
1142
|
-
}
|
|
1058
|
+
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
|
|
1143
1059
|
return 0;
|
|
1144
1060
|
} catch (e) { reportCliError(e); return 1; }
|
|
1145
1061
|
}
|
|
1146
1062
|
|
|
1147
1063
|
async function runDispatchListCli(args) {
|
|
1064
|
+
const q = new URLSearchParams();
|
|
1065
|
+
if (!args.includes('--all') && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
|
|
1148
1066
|
try {
|
|
1149
|
-
const res = await cliFetch(`/api/dispatch?${
|
|
1150
|
-
const
|
|
1151
|
-
const rows = [...running, ...settled].sort((a, b) => b.startedAt - a.startedAt);
|
|
1067
|
+
const res = await cliFetch(`/api/dispatch?${q}`);
|
|
1068
|
+
const rows = (await res.json()).running.sort((a, b) => b.startedAt - a.startedAt);
|
|
1152
1069
|
if (args.includes('--json')) console.log(JSON.stringify(rows, null, 2));
|
|
1153
1070
|
else if (!rows.length) console.log('No dispatches.');
|
|
1154
|
-
else rows.
|
|
1071
|
+
else for (const r of rows) console.log(`${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`);
|
|
1155
1072
|
return 0;
|
|
1156
1073
|
} catch (e) { reportCliError(e); return 1; }
|
|
1157
1074
|
}
|
package/lib/dispatch-groups.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Transient session groups: `dispatch start --group`
|
|
2
|
-
//
|
|
3
|
-
// later dispatches default to the same group. A group lives while any member's claude
|
|
1
|
+
// Transient session groups: `dispatch start --group` puts the started session in a group,
|
|
2
|
+
// and only an explicit `--group` does. A group lives while any member's claude
|
|
4
3
|
// runs, and for a grace period after; once none runs, only pinned members stay in it.
|
|
5
4
|
// The map is on disk so a pinned group outlives a server restart.
|
|
6
5
|
|
|
@@ -25,39 +24,28 @@ function suggestGroupName(s) {
|
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
26
|
* @param {object} o
|
|
28
|
-
* @param {() => object|null} o.load returns `{sessions: {[id]: group}
|
|
27
|
+
* @param {() => object|null} o.load returns `{sessions: {[id]: group}}` or null
|
|
29
28
|
* @param {(data: object) => void} o.save
|
|
30
29
|
* @param {(id: string) => boolean} o.isAlive
|
|
31
30
|
* @param {() => Set<string>} o.pinnedIds
|
|
32
31
|
*/
|
|
33
32
|
function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
|
|
34
33
|
const members = new Map();
|
|
35
|
-
const starterOf = new Map();
|
|
36
34
|
const liveAt = new Map();
|
|
37
35
|
const saved = load();
|
|
38
36
|
if (saved?.sessions && typeof saved.sessions === 'object') {
|
|
39
37
|
for (const [id, g] of Object.entries(saved.sessions)) if (isGroupName(g)) members.set(id, g);
|
|
40
38
|
}
|
|
41
|
-
if (saved?.starters && typeof saved.starters === 'object') {
|
|
42
|
-
for (const [id, s] of Object.entries(saved.starters)) if (members.has(id) && typeof s === 'string') starterOf.set(id, s);
|
|
43
|
-
}
|
|
44
39
|
for (const g of members.values()) liveAt.set(g, now());
|
|
45
40
|
|
|
46
|
-
const persist = () =>
|
|
47
|
-
save({ version: 1, sessions: Object.fromEntries(members), starters: Object.fromEntries(starterOf) });
|
|
41
|
+
const persist = () => save({ version: 1, sessions: Object.fromEntries(members) });
|
|
48
42
|
|
|
49
|
-
function join(group, ids
|
|
43
|
+
function join(group, ids) {
|
|
50
44
|
let changed = false;
|
|
51
45
|
for (const id of ids) {
|
|
52
|
-
if (!id) continue;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
changed = true;
|
|
56
|
-
}
|
|
57
|
-
if (starter && starterOf.get(id) !== starter) {
|
|
58
|
-
starterOf.set(id, starter);
|
|
59
|
-
changed = true;
|
|
60
|
-
}
|
|
46
|
+
if (!id || members.get(id) === group) continue;
|
|
47
|
+
members.set(id, group);
|
|
48
|
+
changed = true;
|
|
61
49
|
}
|
|
62
50
|
liveAt.set(group, now());
|
|
63
51
|
if (changed) persist();
|
|
@@ -82,7 +70,6 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
|
|
|
82
70
|
for (const id of ids) {
|
|
83
71
|
if (pinned.has(id)) continue;
|
|
84
72
|
members.delete(id);
|
|
85
|
-
starterOf.delete(id);
|
|
86
73
|
changed = true;
|
|
87
74
|
}
|
|
88
75
|
if (!ids.some((id) => pinned.has(id))) liveAt.delete(g);
|
|
@@ -90,21 +77,12 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
|
|
|
90
77
|
if (changed) persist();
|
|
91
78
|
}
|
|
92
79
|
|
|
93
|
-
// A starter is not a member; it defaults to the group of a session it started.
|
|
94
|
-
function groupOf(id) {
|
|
95
|
-
refresh();
|
|
96
|
-
if (members.has(id)) return members.get(id);
|
|
97
|
-
let group = null;
|
|
98
|
-
for (const [child, starter] of starterOf) if (starter === id) group = members.get(child);
|
|
99
|
-
return group;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
80
|
function snapshot() {
|
|
103
81
|
refresh();
|
|
104
82
|
return members;
|
|
105
83
|
}
|
|
106
84
|
|
|
107
|
-
return { join,
|
|
85
|
+
return { join, snapshot };
|
|
108
86
|
}
|
|
109
87
|
|
|
110
88
|
module.exports = { createGroupStore, isGroupName, suggestGroupName, GRACE_MS };
|
package/lib/dispatch.js
CHANGED
|
@@ -1,179 +1,32 @@
|
|
|
1
|
-
// Dispatch:
|
|
2
|
-
//
|
|
3
|
-
// restart drops could still settle.
|
|
4
|
-
//
|
|
5
|
-
// The child holds a per-dispatch capability, never the terminal token, so it can
|
|
6
|
-
// report its own outcome and nothing else. A stale or duplicate child is refused
|
|
7
|
-
// because a record settles once.
|
|
1
|
+
// Dispatch: a Claude Code session one session started in cck's terminal. In memory on
|
|
2
|
+
// purpose: an entry lives as long as its PTY, which dies with the server.
|
|
8
3
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const { clampWait, EVENT_PREFIX } = require('./session-events');
|
|
4
|
+
function createDispatchRegistry({ now = Date.now } = {}) {
|
|
5
|
+
const entries = new Map();
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const KEEP_SETTLED_MS = 24 * 60 * 60 * 1000;
|
|
17
|
-
const ID_RE = /^d_[0-9a-f]{12}$/;
|
|
18
|
-
// The peer name lands verbatim in the started session's prompt.
|
|
19
|
-
const PEER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
20
|
-
|
|
21
|
-
const isPeerName = (v) => typeof v === 'string' && PEER_RE.test(v);
|
|
22
|
-
|
|
23
|
-
// With neither --report nor --peer the started session gets the task alone, like one the user typed.
|
|
24
|
-
function formatPreamble(record, cli = 'claude-code-kanban') {
|
|
25
|
-
if (!record.report && !record.peer) return record.spec;
|
|
26
|
-
const lines = [`[cck dispatch ${record.id}] Another Claude Code session started you through claude-code-kanban to do the task below.`];
|
|
27
|
-
if (record.peer) {
|
|
28
|
-
lines.push(
|
|
29
|
-
`That session is the peer "${record.peer}". Ask it, not the user: when you need a decision, or find something that changes the task, send a short message with the SendMessage tool to "${record.peer}".`,
|
|
30
|
-
'Keep working on what does not depend on the answer. The reply arrives as a new message.',
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
if (record.report) {
|
|
34
|
-
lines.push(
|
|
35
|
-
'When the task is done, or you cannot finish it, report exactly once with this command, then stop:',
|
|
36
|
-
`${cli} dispatch done ${record.id} --cap ${record.cap} --outcome succeeded --summary "<what changed, what you found, what remains>"`,
|
|
37
|
-
record.peer
|
|
38
|
-
? 'Use --outcome failed when the task cannot be finished. If the command refuses because the dispatch already ended, send the summary to the peer instead.'
|
|
39
|
-
: 'Use --outcome failed when the task is not done. Do not ask the user questions the other session must answer; report failed with the question instead.',
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
lines.push('', 'Task:', record.spec);
|
|
43
|
-
return lines.join('\n');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// The line reaches the parent through the postman at hook trust level; the summary is
|
|
47
|
-
// last so no summary text can pose as a further field.
|
|
48
|
-
const DISPATCH_OUTCOME = {
|
|
49
|
-
succeeded: 'reported success',
|
|
50
|
-
failed: 'reported failure',
|
|
51
|
-
exited: 'ended without a report',
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
function formatDispatchLine(r) {
|
|
55
|
-
const outcome = DISPATCH_OUTCOME[r.status] || r.status;
|
|
56
|
-
const head = `${EVENT_PREFIX} Dispatch ${r.id} (session ${r.session}) ${outcome}.`;
|
|
57
|
-
return r.summary ? `${head} Summary: ${r.summary}` : head;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function publicView(r) {
|
|
61
|
-
const { cap: _cap, ...rest } = r;
|
|
62
|
-
return rest;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
|
|
66
|
-
const records = new Map();
|
|
67
|
-
const waiters = new Set();
|
|
68
|
-
|
|
69
|
-
function prune() {
|
|
70
|
-
const cutoff = now() - KEEP_SETTLED_MS;
|
|
71
|
-
for (const [id, r] of records) if (r.settledAt && r.settledAt < cutoff) records.delete(id);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function create({ parent, spec, name, report, peer, group, worktree }) {
|
|
75
|
-
prune();
|
|
76
|
-
const r = {
|
|
77
|
-
id: `d_${crypto.randomBytes(6).toString('hex')}`,
|
|
78
|
-
cap: crypto.randomBytes(16).toString('hex'),
|
|
7
|
+
function add({ session, parent, cwd, name, group, worktree }) {
|
|
8
|
+
entries.set(session, {
|
|
9
|
+
session,
|
|
79
10
|
parent: parent || null,
|
|
80
|
-
|
|
81
|
-
cwd: null,
|
|
11
|
+
cwd,
|
|
82
12
|
name: name || null,
|
|
83
|
-
report: !!report,
|
|
84
|
-
peer: peer || null,
|
|
85
13
|
group: group || null,
|
|
86
14
|
worktree: worktree || null,
|
|
87
|
-
spec,
|
|
88
|
-
status: 'running',
|
|
89
|
-
summary: null,
|
|
90
15
|
startedAt: now(),
|
|
91
|
-
|
|
92
|
-
};
|
|
93
|
-
records.set(r.id, r);
|
|
94
|
-
return r;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// The preamble carries the id, so the record exists before its session does.
|
|
98
|
-
function attach(id, { session, cwd }) {
|
|
99
|
-
const r = records.get(id);
|
|
100
|
-
if (r) Object.assign(r, { session, cwd });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function discard(id) {
|
|
104
|
-
records.delete(id);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function finish(r, status, summary) {
|
|
108
|
-
r.status = status;
|
|
109
|
-
r.summary = summary;
|
|
110
|
-
r.settledAt = now();
|
|
111
|
-
onSettle?.(r);
|
|
112
|
-
for (const wake of [...waiters]) wake();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Returns null when settled, or {status, error} naming the refusal.
|
|
116
|
-
function settle(id, cap, outcome, summary) {
|
|
117
|
-
const r = typeof id === 'string' && ID_RE.test(id) ? records.get(id) : null;
|
|
118
|
-
if (!r) return { status: 404, error: 'no such dispatch' };
|
|
119
|
-
if (!tokenMatches(r.cap, cap)) return { status: 403, error: 'wrong capability' };
|
|
120
|
-
if (r.status !== 'running') return { status: 409, error: `already ${r.status}` };
|
|
121
|
-
if (!OUTCOMES.has(outcome)) return { status: 400, error: 'outcome must be succeeded or failed' };
|
|
122
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
123
|
-
const text = typeof summary === 'string' ? summary.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, MAX_SUMMARY) : '';
|
|
124
|
-
finish(r, outcome, text || null);
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// The PTY ended with no report: the parent must not wait on it forever.
|
|
129
|
-
function sessionExited(sessionId) {
|
|
130
|
-
for (const r of records.values()) if (r.session === sessionId && r.status === 'running') finish(r, 'exited', null);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function select({ ids, parent } = {}) {
|
|
134
|
-
prune();
|
|
135
|
-
let out = [...records.values()];
|
|
136
|
-
if (ids?.length) out = out.filter((r) => ids.includes(r.id));
|
|
137
|
-
else if (parent) out = out.filter((r) => r.parent === parent);
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function list(filter) {
|
|
142
|
-
return select(filter).map(publicView);
|
|
16
|
+
});
|
|
143
17
|
}
|
|
144
18
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
function wait(filter, waitSec, onClose) {
|
|
149
|
-
const sec = clampWait(waitSec);
|
|
150
|
-
const snapshot = () => {
|
|
151
|
-
const rows = select(filter);
|
|
152
|
-
return {
|
|
153
|
-
settled: rows.filter((r) => r.status !== 'running').map(publicView),
|
|
154
|
-
running: rows.filter((r) => r.status === 'running').map(publicView),
|
|
155
|
-
};
|
|
156
|
-
};
|
|
157
|
-
const first = snapshot();
|
|
158
|
-
if (first.settled.length || !first.running.length || !sec) return Promise.resolve({ ...first, timeout: false });
|
|
159
|
-
return new Promise((resolve) => {
|
|
160
|
-
let unsubscribe;
|
|
161
|
-
const done = (timeout) => {
|
|
162
|
-
if (!waiters.delete(wake)) return;
|
|
163
|
-
clearTimeout(timer);
|
|
164
|
-
unsubscribe?.();
|
|
165
|
-
resolve({ ...snapshot(), timeout });
|
|
166
|
-
};
|
|
167
|
-
const wake = () => {
|
|
168
|
-
if (select(filter).some((r) => r.status !== 'running')) done(false);
|
|
169
|
-
};
|
|
170
|
-
const timer = setTimeout(() => done(true), sec * 1000);
|
|
171
|
-
waiters.add(wake);
|
|
172
|
-
unsubscribe = onClose?.(() => done(true));
|
|
173
|
-
});
|
|
19
|
+
function list({ parent } = {}) {
|
|
20
|
+
const out = [...entries.values()];
|
|
21
|
+
return parent ? out.filter((e) => e.parent === parent) : out;
|
|
174
22
|
}
|
|
175
23
|
|
|
176
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
add,
|
|
26
|
+
list,
|
|
27
|
+
has: (session) => entries.has(session),
|
|
28
|
+
remove: (session) => entries.delete(session),
|
|
29
|
+
};
|
|
177
30
|
}
|
|
178
31
|
|
|
179
|
-
module.exports = { createDispatchRegistry
|
|
32
|
+
module.exports = { createDispatchRegistry };
|
package/lib/git-branch.js
CHANGED
|
@@ -44,4 +44,19 @@ function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Reads HEAD only where the transcript's `gitBranch` is wrong: cwd moved away from the launch
|
|
49
|
+
* project, or the project is a worktree (a `claude -w` session records the main checkout's
|
|
50
|
+
* branch on every line).
|
|
51
|
+
* @param {{cwd?: string, project?: string, gitBranch?: string}} meta
|
|
52
|
+
* @param {boolean} isWorktree whether `meta.project` is a linked worktree
|
|
53
|
+
* @param {(cwd: string) => string|null} branchAt
|
|
54
|
+
*/
|
|
55
|
+
function sessionGitBranch(meta, isWorktree, branchAt) {
|
|
56
|
+
if (meta.cwd && meta.project && (meta.cwd !== meta.project || isWorktree)) {
|
|
57
|
+
return branchAt(meta.cwd) || meta.gitBranch || null;
|
|
58
|
+
}
|
|
59
|
+
return meta.gitBranch || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { readGitBranch, sessionGitBranch };
|
package/lib/retention.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
const fs = require('node:fs/promises');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { readSettings } = require('./claude-settings');
|
|
6
|
-
const { DISPATCH_OUTCOME } = require('./dispatch');
|
|
7
6
|
|
|
8
7
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
8
|
const DEFAULT_CLEANUP_DAYS = 30;
|
|
@@ -11,7 +10,6 @@ const DEFAULT_CLEANUP_DAYS = 30;
|
|
|
11
10
|
// read that gap as a deleted transcript.
|
|
12
11
|
const GRACE_MS = 60 * 60 * 1000;
|
|
13
12
|
const MAX_DISPATCHED = 500;
|
|
14
|
-
const OUTCOMES = new Set(Object.keys(DISPATCH_OUTCOME));
|
|
15
13
|
|
|
16
14
|
// Only the config dir's settings: retention is per config dir, not per project.
|
|
17
15
|
function retentionMs(claudeDir) {
|
|
@@ -28,10 +26,10 @@ function isExpired(id, at, { known, maxAgeMs, now }) {
|
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
/**
|
|
31
|
-
* Sessions started through `dispatch start`, kept after
|
|
29
|
+
* Sessions started through `dispatch start`, kept after their terminal ends so the card
|
|
32
30
|
* keeps its marker.
|
|
33
31
|
* @param {object} o
|
|
34
|
-
* @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent,
|
|
32
|
+
* @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at}}}` or null
|
|
35
33
|
* @param {(data: object) => void} o.save
|
|
36
34
|
*/
|
|
37
35
|
function createDispatchedStore({ load, save, now = Date.now }) {
|
|
@@ -41,10 +39,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
41
39
|
if (saved && typeof saved === 'object') {
|
|
42
40
|
for (const [id, e] of Object.entries(saved)) {
|
|
43
41
|
if (!e || !Number.isFinite(e.at)) continue;
|
|
44
|
-
|
|
45
|
-
// stopped can never settle, so it reads as ended.
|
|
46
|
-
const status = OUTCOMES.has(e.status) ? e.status : 'exited';
|
|
47
|
-
entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, status, at: e.at });
|
|
42
|
+
entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, at: e.at });
|
|
48
43
|
}
|
|
49
44
|
}
|
|
50
45
|
|
|
@@ -52,18 +47,11 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
52
47
|
|
|
53
48
|
function record(id, parent) {
|
|
54
49
|
entries.delete(id);
|
|
55
|
-
entries.set(id, { parent: parent || null,
|
|
50
|
+
entries.set(id, { parent: parent || null, at: now() });
|
|
56
51
|
if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
|
|
57
52
|
persist();
|
|
58
53
|
}
|
|
59
54
|
|
|
60
|
-
function settle(id, status) {
|
|
61
|
-
const e = entries.get(id);
|
|
62
|
-
if (!e || e.status !== 'running' || !OUTCOMES.has(status)) return;
|
|
63
|
-
e.status = status;
|
|
64
|
-
persist();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
55
|
// Returns how many entries went; writes only when one did.
|
|
68
56
|
function prune({ known, maxAgeMs }) {
|
|
69
57
|
const t = now();
|
|
@@ -78,7 +66,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
78
66
|
return removed;
|
|
79
67
|
}
|
|
80
68
|
|
|
81
|
-
return { record,
|
|
69
|
+
return { record, prune, get: (id) => entries.get(id) || null };
|
|
82
70
|
}
|
|
83
71
|
|
|
84
72
|
// Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
|
package/lib/session-events.js
CHANGED
|
@@ -79,8 +79,9 @@ function clampWait(sec) {
|
|
|
79
79
|
return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Each topic rides its own bucket, so
|
|
83
|
-
//
|
|
82
|
+
// Each topic rides its own bucket, so a postman on a topic never prints task moves the
|
|
83
|
+
// user did not grant with the follow skill. No topic is the task-move bucket. Plugins
|
|
84
|
+
// before 2.22.0 run a `--topic dispatch` postman, which must keep getting nothing.
|
|
84
85
|
function topicKey(topic, sessionId) {
|
|
85
86
|
return topic ? `${topic}:${sessionId}` : sessionId;
|
|
86
87
|
}
|
package/lib/terminal.js
CHANGED
|
@@ -26,6 +26,17 @@ const PICK_START_SLACK_MS = 2000;
|
|
|
26
26
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
|
|
27
27
|
const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
28
28
|
const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
|
|
29
|
+
// Extra claude args reach the command line inside plain quotes (quoteArg), so a character
|
|
30
|
+
// that ends or expands those quotes in any of the shells is refused.
|
|
31
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refuses control characters on purpose
|
|
32
|
+
const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f'"%]*$/;
|
|
33
|
+
const MAX_EXTRA_ARGS = 64;
|
|
34
|
+
const MAX_EXTRA_ARG = 4096;
|
|
35
|
+
// cck sets these itself, or they would start something other than a new session.
|
|
36
|
+
const OWNED_FLAGS = new Set([
|
|
37
|
+
'--session-id', '-n', '--name', '--model', '-w', '--worktree',
|
|
38
|
+
'-r', '--resume', '-c', '--continue', '--fork-session', '-p', '--print',
|
|
39
|
+
]);
|
|
29
40
|
const MAX_PROMPT = 32 * 1024;
|
|
30
41
|
const PROMPT_QUIET_MS = 400;
|
|
31
42
|
// Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
|
|
@@ -118,8 +129,8 @@ function resolveShell(value, which, platform = process.platform, env = process.e
|
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
// claudeArgs is null for a plain shell. Its items are fixed flags, a validated UUID, or
|
|
121
|
-
// values that passed NAME_RE / WORKTREE_RE, so plain quoting is enough to
|
|
122
|
-
// from splitting an argument and nothing can break out of the quotes.
|
|
132
|
+
// values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE, so plain quoting is enough to
|
|
133
|
+
// keep a space from splitting an argument and nothing can break out of the quotes.
|
|
123
134
|
function quoteArg(arg, family) {
|
|
124
135
|
if (/^[A-Za-z0-9._-]+$/.test(arg)) return arg;
|
|
125
136
|
return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
|
|
@@ -142,7 +153,7 @@ function claudeArgsFor(mode, id, spec) {
|
|
|
142
153
|
if (mode === 'fork') return ['--resume', id, '--fork-session'];
|
|
143
154
|
if (mode === 'pick') return ['--resume'];
|
|
144
155
|
if (mode === 'new') {
|
|
145
|
-
const args = ['--session-id', id];
|
|
156
|
+
const args = ['--session-id', id, ...(spec.extraArgs || [])];
|
|
146
157
|
if (spec.name) args.push('--name', spec.name);
|
|
147
158
|
if (spec.model) args.push('--model', spec.model);
|
|
148
159
|
// Last, because its value is optional and a following flag must not be taken for it.
|
|
@@ -153,6 +164,16 @@ function claudeArgsFor(mode, id, spec) {
|
|
|
153
164
|
return ['--resume', id];
|
|
154
165
|
}
|
|
155
166
|
|
|
167
|
+
function extraArgsError(args) {
|
|
168
|
+
if (!Array.isArray(args) || args.length > MAX_EXTRA_ARGS) return `claude args (at most ${MAX_EXTRA_ARGS})`;
|
|
169
|
+
for (const a of args) {
|
|
170
|
+
if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no quotes, % or control characters';
|
|
171
|
+
const flag = a.split('=')[0];
|
|
172
|
+
if (OWNED_FLAGS.has(flag)) return `claude arg: cck sets ${flag}`;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
156
177
|
// Returns the new-session options from a hello, or a string naming the bad field.
|
|
157
178
|
function parseNewSpec(msg) {
|
|
158
179
|
if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
|
|
@@ -165,7 +186,10 @@ function parseNewSpec(msg) {
|
|
|
165
186
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
|
|
166
187
|
const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
|
|
167
188
|
if (prompt.length > MAX_PROMPT) return 'prompt';
|
|
168
|
-
|
|
189
|
+
const extraArgs = msg.extraArgs ?? [];
|
|
190
|
+
const bad = extraArgsError(extraArgs);
|
|
191
|
+
if (bad) return bad;
|
|
192
|
+
return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null, extraArgs };
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
function samePath(a, b) {
|