claude-code-kanban 7.0.2 → 7.1.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/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
- function extractCustomTitleFromText(text) {
273
- if (!hasTitleMarker(text)) return null;
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 extractCustomTitleFromText(buf.toString('utf8')): that function only looks at
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 extractCustomTitleFromBuffer(buf) {
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 null;
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 extractCustomTitleFromText(lines.join('\n'));
316
+ return extractTitlesFromText(lines.join('\n'));
313
317
  }
314
318
 
315
- function readCustomTitle(jsonlPath, existingStat) {
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.customTitle;
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
- customTitle = extractCustomTitleFromBuffer(buf) || customTitle;
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 = extractCustomTitleFromBuffer(buf.subarray(0, n));
348
+ ({ customTitle, agentName } = extractTitlesFromBuffer(buf.subarray(0, n)));
340
349
  }
341
350
  }
342
351
 
343
352
  fs.closeSync(fd);
344
- customTitleCache.set(jsonlPath, { ino: stat.ino, mtimeMs: stat.mtimeMs, scannedUpTo: stat.size, customTitle });
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 customTitle;
356
+ return entry;
347
357
  } catch {
348
- return null;
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
- customTitle: readCustomTitle(jsonlPath, stat)
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.customTitle = readCustomTitle(jsonlPath, stat);
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.
@@ -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 = 5;
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 saved = config.restore ? o.load?.()?.sessions : null;
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
- for (const s of sessions.values()) if (!s.ended && !s.claudeExitedAt && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
370
- const data = { sessions: [...ids] };
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": "7.0.2",
3
+ "version": "7.1.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.25.1",
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).
@@ -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 ? `<div style="font-size: 13px; color: ${getOwnerColor(task.owner).color}; font-weight: 500;">${escapeHtml(task.owner)}</div>` : ''}
5533
+ ${task.owner ? renderOwnerBadge(task) : ''}
5535
5534
  </div>
5536
5535
 
5537
5536
  <div class="detail-section">
@@ -11932,7 +11931,7 @@ const PANE_FRAME_LIMIT = 3;
11932
11931
  const PANE_URL_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads';
