claude-code-kanban 4.27.0 → 4.28.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/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const express = require('express');
5
5
  const path = require('node:path');
6
6
  const fs = require('node:fs').promises;
7
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync } = require('node:fs');
7
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
8
8
  const _readline = require('node:readline');
9
9
  const chokidar = require('chokidar');
10
10
  const os = require('node:os');
@@ -37,7 +37,11 @@ const {
37
37
  } = require('./lib/parsers');
38
38
  const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
39
39
  const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
40
- const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
40
+ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
41
+ const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
42
+ const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
43
+ const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
44
+ const { pickFolder } = require('./lib/folder-dialog');
41
45
 
42
46
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
43
47
  const { runInstall, runUninstall } = require("./install");
@@ -77,7 +81,9 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
77
81
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
78
82
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
79
83
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
84
+ const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
80
85
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
86
+ const TERMINAL_TOKEN_FILE = path.join(CCK_DIR, 'terminal-token.json');
81
87
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
82
88
  const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
83
89
 
@@ -93,11 +99,11 @@ function readPins() {
93
99
  return {};
94
100
  }
95
101
 
96
- function writeJsonAtomic(file, obj) {
102
+ function writeJsonAtomic(file, obj, mode) {
97
103
  try {
98
104
  mkdirSync(CCK_DIR, { recursive: true });
99
105
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
100
- writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
106
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
101
107
  renameSync(tmp, file);
102
108
  } catch (e) {
103
109
  console.error(`Failed to write ${path.basename(file)}:`, e.message);
@@ -113,6 +119,14 @@ function writePins(pins) {
113
119
  // a crashed one.
114
120
  function writeServerInfo(port) {
115
121
  writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
122
+ writeTerminalToken();
123
+ }
124
+
125
+ // For `dispatch start`: a local process as the same user can already run claude itself,
126
+ // so handing it the token adds little. A browser still cannot read the file.
127
+ function writeTerminalToken() {
128
+ if (terminal.unavailableReason()) return;
129
+ writeJsonAtomic(TERMINAL_TOKEN_FILE, { pid: process.pid, token: terminal.token }, 0o600);
116
130
  }
117
131
 
118
132
  // A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
@@ -121,10 +135,22 @@ function writeServerInfo(port) {
121
135
  // the way out. Only when the file is still ours: a newer server on the same config
122
136
  // dir has already claimed it.
123
137
  function removeServerInfo() {
138
+ for (const file of [SERVER_INFO_FILE, TERMINAL_TOKEN_FILE]) {
139
+ try {
140
+ const info = JSON.parse(readFileSync(file, 'utf8'));
141
+ if (info.pid === process.pid) unlinkSync(file);
142
+ } catch (_) { /* gone, unreadable, or not ours */ }
143
+ }
144
+ }
145
+
146
+ // A second server on the same config dir (a test hub, say) takes the beacon and removes it on
147
+ // exit, which leaves this live server undiscoverable. A newer live owner keeps it.
148
+ function reclaimServerInfo(port) {
124
149
  try {
125
- const info = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
126
- if (info.pid === process.pid) unlinkSync(SERVER_INFO_FILE);
127
- } catch (_) { /* gone, unreadable, or not ours */ }
150
+ const { pid } = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
151
+ if (pid === process.pid || isPidAlive(pid)) return;
152
+ } catch (_) { /* missing or unreadable beacon */ }
153
+ writeServerInfo(port);
128
154
  }
129
155
 
130
156
  process.on('exit', removeServerInfo);
@@ -422,9 +448,9 @@ let liveSessionsCache = null;
422
448
  let lastLiveSessionsScan = 0;
423
449
  const LIVE_SESSIONS_TTL = 5000;
424
450
 
425
- function loadLiveSessions() {
451
+ function loadLiveSessions(fresh = false) {
426
452
  const now = Date.now();
427
- if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
453
+ if (!fresh && liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
428
454
  const sessions = [];
429
455
  if (existsSync(SESSIONS_DIR)) {
430
456
  try {
@@ -432,7 +458,7 @@ function loadLiveSessions() {
432
458
  try {
433
459
  const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
434
460
  if (s?.sessionId && s.kind === 'interactive') {
435
- sessions.push({ sessionId: s.sessionId, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
461
+ sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
436
462
  }
437
463
  } catch (_) { /* skip invalid */ }
438
464
  }
@@ -453,6 +479,19 @@ function isRegistryIdle(sessionId) {
453
479
  return live?.status === 'idle';
454
480
  }
455
481
 
482
+ // A registry file outlives a crashed claude, so the pid is probed rather than trusted.
483
+ function isSessionProcessAlive(sessionId, exceptPid = null) {
484
+ return loadLiveSessions().some((s) => {
485
+ if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
486
+ return isPidAlive(s.pid);
487
+ });
488
+ }
489
+
490
+ // EPERM means the process exists but belongs to someone else.
491
+ function isPidAlive(pid) {
492
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
493
+ }
494
+
456
495
  function hasRecentLogActivity(sessionId, logAge) {
457
496
  return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
458
497
  }
@@ -1165,6 +1204,7 @@ app.get('/api/sessions', async (req, res) => {
1165
1204
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
1166
1205
  for (const id of includeIds) pinnedIds.add(id);
1167
1206
  const activeFilter = req.query.filter === 'active';
1207
+ const terminalIds = activeFilter ? new Set(terminal.list().map((t) => t.id)) : new Set();
1168
1208
 
1169
1209
  const metadata = loadSessionMetadata();
1170
1210
  const sessionsMap = new Map();
@@ -1194,7 +1234,7 @@ app.get('/api/sessions', async (req, res) => {
1194
1234
 
1195
1235
  // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
1196
1236
  // Mirrors the post-filter predicate using only signals already computed above.
1197
- if (activeFilter && !pinnedIds.has(entry.name)) {
1237
+ if (activeFilter && !pinnedIds.has(entry.name) && !terminalIds.has(entry.name)) {
1198
1238
  const cheaplyActive = logStat.hasMessages && (
1199
1239
  hasVisibleLogActivity(entry.name, logAge)
1200
1240
  || agentStatus.hasActive
@@ -1294,7 +1334,7 @@ app.get('/api/sessions', async (req, res) => {
1294
1334
  const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
1295
1335
 
1296
1336
  // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
1297
- if (activeFilter && !pinnedIds.has(sessionId)) {
1337
+ if (activeFilter && !pinnedIds.has(sessionId) && !terminalIds.has(sessionId)) {
1298
1338
  const cheaplyActive = logStat.hasMessages && (
1299
1339
  hasVisibleLogActivity(sessionId, logAge) || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
1300
1340
  );
@@ -1495,7 +1535,7 @@ app.get('/api/sessions', async (req, res) => {
1495
1535
  || s.hasRecentActivity
1496
1536
  );
1497
1537
  for (const [id, s] of sessionsMap) {
1498
- if (pinnedIds.has(id)) continue;
1538
+ if (pinnedIds.has(id) || terminalIds.has(id)) continue;
1499
1539
  if (!isActive(s)) sessionsMap.delete(id);
1500
1540
  }
1501
1541
  }
@@ -1522,13 +1562,22 @@ app.get('/api/sessions', async (req, res) => {
1522
1562
  sessions = [...top, ...missingPinned];
1523
1563
  }
1524
1564
 
1525
- res.json(sessions);
1565
+ res.json(withDispatchPlacement(sessions));
1526
1566
  } catch (error) {
1527
1567
  console.error('Error listing sessions:', error);
1528
1568
  res.status(500).json({ error: 'Failed to list sessions' });
1529
1569
  }
1530
1570
  });
1531
1571
 
1572
+ // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
1573
+ const TEMP_ROOT = (() => {
1574
+ try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
1575
+ })();
1576
+ function isTempPath(p) {
1577
+ const rel = path.relative(TEMP_ROOT, p);
1578
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
1579
+ }
1580
+
1532
1581
  // API: Get distinct project paths with last-modified timestamps
1533
1582
  app.get('/api/projects', (_req, res) => {
1534
1583
  res.setHeader('Cache-Control', 'no-store');
@@ -1542,7 +1591,11 @@ app.get('/api/projects', (_req, res) => {
1542
1591
  }
1543
1592
  }
1544
1593
  const projects = Object.entries(projectMap)
1545
- .map(([path, mtime]) => ({ path, modifiedAt: mtime ? new Date(mtime).toISOString() : null }))
1594
+ .map(([path, mtime]) => ({
1595
+ path,
1596
+ modifiedAt: mtime ? new Date(mtime).toISOString() : null,
1597
+ ...(isTempPath(path) && { temp: true }),
1598
+ }))
1546
1599
  .sort((a, b) => a.path.localeCompare(b.path));
1547
1600
  res.json(projects);
1548
1601
  });
@@ -3074,9 +3127,169 @@ app.get('/api/config', (_req, res) => {
3074
3127
  costUrl: COST_URL,
3075
3128
  memoryUrl: MEMORY_URL,
3076
3129
  scratchAvailable: !!whichSync('scratch'),
3130
+ terminal: terminal.clientConfig(),
3131
+ });
3132
+ });
3133
+
3134
+ // #endregion
3135
+
3136
+ // #region TERMINAL
3137
+ // A new session may start only in a folder the user has already worked in, or one they
3138
+ // chose in the native dialog during this run. Anything else would let a page script pick
3139
+ // the directory claude runs in.
3140
+ const pickedFolders = new Set();
3141
+ function isAllowedFolder(dir) {
3142
+ const known = pickedFolders.has(dir) || Object.values(loadSessionMetadata()).some((m) => m.project === dir);
3143
+ try { return known && statSync(dir).isDirectory(); } catch { return false; }
3144
+ }
3145
+
3146
+ const terminal = createTerminalService({
3147
+ config: readTerminalConfig({ getArgValue }),
3148
+ net,
3149
+ claudeDir: CLAUDE_DIR,
3150
+ isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
3151
+ token: process.env.CCK_TERMINAL_TOKEN,
3152
+ which: whichSync,
3153
+ isLiveElsewhere: isSessionProcessAlive,
3154
+ // The project, not the last cwd: `claude --resume` finds a session under the
3155
+ // project dir it started in, and cwd drifts into subdirectories.
3156
+ resolveCwd: (id) => {
3157
+ const meta = loadSessionMetadata()[id];
3158
+ return meta ? meta.project || meta.cwd || null : null;
3159
+ },
3160
+ isAllowedFolder,
3161
+ liveSessions: () => loadLiveSessions(true),
3162
+ onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
3163
+ onExit: (id) => dispatches.sessionExited(id),
3164
+ });
3165
+
3166
+ let folderDialogOpen = false;
3167
+ app.post('/api/terminal/pick-folder', async (req, res) => {
3168
+ const reason = terminal.unavailableReason();
3169
+ if (reason) return res.status(403).json({ error: reason });
3170
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3171
+ if (folderDialogOpen) return res.status(409).json({ error: 'a folder dialog is already open' });
3172
+ folderDialogOpen = true;
3173
+ try {
3174
+ const dir = await pickFolder(whichSync, { start: req.body?.start });
3175
+ if (dir) pickedFolders.add(dir);
3176
+ res.json({ path: dir });
3177
+ } catch (e) {
3178
+ res.status(501).json({ error: e.message });
3179
+ } finally {
3180
+ folderDialogOpen = false;
3181
+ }
3182
+ });
3183
+
3184
+ // The hub's eviction check reads this to keep a pool with live PTYs alive.
3185
+ app.get('/api/terminals', (_req, res) => {
3186
+ res.setHeader('Cache-Control', 'no-store');
3187
+ res.json({ sessions: terminal.list() });
3188
+ });
3189
+
3190
+ app.delete('/api/terminals/:id', (req, res) => {
3191
+ const err = terminal.end(req.params.id, req.get('x-terminal-token'));
3192
+ if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
3193
+ if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
3194
+ res.status(204).end();
3195
+ });
3196
+
3197
+ // Served from node_modules, never a CDN: any script on this page can use the token.
3198
+ const XTERM_FILES = {
3199
+ 'xterm.js': '@xterm/xterm/lib/xterm.js',
3200
+ 'xterm.css': '@xterm/xterm/css/xterm.css',
3201
+ 'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
3202
+ 'addon-webgl.js': '@xterm/addon-webgl/lib/addon-webgl.js',
3203
+ 'addon-unicode11.js': '@xterm/addon-unicode11/lib/addon-unicode11.js',
3204
+ };
3205
+ app.get('/vendor/xterm/:file', (req, res) => {
3206
+ const rel = Object.hasOwn(XTERM_FILES, req.params.file) ? XTERM_FILES[req.params.file] : null;
3207
+ if (!rel) return res.status(404).end();
3208
+ let file;
3209
+ try { file = require.resolve(rel); } catch { return res.status(404).end(); }
3210
+ res.setHeader('Cache-Control', 'public, max-age=86400');
3211
+ res.sendFile(file);
3212
+ });
3213
+ // #endregion
3214
+
3215
+ // #region DISPATCH
3216
+ const dispatches = createDispatchRegistry({
3217
+ onSettle: (r) => {
3218
+ if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
3219
+ broadcast({ type: 'dispatch-update' });
3220
+ },
3221
+ });
3222
+
3223
+ const dispatchGroups = createGroupStore({
3224
+ load: () => {
3225
+ try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
3226
+ },
3227
+ save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
3228
+ isAlive: (id) => terminal.isRunning(id) || isSessionProcessAlive(id),
3229
+ pinnedIds: () => new Set(Object.keys(readPins())),
3230
+ });
3231
+
3232
+ // The board places a session from these alone, so it never has to move it later.
3233
+ // `startedBy` lets it follow a starter the user put in a named group, which only the
3234
+ // browser knows; it goes once the dispatch settles.
3235
+ function withDispatchPlacement(sessions) {
3236
+ const groups = dispatchGroups.snapshot();
3237
+ const starters = new Map(
3238
+ dispatches.list().filter((r) => r.status === 'running' && r.parent && r.session).map((r) => [r.session, r.parent]),
3239
+ );
3240
+ if (!groups.size && !starters.size) return sessions;
3241
+ return sessions.map((s) => {
3242
+ const dispatchGroup = groups.get(s.id);
3243
+ const startedBy = starters.get(s.id);
3244
+ return dispatchGroup || startedBy ? { ...s, dispatchGroup, startedBy } : s;
3077
3245
  });
3246
+ }
3247
+
3248
+ // Starting a session needs the terminal token, as the browser does; the CLI reads it from
3249
+ // TERMINAL_TOKEN_FILE. The child gets only its dispatch capability through the preamble.
3250
+ app.post('/api/dispatch', (req, res) => {
3251
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3252
+ const { cwd, spec, name, model, worktree, parent, group, report, peer } = req.body || {};
3253
+ if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3254
+ if (peer != null && !isPeerName(peer)) return res.status(400).json({ error: 'peer must be a peer name as ListAgents prints it' });
3255
+ if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
3256
+ if (group != null && !isGroupName(group)) {
3257
+ return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3258
+ }
3259
+ const starterGroup = parent ? dispatchGroups.groupOf(parent) : null;
3260
+ const target = group || starterGroup;
3261
+ const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
3262
+ const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
3263
+ const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
3264
+ if (started.error) {
3265
+ dispatches.discard(r.id);
3266
+ return res.status(started.status).json({ error: started.error });
3267
+ }
3268
+ dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
3269
+ // A starter already in a group stays where it is: moving it would jump it under the user.
3270
+ if (target) dispatchGroups.join(target, [starterGroup ? null : parent, started.id]);
3271
+ broadcast({ type: 'dispatch-update' });
3272
+ res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
3078
3273
  });
3079
3274
 
3275
+ app.post('/api/dispatch/:id/done', (req, res) => {
3276
+ const { cap, outcome, summary } = req.body || {};
3277
+ const err = dispatches.settle(req.params.id, cap, outcome, summary);
3278
+ if (err) return res.status(err.status).json({ error: err.error });
3279
+ res.status(204).end();
3280
+ });
3281
+
3282
+ app.get('/api/dispatch', async (req, res) => {
3283
+ res.setHeader('Cache-Control', 'no-store');
3284
+ const ids = typeof req.query.ids === 'string' && req.query.ids ? req.query.ids.split(',') : null;
3285
+ const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
3286
+ // res, not req: a GET's request stream can close before the response is sent.
3287
+ const out = await dispatches.wait({ ids, parent }, req.query.wait, (stop) => {
3288
+ res.on('close', stop);
3289
+ return () => res.off('close', stop);
3290
+ });
3291
+ if (!res.writableEnded) res.json(out);
3292
+ });
3080
3293
  // #endregion
3081
3294
 
3082
3295
  // #region TASK_ROUTES
@@ -3134,7 +3347,7 @@ app.get('/api/tasks/all', async (_req, res) => {
3134
3347
  }
3135
3348
  });
3136
3349
 
3137
- const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents } = require('./lib/session-events');
3350
+ const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents, topicKey } = require('./lib/session-events');
3138
3351
  app.get('/api/sessions/:sessionId/events', handleSessionEvents);
3139
3352
 
3140
3353
  // API: Create a task
@@ -3891,9 +4104,16 @@ async function prewarmCaches() {
3891
4104
  // The port is configurable and falls back to a random one when taken, so the postman
3892
4105
  // monitor cannot assume it -- publish the live one where it can read it.
3893
4106
  writeServerInfo(actualPort);
4107
+ setInterval(() => reclaimServerInfo(actualPort), 30000).unref();
3894
4108
  migrateLegacyApprovalsConfig();
3895
4109
  const warning = net.exposureWarning();
3896
4110
  if (warning) console.log(warning);
4111
+ const terminalReason = terminal.unavailableReason();
4112
+ if (terminalReason && terminalReason !== 'disabled') console.log(`Terminal unavailable: ${terminalReason}`);
4113
+ // Under the hub the hub owns the token and hands it to the iframe itself.
4114
+ if (!terminalReason && !process.env.CLAUDE_HUB) {
4115
+ console.log(`Terminal enabled - open http://localhost:${actualPort}/#t=${terminal.token}`);
4116
+ }
3897
4117
 
3898
4118
  if (process.argv.includes('--open')) {
3899
4119
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
@@ -3901,12 +4121,14 @@ async function prewarmCaches() {
3901
4121
  setImmediate(prewarmCaches);
3902
4122
  };
3903
4123
 
3904
- const server = net.listenLoopback(app, PORT, onReady);
4124
+ const listenOpts = { onUpgrade: terminal.handleUpgrade };
4125
+ const server = net.listenLoopback(app, PORT, onReady, listenOpts);
4126
+ process.on('exit', terminal.shutdown);
3905
4127
 
3906
4128
  server.on('error', (err) => {
3907
4129
  if (err.code === 'EADDRINUSE') {
3908
4130
  console.log(`Port ${PORT} in use, trying random port...`);
3909
- net.listenLoopback(app, 0, onReady);
4131
+ net.listenLoopback(app, 0, onReady, listenOpts);
3910
4132
  } else {
3911
4133
  throw err;
3912
4134
  }
@@ -0,0 +1,72 @@
1
+ # Dispatch guide
2
+
3
+ A dispatch is one Claude Code session that cck starts for a task, in its embedded terminal. It is an ordinary session, not a child: it shows in the sidebar like any other, and the user can open its terminal at any time.
4
+
5
+ A dispatch is **fire-and-forget** by default: you hand the task off, and the user watches it in the sidebar. Add `--report` only when you need the outcome back: the user asked you to collect it, or your next step depends on it.
6
+
7
+ ## Write the spec
8
+
9
+ The started session sees only the spec, not this conversation, so every spec is self-contained. Name:
10
+
11
+ - **Target:** the files, component, or environment in scope.
12
+ - **Change:** the concrete result to produce.
13
+ - **Constraints:** invariants and do-not-touch boundaries.
14
+ - **Ownership:** what it may edit. Two dispatches edit the same files only when each runs in its own `--worktree`.
15
+ - **Acceptance:** the test, output, or evidence that proves it is done.
16
+
17
+ Dispatch when the task can run on its own. Do the work yourself when it is small or needs context from this conversation that you cannot write down.
18
+
19
+ ## Start
20
+
21
+ ```bash
22
+ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> [--report] [--model haiku|sonnet|opus|fable] [--worktree [name]] --json
23
+ ```
24
+
25
+ - `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it whenever you have the `ListAgents` tool. cck then tells the started session to ask you with `SendMessage` instead of failing on a question. See [Peer](#peer).
26
+ - `--cwd` must be a project cck already knows (default: the current dir).
27
+ - `--spec-file` over `--spec` for anything longer than a line: no shell quoting.
28
+ - `--name` is what the user sees in the sidebar. Kebab-case, saying what the session does: `fix-login-redirect`, not `task-1`.
29
+ - `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session and this session together under one sidebar group. Pass it on your first dispatch; later dispatches join the same group without it. A group goes away when its sessions end, unless the user pins a member or keeps the group.
30
+ - The result holds the `dispatch` id and the `session` id.
31
+
32
+ ## Fire-and-forget
33
+
34
+ Tell the user the session name, its group, and the dispatch id, then carry on with your own work or end your turn. The user follows the dispatch in the sidebar. With `--peer`, its questions still reach you as new turns.
35
+
36
+ ## With `--report`
37
+
38
+ The started session settles with one report, `succeeded` or `failed`, or as `exited` when its terminal ends first. Start every independent dispatch first, then collect. Two channels, use either or both:
39
+
40
+ - **Inbox:** this skill armed it. Lines `cck:1 dispatch.<status> <id> ...` arrive on their own while you keep working.
41
+ - **Wait:** block until one settles.
42
+
43
+ ```bash
44
+ claude-code-kanban dispatch wait [<id>...] --timeout 15m --json
45
+ ```
46
+
47
+ It returns `settled`, `running`, and `timeout`, as soon as any watched dispatch settles; call it again with the ids still running. A timeout is a checkpoint: the session may still be working. Look before you act:
48
+
49
+ ```bash
50
+ claude-code-kanban dispatch list --json
51
+ claude-code-kanban session peek <session-id> --limit 20
52
+ ```
53
+
54
+ A dispatch still `running` is still working; retry only after a `failed` report or an `exited` one.
55
+
56
+ The summary is the started session's own claim. Verify it (run the tests, read the diff), then give the user each dispatch's outcome, the summary, and what you checked. Done when every `--report` dispatch has settled and each summary is verified.
57
+
58
+ ## Peer
59
+
60
+ A dispatch is a Claude Code peer under its `--name`, so `SendMessage` reaches it and it reaches you. The peer channel carries the conversation. The report carries the record: only `dispatch done` settles a dispatch, ends `dispatch wait`, and shows in the sidebar.
61
+
62
+ - **Answer questions.** A question or a finding from the dispatch arrives as a new turn. Answer it yourself, or ask the user when the decision is theirs, then send the answer back.
63
+ - **Steer.** Send a short, self-contained message to the dispatch's name. It arrives between the receiver's steps, never inside a subagent or a running workflow.
64
+ - **Limits.** A session in another permission mode can hold a message until its user approves it, so anything the result depends on goes in the report. A dispatch that restarts ends as `exited` and cannot report, so its result comes back as a message.
65
+
66
+ ## If you are the started session
67
+
68
+ Your prompt begins with `[cck dispatch <id>]` and holds your instructions: the peer to ask, and with a report the exact `dispatch done` command. Follow them. Without that line, the prompt is the task alone.
69
+
70
+ - Ask the peer with `SendMessage` when you need a decision or find something that changes the task, and keep working on what does not depend on the answer.
71
+ - The summary is three sentences: what changed, what you found, what remains. Use `--summary-file` if it needs quotes.
72
+ - After you report, a message from the peer is a new request: answer it.