claude-code-kanban 7.0.3 → 7.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +10 -1
- package/lib/parsers.js +30 -20
- package/lib/retention.js +1 -1
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +27 -7
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +8 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +5 -0
- package/public/app.js +92 -27
- package/public/style.css +17 -1
- package/server.js +82 -25
package/cli.js
CHANGED
|
@@ -228,7 +228,7 @@ const COMMANDS = {
|
|
|
228
228
|
verbs: {
|
|
229
229
|
start: {
|
|
230
230
|
summary: 'Start claude in cck\'s terminal with a task; prints the session id',
|
|
231
|
-
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--model <m>] [--worktree [name]] [--json] [-- <claude args>...]',
|
|
231
|
+
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--model <m>] [--worktree [name]] [--task-list [id]] [--json] [-- <claude args>...]',
|
|
232
232
|
flags: {
|
|
233
233
|
'--cwd <dir>': 'Folder to run in (a known project, default: current dir)',
|
|
234
234
|
'--spec <text>': 'The task, self-contained; sent as the first message',
|
|
@@ -237,12 +237,14 @@ const COMMANDS = {
|
|
|
237
237
|
'--group <g>': 'Show it with this session in a kebab-case group (default: this session\'s group)',
|
|
238
238
|
'--model <m>': 'fable, opus, sonnet or haiku',
|
|
239
239
|
'--worktree [name]': 'Run in a new git worktree',
|
|
240
|
+
'--task-list [id]': 'Use this task list instead of its own (default: this session\'s list), so its cards show on one board with yours. Off unless given',
|
|
240
241
|
'--json': 'Output JSON',
|
|
241
242
|
'-- <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',
|
|
242
243
|
},
|
|
243
244
|
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.',
|
|
244
245
|
examples: [
|
|
245
246
|
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --model sonnet -- --permission-mode auto',
|
|
247
|
+
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name api-worker --task-list',
|
|
246
248
|
],
|
|
247
249
|
run: runDispatchStartCli,
|
|
248
250
|
},
|
|
@@ -1079,12 +1081,19 @@ async function runDispatchStartCli(argv) {
|
|
|
1079
1081
|
return usageError(COMMANDS.dispatch.verbs.start, `Group names are kebab-case${hint ? `: try --group ${hint}` : ', e.g. auth-refactor'}`);
|
|
1080
1082
|
}
|
|
1081
1083
|
const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
|
|
1084
|
+
const hasTaskList = args.some(a => a === '--task-list' || a.startsWith('--task-list='));
|
|
1085
|
+
const ownList = process.env.CLAUDE_CODE_TASK_LIST_ID || process.env.CLAUDE_CODE_SESSION_ID;
|
|
1086
|
+
const taskList = hasTaskList ? getArgValue(args, 'task-list') || ownList || null : null;
|
|
1087
|
+
if (hasTaskList && !taskList) {
|
|
1088
|
+
return usageError(COMMANDS.dispatch.verbs.start, 'No task list id: pass --task-list <id> outside a Claude Code session');
|
|
1089
|
+
}
|
|
1082
1090
|
const body = {
|
|
1083
1091
|
cwd: canonicalDir(getArgValue(args, 'cwd') || '.'),
|
|
1084
1092
|
spec,
|
|
1085
1093
|
name: getArgValue(args, 'name'),
|
|
1086
1094
|
model: getArgValue(args, 'model'),
|
|
1087
1095
|
worktree,
|
|
1096
|
+
taskList,
|
|
1088
1097
|
group,
|
|
1089
1098
|
claudeArgs,
|
|
1090
1099
|
parent: process.env.CLAUDE_CODE_SESSION_ID || null,
|
package/lib/parsers.js
CHANGED
|
@@ -247,7 +247,7 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
|
247
247
|
messages.push(msg);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
// Cache: jsonlPath -> { ino, mtimeMs, scannedUpTo, customTitle }
|
|
250
|
+
// Cache: jsonlPath -> { ino, mtimeMs, scannedUpTo, customTitle, agentName }
|
|
251
251
|
// Only re-scan the new bytes appended since last scan
|
|
252
252
|
const customTitleCache = new Map();
|
|
253
253
|
let sessionCacheDirty = false;
|
|
@@ -269,15 +269,19 @@ const TITLE_MARKER_TEXT = ['"custom-title"', '"ai-title"', '"agent-name"'];
|
|
|
269
269
|
const TITLE_MARKERS = TITLE_MARKER_TEXT.map((m) => Buffer.from(m));
|
|
270
270
|
const hasTitleMarker = (s) => TITLE_MARKER_TEXT.some((m) => s.includes(m));
|
|
271
271
|
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
// agentName is the session's peer name (the name SendMessage addresses), kept apart from the
|
|
273
|
+
// merged title so a task owner can be matched to its session after the session ends.
|
|
274
|
+
const NO_TITLES = { customTitle: null, agentName: null };
|
|
275
|
+
|
|
276
|
+
function extractTitlesFromText(text) {
|
|
277
|
+
if (!hasTitleMarker(text)) return NO_TITLES;
|
|
274
278
|
const lines = text.split('\n');
|
|
275
279
|
let customTitle = null;
|
|
276
280
|
let aiTitle = null;
|
|
277
281
|
let agentName = null;
|
|
278
282
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
279
283
|
const line = lines[i];
|
|
280
|
-
if (!hasTitleMarker(line)) continue;
|
|
284
|
+
if (!hasTitleMarker(line) || (customTitle && !line.includes('"agent-name"'))) continue;
|
|
281
285
|
try {
|
|
282
286
|
const data = JSON.parse(line);
|
|
283
287
|
if (!customTitle && data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
|
|
@@ -287,40 +291,43 @@ function extractCustomTitleFromText(text) {
|
|
|
287
291
|
} else if (!agentName && data.type === 'agent-name' && data.agentName && !data.agentName.startsWith('<')) {
|
|
288
292
|
agentName = data.agentName;
|
|
289
293
|
}
|
|
290
|
-
if (customTitle) break;
|
|
294
|
+
if (customTitle && agentName) break;
|
|
291
295
|
} catch {}
|
|
292
296
|
}
|
|
293
|
-
return customTitle || aiTitle || agentName || null;
|
|
297
|
+
return { customTitle: customTitle || aiTitle || agentName || null, agentName };
|
|
294
298
|
}
|
|
295
299
|
|
|
296
|
-
// Same answer as
|
|
300
|
+
// Same answer as extractTitlesFromText(buf.toString('utf8')): that function only looks at
|
|
297
301
|
// lines holding a marker, and a line cut at \n decodes the same alone as inside the chunk.
|
|
298
|
-
function
|
|
302
|
+
function extractTitlesFromBuffer(buf) {
|
|
299
303
|
const starts = new Set();
|
|
300
304
|
for (const m of TITLE_MARKERS) {
|
|
301
305
|
for (let i = buf.indexOf(m); i !== -1; i = buf.indexOf(m, i + m.length)) {
|
|
302
306
|
starts.add(buf.lastIndexOf(0x0a, i) + 1);
|
|
303
307
|
}
|
|
304
308
|
}
|
|
305
|
-
if (!starts.size) return
|
|
309
|
+
if (!starts.size) return NO_TITLES;
|
|
306
310
|
const lines = [...starts]
|
|
307
311
|
.sort((a, b) => a - b)
|
|
308
312
|
.map((s) => {
|
|
309
313
|
const e = buf.indexOf(0x0a, s);
|
|
310
314
|
return buf.toString('utf8', s, e === -1 ? buf.length : e);
|
|
311
315
|
});
|
|
312
|
-
return
|
|
316
|
+
return extractTitlesFromText(lines.join('\n'));
|
|
313
317
|
}
|
|
314
318
|
|
|
315
|
-
|
|
319
|
+
const pickTitles = ({ customTitle, agentName }) => ({ customTitle, agentName });
|
|
320
|
+
|
|
321
|
+
function readTitles(jsonlPath, existingStat) {
|
|
316
322
|
try {
|
|
317
323
|
const stat = existingStat || statSync(jsonlPath);
|
|
318
324
|
const prev = customTitleCache.get(jsonlPath);
|
|
319
325
|
const cached = sameFileGrown(prev, stat) ? prev : null;
|
|
320
326
|
|
|
321
|
-
if (cached && cached.scannedUpTo === stat.size) return cached
|
|
327
|
+
if (cached && cached.scannedUpTo === stat.size) return cached;
|
|
322
328
|
|
|
323
329
|
let customTitle = cached?.customTitle || null;
|
|
330
|
+
let agentName = cached?.agentName || null;
|
|
324
331
|
const fd = fs.openSync(jsonlPath, 'r');
|
|
325
332
|
|
|
326
333
|
if (cached) {
|
|
@@ -328,7 +335,9 @@ function readCustomTitle(jsonlPath, existingStat) {
|
|
|
328
335
|
if (len > 0) {
|
|
329
336
|
const buf = Buffer.alloc(len);
|
|
330
337
|
fs.readSync(fd, buf, 0, len, cached.scannedUpTo);
|
|
331
|
-
|
|
338
|
+
const found = extractTitlesFromBuffer(buf);
|
|
339
|
+
customTitle = found.customTitle || customTitle;
|
|
340
|
+
agentName = found.agentName || agentName;
|
|
332
341
|
}
|
|
333
342
|
} else {
|
|
334
343
|
// The last chunk holding a title wins, so scan from the end and stop at the first hit.
|
|
@@ -336,16 +345,17 @@ function readCustomTitle(jsonlPath, existingStat) {
|
|
|
336
345
|
const buf = Buffer.allocUnsafe(Math.min(CHUNK, stat.size));
|
|
337
346
|
for (let offset = Math.floor((stat.size - 1) / CHUNK) * CHUNK; offset >= 0 && !customTitle; offset -= CHUNK) {
|
|
338
347
|
const n = fs.readSync(fd, buf, 0, Math.min(CHUNK, stat.size - offset), offset);
|
|
339
|
-
customTitle =
|
|
348
|
+
({ customTitle, agentName } = extractTitlesFromBuffer(buf.subarray(0, n)));
|
|
340
349
|
}
|
|
341
350
|
}
|
|
342
351
|
|
|
343
352
|
fs.closeSync(fd);
|
|
344
|
-
|
|
353
|
+
const entry = { ino: stat.ino, mtimeMs: stat.mtimeMs, scannedUpTo: stat.size, customTitle, agentName };
|
|
354
|
+
customTitleCache.set(jsonlPath, entry);
|
|
345
355
|
sessionCacheDirty = true;
|
|
346
|
-
return
|
|
356
|
+
return entry;
|
|
347
357
|
} catch {
|
|
348
|
-
return
|
|
358
|
+
return NO_TITLES;
|
|
349
359
|
}
|
|
350
360
|
}
|
|
351
361
|
|
|
@@ -416,7 +426,7 @@ const withoutUsage = (reply) => reply && { ...reply, usage: null };
|
|
|
416
426
|
// change or truncation (size < scannedUpTo).
|
|
417
427
|
|
|
418
428
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
419
|
-
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null, cacheTtl: null, lastReply: null };
|
|
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 };
|
|
420
430
|
let stat;
|
|
421
431
|
let fd;
|
|
422
432
|
try {
|
|
@@ -439,7 +449,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
439
449
|
permissionMode: cached.permissionMode || null,
|
|
440
450
|
cacheTtl: cached.cacheTtl || null,
|
|
441
451
|
lastReply: cached.lastReply || null,
|
|
442
|
-
|
|
452
|
+
...pickTitles(readTitles(jsonlPath, stat))
|
|
443
453
|
};
|
|
444
454
|
}
|
|
445
455
|
|
|
@@ -633,7 +643,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
633
643
|
}
|
|
634
644
|
sessionCacheDirty = true;
|
|
635
645
|
}
|
|
636
|
-
result
|
|
646
|
+
Object.assign(result, pickTitles(readTitles(jsonlPath, stat)));
|
|
637
647
|
return result;
|
|
638
648
|
}
|
|
639
649
|
|
package/lib/retention.js
CHANGED
|
@@ -72,7 +72,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
72
72
|
return removed;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
return { record, prune, get: (id) => entries.get(id) || null };
|
|
75
|
+
return { record, prune, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
|
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 = 6;
|
|
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/terminal.js
CHANGED
|
@@ -30,6 +30,8 @@ const PICK_START_SLACK_MS = 2000;
|
|
|
30
30
|
// charset that needs no escaping beyond plain quotes. The first character is never '-'.
|
|
31
31
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
|
|
32
32
|
const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
33
|
+
// Claude Code keeps a list in tasks/<id>/, so the id must stay one path segment.
|
|
34
|
+
const TASK_LIST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
33
35
|
const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
|
|
34
36
|
// Extra claude args reach the command line inside plain quotes (quoteArg), so a character
|
|
35
37
|
// that ends or expands those quotes in any of the shells is refused.
|
|
@@ -72,6 +74,8 @@ const STRIP_ENV = [
|
|
|
72
74
|
// session's child and writes no live-session registry entry, which the resume picker relies on.
|
|
73
75
|
'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID', 'CLAUDE_CODE_SESSION_ATTENDED',
|
|
74
76
|
'CLAUDE_CODE_MESSAGING_SOCKET', 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_EXECPATH',
|
|
77
|
+
// A shared task list is opt-in per dispatch (taskList), never inherited from cck's own env.
|
|
78
|
+
'CLAUDE_CODE_TASK_LIST_ID',
|
|
75
79
|
// A cck started inside tmux passes these on, and claude then lists its session under a tmux pane it is not in.
|
|
76
80
|
'TMUX', 'TMUX_PANE',
|
|
77
81
|
];
|
|
@@ -190,13 +194,15 @@ function parseNewSpec(msg) {
|
|
|
190
194
|
if (typeof worktree !== 'boolean' && !(typeof worktree === 'string' && WORKTREE_RE.test(worktree))) return 'worktree name';
|
|
191
195
|
const model = msg.model || null;
|
|
192
196
|
if (model && !MODELS.has(model)) return 'model';
|
|
197
|
+
const taskList = msg.taskList || null;
|
|
198
|
+
if (taskList && !(typeof taskList === 'string' && TASK_LIST_RE.test(taskList))) return 'task list id';
|
|
193
199
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
|
|
194
200
|
const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
|
|
195
201
|
if (prompt.length > MAX_PROMPT) return 'prompt';
|
|
196
202
|
const extraArgs = msg.extraArgs ?? [];
|
|
197
203
|
const bad = extraArgsError(extraArgs);
|
|
198
204
|
if (bad) return bad;
|
|
199
|
-
return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null, extraArgs };
|
|
205
|
+
return { cwd: msg.cwd, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs };
|
|
200
206
|
}
|
|
201
207
|
|
|
202
208
|
function samePath(a, b) {
|
|
@@ -298,8 +304,8 @@ function clientConfigFor(config, available) {
|
|
|
298
304
|
* @param {(cmd: string) => string|null} o.which
|
|
299
305
|
* @param {string} [o.token]
|
|
300
306
|
* @param {() => void} [o.onChange] a terminal started, ended or moved to another session id
|
|
301
|
-
* @param {() => {sessions?: string[]}|null} [o.load] the saved list, read when config.restore is on
|
|
302
|
-
* @param {(data: {sessions: string[]}) => void} [o.save] the session ids to resume on the next start
|
|
307
|
+
* @param {() => {sessions?: string[], taskLists?: Object<string, string>}|null} [o.load] the saved list, read when config.restore is on
|
|
308
|
+
* @param {(data: {sessions: string[], taskLists?: Object<string, string>}) => void} [o.save] the session ids to resume on the next start, and the shared task list of each that has one
|
|
303
309
|
* @param {object} [o.pty] stands in for @lydell/node-pty in tests
|
|
304
310
|
*/
|
|
305
311
|
function createTerminalService(o) {
|
|
@@ -323,8 +329,14 @@ function createTerminalService(o) {
|
|
|
323
329
|
const exitTimer = setInterval(checkExits, exitPollMs);
|
|
324
330
|
exitTimer.unref();
|
|
325
331
|
// Read before any terminal starts, so the first save keeps the ids still waiting to restore.
|
|
326
|
-
const
|
|
332
|
+
const savedState = config.restore ? o.load?.() : null;
|
|
333
|
+
const saved = savedState?.sessions;
|
|
327
334
|
const restoreQueue = Array.isArray(saved) ? saved.filter((id) => typeof id === 'string' && UUID_RE.test(id)) : [];
|
|
335
|
+
// A resumed claude gets back the shared task list it was started with.
|
|
336
|
+
const savedTaskLists = {};
|
|
337
|
+
for (const [id, list] of Object.entries(savedState?.taskLists || {})) {
|
|
338
|
+
if (restoreQueue.includes(id) && typeof list === 'string' && TASK_LIST_RE.test(list)) savedTaskLists[id] = list;
|
|
339
|
+
}
|
|
328
340
|
|
|
329
341
|
// Lazy and optional: a missing prebuilt or shell must hide the feature, not crash cck.
|
|
330
342
|
function load() {
|
|
@@ -366,8 +378,14 @@ function createTerminalService(o) {
|
|
|
366
378
|
function saveState() {
|
|
367
379
|
saveTimer = null;
|
|
368
380
|
const ids = new Set(restoreQueue);
|
|
369
|
-
|
|
370
|
-
const
|
|
381
|
+
const taskLists = {};
|
|
382
|
+
for (const id of restoreQueue) if (savedTaskLists[id]) taskLists[id] = savedTaskLists[id];
|
|
383
|
+
for (const s of sessions.values()) {
|
|
384
|
+
if (s.ended || s.claudeExitedAt || !RESTORABLE_MODES.has(s.mode)) continue;
|
|
385
|
+
ids.add(s.id);
|
|
386
|
+
if (s.taskList) taskLists[s.id] = s.taskList;
|
|
387
|
+
}
|
|
388
|
+
const data = { sessions: [...ids], ...(Object.keys(taskLists).length && { taskLists }) };
|
|
371
389
|
const json = JSON.stringify(data);
|
|
372
390
|
if (json === lastSaved) return;
|
|
373
391
|
lastSaved = json;
|
|
@@ -515,6 +533,8 @@ function createTerminalService(o) {
|
|
|
515
533
|
function spawnSession(id, mode, cwdCandidate, cols, rows, spec, extraEnv) {
|
|
516
534
|
const cwd = cwdCandidate && fs.existsSync(cwdCandidate) ? cwdCandidate : os.homedir();
|
|
517
535
|
const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker, cckUrl: o.serverUrl?.() }), ...extraEnv };
|
|
536
|
+
const taskList = spec?.taskList || savedTaskLists[id] || null;
|
|
537
|
+
if (taskList) env.CLAUDE_CODE_TASK_LIST_ID = taskList;
|
|
518
538
|
// Git Bash's login profile cds to $HOME unless this is set.
|
|
519
539
|
if (shellFamily(shell) === 'gitbash') env.CHERE_INVOKING = '1';
|
|
520
540
|
const proc = pty.spawn(shell, shellArgs(shell, claudeArgsFor(mode, id, spec)), {
|
|
@@ -529,7 +549,7 @@ function createTerminalService(o) {
|
|
|
529
549
|
term.loadAddon(serializer);
|
|
530
550
|
const s = {
|
|
531
551
|
id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
532
|
-
name: spec?.name || null, worktree: spec?.worktree || false, onOutput: null,
|
|
552
|
+
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null,
|
|
533
553
|
};
|
|
534
554
|
if (spec?.prompt) queuePrompt(s, spec.prompt);
|
|
535
555
|
if (mode === 'pick') watchPick(s);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.25.
|
|
3
|
+
"version": "2.25.2",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -24,6 +24,14 @@ When you are done, or cannot finish, send the result to <your peer name> with th
|
|
|
24
24
|
|
|
25
25
|
`--handoff` is a skill argument that drops the reply line: the session owns the task, and the user follows it on the board. Keep it out of the `dispatch start` command.
|
|
26
26
|
|
|
27
|
+
## Track on the board (optional)
|
|
28
|
+
|
|
29
|
+
When the user asks to track the dispatched work, give each worker a card:
|
|
30
|
+
|
|
31
|
+
1. Create one card per worker with `TaskCreate`.
|
|
32
|
+
2. Right after each `dispatch start`, set the card's `owner` to the worker's `--name` and its status to `in_progress`. The card then links to the worker's session.
|
|
33
|
+
3. Set the card to `completed` when the worker reports.
|
|
34
|
+
|
|
27
35
|
## Orchestration patterns
|
|
28
36
|
|
|
29
37
|
Use request-reply, handoff or orchestrator-workers directly. When another shape fits better (a separate reviewer, steps that feed each other, a sub-orchestrator, a decision for the user), propose it in one line, the pattern and why, and dispatch only after the user agrees, unless the user named it. Details: [references/orchestration-patterns.md](references/orchestration-patterns.md).
|
package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md
CHANGED
|
@@ -20,6 +20,7 @@ Propose these first, unless the user named one. Each adds steps, rounds or sessi
|
|
|
20
20
|
| Evaluator-optimizer | There is a clear acceptance bar and a second look improves the result |
|
|
21
21
|
| Hierarchical | A part is itself large enough to split |
|
|
22
22
|
| Human-in-the-loop | The spec leaves a choice the user should make |
|
|
23
|
+
| Shared board | The user wants the workers to pick cards from one task list |
|
|
23
24
|
|
|
24
25
|
## Request-reply
|
|
25
26
|
|
|
@@ -48,3 +49,7 @@ A worker that dispatches its own workers and replies once they all have. Your co
|
|
|
48
49
|
## Human-in-the-loop
|
|
49
50
|
|
|
50
51
|
Add to the worker's spec: `When you need a decision, ask <your peer name> with the SendMessage tool and keep working on what does not depend on the answer.` Ask the user when the decision is theirs, and reply with `SendMessage`. You can steer a running worker the same way at any time.
|
|
52
|
+
|
|
53
|
+
## Shared board
|
|
54
|
+
|
|
55
|
+
Every worker uses your task list instead of its own, so all cards show on one board. Pass `--task-list` with no value to each `dispatch start`. You assign the cards as in Track on the board (optional); the spec names the worker's card and tells it to set that card to `completed` when done.
|
package/public/app.js
CHANGED
|
@@ -4036,6 +4036,14 @@ function openBlockers(task) {
|
|
|
4036
4036
|
return (task.blockedBy || []).filter((id) => findSiblingTask(task, id)?.status !== 'completed');
|
|
4037
4037
|
}
|
|
4038
4038
|
|
|
4039
|
+
function renderOwnerBadge(task) {
|
|
4040
|
+
const c = getOwnerColor(task.owner);
|
|
4041
|
+
const style = `background:${c.bg};color:${c.color}`;
|
|
4042
|
+
const target = task.ownerSessionId;
|
|
4043
|
+
if (!target) return `<span class="task-owner-badge" style="${style}">${escapeHtml(task.owner)}</span>`;
|
|
4044
|
+
return `<button type="button" class="task-owner-badge task-owner-link" style="${style}" title="Open dispatched session" onclick="event.stopPropagation(); revealSession('${escAttrJs(target)}')">${ICON_DISPATCHED}${escapeHtml(task.owner)}</button>`;
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4039
4047
|
function renderTaskCard(task) {
|
|
4040
4048
|
const blockers = openBlockers(task);
|
|
4041
4049
|
const isBlocked = blockers.length > 0;
|
|
@@ -4059,16 +4067,7 @@ function renderTaskCard(task) {
|
|
|
4059
4067
|
<div class="task-id">
|
|
4060
4068
|
<span>#${taskId}</span>
|
|
4061
4069
|
${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
|
|
4062
|
-
${
|
|
4063
|
-
task.owner
|
|
4064
|
-
? (
|
|
4065
|
-
() => {
|
|
4066
|
-
const c = getOwnerColor(task.owner);
|
|
4067
|
-
return `<span class="task-owner-badge" style="background:${c.bg};color:${c.color}">${escapeHtml(task.owner)}</span>`;
|
|
4068
|
-
}
|
|
4069
|
-
)()
|
|
4070
|
-
: ''
|
|
4071
|
-
}
|
|
4070
|
+
${task.owner ? renderOwnerBadge(task) : ''}
|
|
4072
4071
|
</div>
|
|
4073
4072
|
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
4074
4073
|
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
@@ -5531,7 +5530,7 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5531
5530
|
|
|
5532
5531
|
<div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
|
|
5533
5532
|
<div>${statusLabels[task.status] || ''}</div>
|
|
5534
|
-
${task.owner ?
|
|
5533
|
+
${task.owner ? renderOwnerBadge(task) : ''}
|
|
5535
5534
|
</div>
|
|
5536
5535
|
|
|
5537
5536
|
<div class="detail-section">
|
|
@@ -7630,6 +7629,18 @@ function bridgedFrameEls() {
|
|
|
7630
7629
|
return [...document.querySelectorAll('iframe[srcdoc]')].filter((f) => bridgedFrames.has(f));
|
|
7631
7630
|
}
|
|
7632
7631
|
|
|
7632
|
+
// A previewed page always hands back the zoom keys, so Ctrl +/-/0 scales it like a markdown
|
|
7633
|
+
// preview instead of zooming the whole board. A bridged frame is on screen only in a zoomable view.
|
|
7634
|
+
const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
|
|
7635
|
+
[false, true].flatMap((shiftKey) =>
|
|
7636
|
+
Object.keys(ZOOM_KEYS).map((k) =>
|
|
7637
|
+
k.length === 1
|
|
7638
|
+
? { [mod]: true, shiftKey, key: k, code: '' }
|
|
7639
|
+
: { [mod]: true, shiftKey, key: 'Unidentified', code: k },
|
|
7640
|
+
),
|
|
7641
|
+
),
|
|
7642
|
+
);
|
|
7643
|
+
|
|
7633
7644
|
function sendBridgeClaims(frames, claims) {
|
|
7634
7645
|
for (const f of frames) f.contentWindow?.postMessage({ type: `${REVIEW_MSG}claims`, ...claims }, '*');
|
|
7635
7646
|
}
|
|
@@ -7985,7 +7996,7 @@ function createPreviewFrame(className, srcdoc) {
|
|
|
7985
7996
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
7986
7997
|
frame.srcdoc = srcdoc;
|
|
7987
7998
|
bridgedFrames.add(frame);
|
|
7988
|
-
frame.addEventListener('load', () => sendBridgeClaims([frame], keyClaims()));
|
|
7999
|
+
frame.addEventListener('load', () => sendBridgeClaims([frame], bridgeClaims(keyClaims())));
|
|
7989
8000
|
return frame;
|
|
7990
8001
|
}
|
|
7991
8002
|
|
|
@@ -12068,7 +12079,7 @@ async function loadPaneView(sid, pane, view) {
|
|
|
12068
12079
|
}
|
|
12069
12080
|
if (pane.kind === 'url') {
|
|
12070
12081
|
const frame = document.createElement('iframe');
|
|
12071
|
-
frame.className = 'pane-frame';
|
|
12082
|
+
frame.className = 'pane-frame modal-zoomable';
|
|
12072
12083
|
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
12073
12084
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
12074
12085
|
frame.title = pane.title;
|
|
@@ -12088,7 +12099,7 @@ async function loadPaneView(sid, pane, view) {
|
|
|
12088
12099
|
if (r.ok) data = await r.json();
|
|
12089
12100
|
} catch {}
|
|
12090
12101
|
if (data?.kind === 'html') {
|
|
12091
|
-
const frame = createPreviewFrame('pane-frame', data.content + REVIEW_BRIDGE_TAG);
|
|
12102
|
+
const frame = createPreviewFrame('pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG);
|
|
12092
12103
|
frame.title = pane.title;
|
|
12093
12104
|
view.appendChild(frame);
|
|
12094
12105
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
@@ -12255,26 +12266,74 @@ function paneMenuKeydown(e) {
|
|
|
12255
12266
|
menuKeydown(e, [...menu.querySelectorAll('.pane-menu-item')], closePaneMenu);
|
|
12256
12267
|
}
|
|
12257
12268
|
|
|
12269
|
+
async function deleteBoardTasks(onlyCompleted) {
|
|
12270
|
+
const sid = paneSessionId();
|
|
12271
|
+
if (!sid) return;
|
|
12272
|
+
const count = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed').length;
|
|
12273
|
+
if (!count) {
|
|
12274
|
+
showToast(onlyCompleted ? 'No completed tasks' : 'No tasks', 'info');
|
|
12275
|
+
return;
|
|
12276
|
+
}
|
|
12277
|
+
const noun = `${count} ${onlyCompleted ? 'completed ' : ''}task${count === 1 ? '' : 's'}`;
|
|
12278
|
+
const ok = await confirmModal({
|
|
12279
|
+
title: onlyCompleted ? 'Delete Completed Tasks' : 'Delete All Tasks',
|
|
12280
|
+
message: `Delete ${noun}? This cannot be undone.`,
|
|
12281
|
+
okLabel: 'Delete',
|
|
12282
|
+
});
|
|
12283
|
+
if (!ok) return;
|
|
12284
|
+
const q = onlyCompleted ? '?status=completed' : '';
|
|
12285
|
+
const res = await paneRequest('DELETE', `/api/tasks/${encodeURIComponent(sid)}${q}`);
|
|
12286
|
+
if (res.error) {
|
|
12287
|
+
showToast(`Failed to delete tasks: ${res.error}`, 'error');
|
|
12288
|
+
return;
|
|
12289
|
+
}
|
|
12290
|
+
if (detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
12291
|
+
showToast(`Deleted ${res.deleted.length} task${res.deleted.length === 1 ? '' : 's'}`, 'success');
|
|
12292
|
+
await refreshCurrentView();
|
|
12293
|
+
}
|
|
12294
|
+
|
|
12295
|
+
const paneMenuItem = (act, label) =>
|
|
12296
|
+
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
|
|
12297
|
+
|
|
12258
12298
|
function openPaneMenu(x, y, id) {
|
|
12299
|
+
if (id === 'board') {
|
|
12300
|
+
showPaneMenu(
|
|
12301
|
+
x,
|
|
12302
|
+
y,
|
|
12303
|
+
id,
|
|
12304
|
+
'Board actions',
|
|
12305
|
+
'<div class="pane-menu-label">Board</div>' +
|
|
12306
|
+
paneMenuItem('deleteCompleted', 'Delete completed tasks') +
|
|
12307
|
+
paneMenuItem('deleteAll', 'Delete all tasks'),
|
|
12308
|
+
);
|
|
12309
|
+
return;
|
|
12310
|
+
}
|
|
12259
12311
|
const pane = paneById(id);
|
|
12260
12312
|
if (!pane) return;
|
|
12313
|
+
const url = pane.kind === 'url';
|
|
12314
|
+
showPaneMenu(
|
|
12315
|
+
x,
|
|
12316
|
+
y,
|
|
12317
|
+
pane.id,
|
|
12318
|
+
'Pane actions',
|
|
12319
|
+
`<div class="pane-menu-label" title="${escapeHtml(pane.target)}">${escapeHtml(pane.target)}</div>` +
|
|
12320
|
+
paneMenuItem('copy', url ? 'Copy URL' : 'Copy path') +
|
|
12321
|
+
paneMenuItem('open', url ? 'Open in new tab' : 'Open in preview') +
|
|
12322
|
+
paneMenuItem('reload', 'Reload') +
|
|
12323
|
+
paneMenuItem('close', 'Close pane'),
|
|
12324
|
+
);
|
|
12325
|
+
}
|
|
12326
|
+
|
|
12327
|
+
function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
12261
12328
|
closePaneMenu();
|
|
12262
12329
|
sgCloseMenu();
|
|
12263
|
-
const url = pane.kind === 'url';
|
|
12264
|
-
const item = (act, label) =>
|
|
12265
|
-
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
|
|
12266
12330
|
const menu = document.createElement('div');
|
|
12267
12331
|
menu.id = 'pane-menu';
|
|
12268
12332
|
menu.className = 'pane-menu';
|
|
12269
12333
|
menu.setAttribute('role', 'menu');
|
|
12270
|
-
menu.setAttribute('aria-label',
|
|
12271
|
-
menu.dataset.pane =
|
|
12272
|
-
menu.innerHTML =
|
|
12273
|
-
`<div class="pane-menu-label" title="${escapeHtml(pane.target)}">${escapeHtml(pane.target)}</div>` +
|
|
12274
|
-
item('copy', url ? 'Copy URL' : 'Copy path') +
|
|
12275
|
-
item('open', url ? 'Open in new tab' : 'Open in preview') +
|
|
12276
|
-
item('reload', 'Reload') +
|
|
12277
|
-
item('close', 'Close pane');
|
|
12334
|
+
menu.setAttribute('aria-label', ariaLabel);
|
|
12335
|
+
menu.dataset.pane = id;
|
|
12336
|
+
menu.innerHTML = html;
|
|
12278
12337
|
placeMenu(menu, x, y);
|
|
12279
12338
|
document.addEventListener('keydown', paneMenuKeydown, true);
|
|
12280
12339
|
menu.querySelector('.pane-menu-item').focus();
|
|
@@ -12285,6 +12344,8 @@ const PANE_MENU_ACTIONS = {
|
|
|
12285
12344
|
open: openPaneExternally,
|
|
12286
12345
|
reload: reloadPane,
|
|
12287
12346
|
close: closePane,
|
|
12347
|
+
deleteCompleted: () => deleteBoardTasks(true),
|
|
12348
|
+
deleteAll: () => deleteBoardTasks(false),
|
|
12288
12349
|
};
|
|
12289
12350
|
|
|
12290
12351
|
// The server takes only absolute paths, and linked docs can be relative to the session's folder.
|
|
@@ -12396,7 +12457,7 @@ paneStrip.addEventListener('auxclick', (e) => {
|
|
|
12396
12457
|
});
|
|
12397
12458
|
paneStrip.addEventListener('contextmenu', (e) => {
|
|
12398
12459
|
const tab = e.target.closest('.pane-tab');
|
|
12399
|
-
if (!tab
|
|
12460
|
+
if (!tab) return;
|
|
12400
12461
|
e.preventDefault();
|
|
12401
12462
|
openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
|
|
12402
12463
|
});
|
|
@@ -12924,6 +12985,10 @@ function keyClaims() {
|
|
|
12924
12985
|
return { keys: terminalClaims(), forward: hub.forwardCombos() };
|
|
12925
12986
|
}
|
|
12926
12987
|
|
|
12988
|
+
function bridgeClaims(claims) {
|
|
12989
|
+
return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS] };
|
|
12990
|
+
}
|
|
12991
|
+
|
|
12927
12992
|
function pushTerminalClaims() {
|
|
12928
12993
|
if (terminalClaimsQueued) return;
|
|
12929
12994
|
terminalClaimsQueued = true;
|
|
@@ -12932,7 +12997,7 @@ function pushTerminalClaims() {
|
|
|
12932
12997
|
const bridged = bridgedFrameEls();
|
|
12933
12998
|
if (!termFrame.inited && !bridged.length) return;
|
|
12934
12999
|
const claims = keyClaims();
|
|
12935
|
-
sendBridgeClaims(bridged, claims);
|
|
13000
|
+
sendBridgeClaims(bridged, bridgeClaims(claims));
|
|
12936
13001
|
if (!termFrame.inited) return;
|
|
12937
13002
|
const sig = JSON.stringify(claims);
|
|
12938
13003
|
if (sig === termFrame.claims) return;
|
package/public/style.css
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
--font-serif: var(--serif);
|
|
53
53
|
/* reading-surface scale for the message-detail and preview modals (Ctrl +/-/0) */
|
|
54
54
|
--modal-zoom: 1;
|
|
55
|
+
--review-panel-w: 320px;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/* Tokens derived from theme colors live on body, where color themes set those colors;
|
|
@@ -2929,6 +2930,22 @@ code.language-tree .hljs-title {
|
|
|
2929
2930
|
letter-spacing: 0;
|
|
2930
2931
|
}
|
|
2931
2932
|
|
|
2933
|
+
.task-owner-link {
|
|
2934
|
+
border: none;
|
|
2935
|
+
font-family: inherit;
|
|
2936
|
+
cursor: pointer;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
.task-owner-link:hover {
|
|
2940
|
+
text-decoration: underline;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
.task-owner-link svg {
|
|
2944
|
+
width: 10px;
|
|
2945
|
+
height: 10px;
|
|
2946
|
+
flex-shrink: 0;
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2932
2949
|
/* #endregion */
|
|
2933
2950
|
|
|
2934
2951
|
/* #region TEAM_MODAL */
|
|
@@ -6028,7 +6045,6 @@ body.light .msg-assistant .msg-text {
|
|
|
6028
6045
|
/* The comment panel opens beside the content, so the dialog grows by `--review-extra`
|
|
6029
6046
|
instead of squeezing what is under review. */
|
|
6030
6047
|
.modal.review-host {
|
|
6031
|
-
--review-panel-w: 320px;
|
|
6032
6048
|
--review-extra: 0px;
|
|
6033
6049
|
}
|
|
6034
6050
|
|
package/server.js
CHANGED
|
@@ -828,12 +828,14 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
828
828
|
if (shadow) {
|
|
829
829
|
if (!existing.slug && info.slug) existing.slug = info.slug;
|
|
830
830
|
if (!existing.customTitle && info.customTitle) existing.customTitle = info.customTitle;
|
|
831
|
+
if (!existing.agentName && info.agentName) existing.agentName = info.agentName;
|
|
831
832
|
return true;
|
|
832
833
|
}
|
|
833
834
|
if (info.slug) existing.slug = info.slug;
|
|
834
835
|
if (info.cwd) existing.cwd = info.cwd;
|
|
835
836
|
if (info.gitBranch) existing.gitBranch = info.gitBranch;
|
|
836
837
|
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
838
|
+
if (info.agentName) existing.agentName = info.agentName;
|
|
837
839
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
838
840
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
839
841
|
existing.permissionMode = info.permissionMode;
|
|
@@ -912,6 +914,7 @@ function loadSessionMetadata() {
|
|
|
912
914
|
if (existing?.project && !candidateProject) {
|
|
913
915
|
if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
|
|
914
916
|
if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
|
|
917
|
+
if (!existing.agentName && sessionInfo.agentName) existing.agentName = sessionInfo.agentName;
|
|
915
918
|
if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
|
|
916
919
|
sessionIds.push(sessionId);
|
|
917
920
|
continue;
|
|
@@ -923,6 +926,7 @@ function loadSessionMetadata() {
|
|
|
923
926
|
cwd: sessionInfo.cwd || null,
|
|
924
927
|
gitBranch: sessionInfo.gitBranch || null,
|
|
925
928
|
customTitle: sessionInfo.customTitle || null,
|
|
929
|
+
agentName: sessionInfo.agentName || null,
|
|
926
930
|
jsonlPath: jsonlPath,
|
|
927
931
|
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
928
932
|
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
|
|
@@ -1680,6 +1684,27 @@ app.get('/api/projects', (_req, res) => {
|
|
|
1680
1684
|
});
|
|
1681
1685
|
|
|
1682
1686
|
// API: Get tasks for a session
|
|
1687
|
+
// A card's owner links to the session of that name that the list's session dispatched.
|
|
1688
|
+
// A name two of its dispatched sessions share gets no link.
|
|
1689
|
+
function addOwnerSessions(tasks, listIdOf) {
|
|
1690
|
+
const lists = new Set(tasks.filter((t) => t.owner).map(listIdOf));
|
|
1691
|
+
if (!lists.size) return;
|
|
1692
|
+
const childIds = [...dispatched.entries()].filter(([, e]) => lists.has(e.parent));
|
|
1693
|
+
if (!childIds.length) return;
|
|
1694
|
+
const metadata = loadSessionMetadata();
|
|
1695
|
+
const children = new Map();
|
|
1696
|
+
for (const [id, { parent }] of childIds) {
|
|
1697
|
+
const name = metadata[id]?.agentName;
|
|
1698
|
+
if (!name) continue;
|
|
1699
|
+
const key = `${parent}\n${name}`;
|
|
1700
|
+
children.set(key, children.has(key) ? null : id);
|
|
1701
|
+
}
|
|
1702
|
+
for (const t of tasks) {
|
|
1703
|
+
const id = t.owner && children.get(`${listIdOf(t)}\n${t.owner}`);
|
|
1704
|
+
if (id) t.ownerSessionId = id;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1683
1708
|
app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
1684
1709
|
try {
|
|
1685
1710
|
const sessionPath = taskDirFor(req.params.sessionId);
|
|
@@ -1704,6 +1729,7 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
|
1704
1729
|
|
|
1705
1730
|
// Sort by ID (numeric)
|
|
1706
1731
|
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
1732
|
+
addOwnerSessions(tasks, () => req.params.sessionId);
|
|
1707
1733
|
|
|
1708
1734
|
res.json(tasks);
|
|
1709
1735
|
} catch (error) {
|
|
@@ -1751,6 +1777,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
|
|
|
1751
1777
|
}
|
|
1752
1778
|
}
|
|
1753
1779
|
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
1780
|
+
addOwnerSessions(tasks, (t) => t._taskDir);
|
|
1754
1781
|
res.json(tasks);
|
|
1755
1782
|
} catch (error) {
|
|
1756
1783
|
console.error('Error getting project tasks:', error);
|
|
@@ -3413,13 +3440,13 @@ function withDispatchPlacement(sessions) {
|
|
|
3413
3440
|
// TERMINAL_TOKEN_FILE. The started session never holds it.
|
|
3414
3441
|
app.post('/api/dispatch', terminalRoute(async (req, res) => {
|
|
3415
3442
|
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3416
|
-
const { cwd, spec, name, model, worktree, parent, group, claudeArgs } = req.body || {};
|
|
3443
|
+
const { cwd, spec, name, model, worktree, taskList, parent, group, claudeArgs } = req.body || {};
|
|
3417
3444
|
if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
|
|
3418
3445
|
if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
|
|
3419
3446
|
if (group != null && !isGroupName(group)) {
|
|
3420
3447
|
return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
|
|
3421
3448
|
}
|
|
3422
|
-
const started = await terminal.startNew({ cwd, name, model, worktree, prompt: spec.trim(), extraArgs: claudeArgs });
|
|
3449
|
+
const started = await terminal.startNew({ cwd, name, model, worktree, taskList, prompt: spec.trim(), extraArgs: claudeArgs });
|
|
3423
3450
|
if (started.error) return res.status(started.status).json({ error: started.error });
|
|
3424
3451
|
dispatches.add({ session: started.id, parent, cwd: started.cwd, name, group: group || null, worktree });
|
|
3425
3452
|
dispatched.record(started.id, parent);
|
|
@@ -3578,33 +3605,50 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
3578
3605
|
}
|
|
3579
3606
|
});
|
|
3580
3607
|
|
|
3608
|
+
// A deleted task can't block anything, so drop the dangling reference instead of
|
|
3609
|
+
// refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
|
|
3610
|
+
async function deleteTasks(dir, shouldDelete) {
|
|
3611
|
+
let files;
|
|
3612
|
+
try {
|
|
3613
|
+
files = (await fs.readdir(dir)).filter((f) => f.endsWith('.json'));
|
|
3614
|
+
} catch (e) {
|
|
3615
|
+
if (e.code === 'ENOENT') return [];
|
|
3616
|
+
throw e;
|
|
3617
|
+
}
|
|
3618
|
+
const tasks = [];
|
|
3619
|
+
for (const file of files) {
|
|
3620
|
+
try {
|
|
3621
|
+
tasks.push({ file, task: JSON.parse(await fs.readFile(path.join(dir, file), 'utf8')) });
|
|
3622
|
+
} catch (e) {
|
|
3623
|
+
console.error(`Error parsing ${file}:`, e);
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
const doomed = tasks.filter((t) => shouldDelete(t));
|
|
3627
|
+
const doomedIds = new Set(doomed.map(({ task }) => task.id));
|
|
3628
|
+
const strip = (ids) => ids?.filter((id) => !doomedIds.has(id));
|
|
3629
|
+
|
|
3630
|
+
await Promise.all(
|
|
3631
|
+
tasks
|
|
3632
|
+
.filter(({ task }) => !doomedIds.has(task.id))
|
|
3633
|
+
.map(({ file, task }) => {
|
|
3634
|
+
const before = JSON.stringify(task);
|
|
3635
|
+
task.blockedBy = strip(task.blockedBy);
|
|
3636
|
+
task.blocks = strip(task.blocks);
|
|
3637
|
+
if (JSON.stringify(task) === before) return null;
|
|
3638
|
+
return fs.writeFile(path.join(dir, file), JSON.stringify(task, null, 2));
|
|
3639
|
+
}),
|
|
3640
|
+
);
|
|
3641
|
+
// Delete the task file
|
|
3642
|
+
await Promise.all(doomed.map(({ file }) => fs.unlink(path.join(dir, file))));
|
|
3643
|
+
return [...doomedIds];
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3581
3646
|
// API: Delete a task
|
|
3582
3647
|
app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
3583
3648
|
try {
|
|
3584
3649
|
const { sessionId, taskId } = req.params;
|
|
3585
|
-
const
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
if (!existsSync(taskPath)) {
|
|
3589
|
-
return res.status(404).json({ error: 'Task not found' });
|
|
3590
|
-
}
|
|
3591
|
-
|
|
3592
|
-
// A deleted task can't block anything, so drop the dangling reference instead of
|
|
3593
|
-
// refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
|
|
3594
|
-
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
3595
|
-
|
|
3596
|
-
for (const file of taskFiles) {
|
|
3597
|
-
const otherPath = path.join(sessionPath, file);
|
|
3598
|
-
const otherTask = JSON.parse(readFileSync(otherPath, 'utf8'));
|
|
3599
|
-
if (otherTask.blockedBy?.includes(taskId)) {
|
|
3600
|
-
otherTask.blockedBy = otherTask.blockedBy.filter(id => id !== taskId);
|
|
3601
|
-
await fs.writeFile(otherPath, JSON.stringify(otherTask, null, 2));
|
|
3602
|
-
}
|
|
3603
|
-
}
|
|
3604
|
-
|
|
3605
|
-
// Delete the task file
|
|
3606
|
-
await fs.unlink(taskPath);
|
|
3607
|
-
|
|
3650
|
+
const deleted = await deleteTasks(taskDirFor(sessionId), ({ file }) => file === `${taskId}.json`);
|
|
3651
|
+
if (!deleted.length) return res.status(404).json({ error: 'Task not found' });
|
|
3608
3652
|
res.json({ success: true, taskId });
|
|
3609
3653
|
} catch (error) {
|
|
3610
3654
|
console.error('Error deleting task:', error);
|
|
@@ -3612,6 +3656,19 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
3612
3656
|
}
|
|
3613
3657
|
});
|
|
3614
3658
|
|
|
3659
|
+
// API: Delete every task in a list, or with ?status=completed only the completed ones
|
|
3660
|
+
app.delete('/api/tasks/:sessionId', async (req, res) => {
|
|
3661
|
+
try {
|
|
3662
|
+
const { status } = req.query;
|
|
3663
|
+
if (status !== undefined && status !== 'completed') return res.status(400).json({ error: 'Invalid status' });
|
|
3664
|
+
const deleted = await deleteTasks(taskDirFor(req.params.sessionId), ({ task }) => !status || task.status === status);
|
|
3665
|
+
res.json({ success: true, deleted });
|
|
3666
|
+
} catch (error) {
|
|
3667
|
+
console.error('Error deleting tasks:', error);
|
|
3668
|
+
res.status(500).json({ error: 'Failed to delete tasks' });
|
|
3669
|
+
}
|
|
3670
|
+
});
|
|
3671
|
+
|
|
3615
3672
|
// #endregion
|
|
3616
3673
|
|
|
3617
3674
|
// #region PREVIEW
|