11933
11932
  const PANE_ICONS = {
11934
11933
  board:
11935
- '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="10" rx="1"/><rect x="17" y="4" width="4" height="13" rx="1"/></svg>',
11934
+ '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 5v14"/><path d="M12 5v8"/><path d="M18 5v11"/></svg>',
11936
11935
  url: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>',
11937
11936
  file: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
11938
11937
  warning:
@@ -12255,26 +12254,74 @@ function paneMenuKeydown(e) {
12255
12254
  menuKeydown(e, [...menu.querySelectorAll('.pane-menu-item')], closePaneMenu);
12256
12255
  }
12257
12256
 
12257
+ async function deleteBoardTasks(onlyCompleted) {
12258
+ const sid = paneSessionId();
12259
+ if (!sid) return;
12260
+ const count = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed').length;
12261
+ if (!count) {
12262
+ showToast(onlyCompleted ? 'No completed tasks' : 'No tasks', 'info');
12263
+ return;
12264
+ }
12265
+ const noun = `${count} ${onlyCompleted ? 'completed ' : ''}task${count === 1 ? '' : 's'}`;
12266
+ const ok = await confirmModal({
12267
+ title: onlyCompleted ? 'Delete Completed Tasks' : 'Delete All Tasks',
12268
+ message: `Delete ${noun}? This cannot be undone.`,
12269
+ okLabel: 'Delete',
12270
+ });
12271
+ if (!ok) return;
12272
+ const q = onlyCompleted ? '?status=completed' : '';
12273
+ const res = await paneRequest('DELETE', `/api/tasks/${encodeURIComponent(sid)}${q}`);
12274
+ if (res.error) {
12275
+ showToast(`Failed to delete tasks: ${res.error}`, 'error');
12276
+ return;
12277
+ }
12278
+ if (detailPanel.classList.contains('visible')) closeDetailPanel();
12279
+ showToast(`Deleted ${res.deleted.length} task${res.deleted.length === 1 ? '' : 's'}`, 'success');
12280
+ await refreshCurrentView();
12281
+ }
12282
+
12283
+ const paneMenuItem = (act, label) =>
12284
+ `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
12285
+
12258
12286
  function openPaneMenu(x, y, id) {
12287
+ if (id === 'board') {
12288
+ showPaneMenu(
12289
+ x,
12290
+ y,
12291
+ id,
12292
+ 'Board actions',
12293
+ '<div class="pane-menu-label">Board</div>' +
12294
+ paneMenuItem('deleteCompleted', 'Delete completed tasks') +
12295
+ paneMenuItem('deleteAll', 'Delete all tasks'),
12296
+ );
12297
+ return;
12298
+ }
12259
12299
  const pane = paneById(id);
12260
12300
  if (!pane) return;
12301
+ const url = pane.kind === 'url';
12302
+ showPaneMenu(
12303
+ x,
12304
+ y,
12305
+ pane.id,
12306
+ 'Pane actions',
12307
+ `<div class="pane-menu-label" title="${escapeHtml(pane.target)}">${escapeHtml(pane.target)}</div>` +
12308
+ paneMenuItem('copy', url ? 'Copy URL' : 'Copy path') +
12309
+ paneMenuItem('open', url ? 'Open in new tab' : 'Open in preview') +
12310
+ paneMenuItem('reload', 'Reload') +
12311
+ paneMenuItem('close', 'Close pane'),
12312
+ );
12313
+ }
12314
+
12315
+ function showPaneMenu(x, y, id, ariaLabel, html) {
12261
12316
  closePaneMenu();
12262
12317
  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
12318
  const menu = document.createElement('div');
12267
12319
  menu.id = 'pane-menu';
12268
12320
  menu.className = 'pane-menu';
12269
12321
  menu.setAttribute('role', 'menu');
12270
- menu.setAttribute('aria-label', 'Pane actions');
12271
- menu.dataset.pane = pane.id;
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');
12322
+ menu.setAttribute('aria-label', ariaLabel);
12323
+ menu.dataset.pane = id;
12324
+ menu.innerHTML = html;
12278
12325
  placeMenu(menu, x, y);
12279
12326
  document.addEventListener('keydown', paneMenuKeydown, true);
12280
12327
  menu.querySelector('.pane-menu-item').focus();
@@ -12285,6 +12332,8 @@ const PANE_MENU_ACTIONS = {
12285
12332
  open: openPaneExternally,
12286
12333
  reload: reloadPane,
12287
12334
  close: closePane,
12335
+ deleteCompleted: () => deleteBoardTasks(true),
12336
+ deleteAll: () => deleteBoardTasks(false),
12288
12337
  };
12289
12338
 
12290
12339
  // The server takes only absolute paths, and linked docs can be relative to the session's folder.
@@ -12396,7 +12445,7 @@ paneStrip.addEventListener('auxclick', (e) => {
12396
12445
  });
12397
12446
  paneStrip.addEventListener('contextmenu', (e) => {
12398
12447
  const tab = e.target.closest('.pane-tab');
12399
- if (!tab || tab.dataset.pane === 'board') return;
12448
+ if (!tab) return;
12400
12449
  e.preventDefault();
12401
12450
  openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
12402
12451
  });
package/public/style.css CHANGED
@@ -2929,6 +2929,22 @@ code.language-tree .hljs-title {
2929
2929
  letter-spacing: 0;
2930
2930
  }
2931
2931
 
2932
+ .task-owner-link {
2933
+ border: none;
2934
+ font-family: inherit;
2935
+ cursor: pointer;
2936
+ }
2937
+
2938
+ .task-owner-link:hover {
2939
+ text-decoration: underline;
2940
+ }
2941
+
2942
+ .task-owner-link svg {
2943
+ width: 10px;
2944
+ height: 10px;
2945
+ flex-shrink: 0;
2946
+ }
2947
+
2932
2948
  /* #endregion */
2933
2949
 
2934
2950
  /* #region TEAM_MODAL */
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 sessionPath = taskDirFor(sessionId);
3586
- const taskPath = path.join(sessionPath, `${taskId}.json`);
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