claude-code-kanban 5.4.0 → 6.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/public/index.html CHANGED
@@ -52,7 +52,7 @@
52
52
  </div>
53
53
  <h2>Server Not Running</h2>
54
54
  <p>The dashboard needs the Claude Code Kanban server to stream live task data. Start it with:</p>
55
- <code>npx claude-code-kanban</code>
55
+ <code>claude-code-kanban</code>
56
56
  <button class="btn btn-primary" onclick="location.reload()">Retry Connection</button>
57
57
  <div class="offline-status" id="offline-status" aria-live="polite"></div>
58
58
  </div>
@@ -691,8 +691,18 @@
691
691
  </div>
692
692
  <div id="scratchpad-modal" class="modal-overlay" onclick="closeScratchpad()">
693
693
  <div class="modal scratchpad-modal" onclick="event.stopPropagation()">
694
- <div class="modal-header">
695
- <h3 class="modal-title">Scratchpad</h3>
694
+ <div class="modal-header scratchpad-header">
695
+ <div class="scratchpad-title-row">
696
+ <button id="scratchpad-emoji-btn" class="scratchpad-emoji-btn" type="button" onclick="toggleScratchpadEmojiPicker()"></button>
697
+ <h3 class="modal-title">Scratchpad</h3>
698
+ </div>
699
+ <div id="scratchpad-emoji-picker" class="scratchpad-emoji-picker" tabindex="-1">
700
+ <div id="scratchpad-emoji-grid" class="scratchpad-emoji-grid"></div>
701
+ <div class="scratchpad-emoji-row">
702
+ <input id="scratchpad-emoji-input" class="form-input" maxlength="16" placeholder="Paste" aria-label="Custom emoji">
703
+ <button type="button" class="btn btn-secondary" onclick="setScratchpadEmoji('')">Clear</button>
704
+ </div>
705
+ </div>
696
706
  <button class="modal-close" aria-label="Close dialog" onclick="closeScratchpad()">
697
707
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
698
708
  <path d="M18 6L6 18M6 6l12 12"/>
@@ -808,6 +818,7 @@
808
818
  <div id="ns-error" class="ns-error" hidden></div>
809
819
  </div>
810
820
  <div class="modal-footer ns-footer">
821
+ <label class="ns-check ns-stay" title="Start in the background and keep the current view"><input type="checkbox" id="ns-stay"> Stay here</label>
811
822
  <button type="button" class="btn btn-secondary" onclick="closeNewSession()">Cancel</button>
812
823
  <button type="button" id="ns-start" class="btn btn-primary" onclick="startNewSession()" disabled>Start</button>
813
824
  </div>
@@ -23,7 +23,12 @@ const projectMatch = (() => {
23
23
  return p === q || (!!repo && normalizeProjectPath(repo) === q);
24
24
  };
25
25
  }
26
- return { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher };
26
+ // The project a session is listed under. A worktree transcript is filed under the worktree
27
+ // path, so `session.project` alone would show each worktree as a project of its own.
28
+ function sessionProjectKey(session) {
29
+ return session?.worktree?.repo || session?.project || null;
30
+ }
31
+ return { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey };
27
32
  })();
28
33
 
29
34
  if (typeof module === 'object' && module.exports) module.exports = projectMatch;
package/public/style.css CHANGED
@@ -6861,6 +6861,17 @@ select.form-input option:checked {
6861
6861
 
6862
6862
  .ns-footer {
6863
6863
  align-items: center;
6864
+ margin-top: 16px;
6865
+ border-top: 1px solid var(--border);
6866
+ }
6867
+
6868
+ .ns-stay {
6869
+ margin-right: auto;
6870
+ color: var(--text-tertiary);
6871
+ }
6872
+
6873
+ .new-session-modal.ns-resume .ns-stay {
6874
+ visibility: hidden;
6864
6875
  }
6865
6876
 
6866
6877
  .ns-footer .btn-primary:disabled {
@@ -7075,6 +7086,105 @@ select.form-input option:checked {
7075
7086
  color: var(--text-muted);
7076
7087
  align-self: center;
7077
7088
  }
7089
+
7090
+ .scratchpad-header {
7091
+ position: relative;
7092
+ }
7093
+
7094
+ .scratchpad-title-row {
7095
+ display: flex;
7096
+ align-items: center;
7097
+ gap: 10px;
7098
+ }
7099
+
7100
+ .scratchpad-emoji-btn {
7101
+ width: 32px;
7102
+ height: 32px;
7103
+ display: flex;
7104
+ align-items: center;
7105
+ justify-content: center;
7106
+ font-size: 18px;
7107
+ line-height: 1;
7108
+ color: var(--text-muted);
7109
+ background: var(--bg-elevated);
7110
+ border: 1px dashed var(--border);
7111
+ border-radius: 8px;
7112
+ cursor: pointer;
7113
+ }
7114
+
7115
+ .scratchpad-emoji-btn.set {
7116
+ border-style: solid;
7117
+ border-color: var(--accent);
7118
+ }
7119
+
7120
+ .scratchpad-emoji-btn:hover {
7121
+ border-color: var(--accent);
7122
+ }
7123
+
7124
+ .scratchpad-emoji-btn[hidden] {
7125
+ display: none;
7126
+ }
7127
+
7128
+ .scratchpad-emoji-picker {
7129
+ display: none;
7130
+ position: absolute;
7131
+ top: 40px;
7132
+ left: 0;
7133
+ z-index: 5;
7134
+ width: 300px;
7135
+ padding: 10px;
7136
+ background: var(--bg-elevated);
7137
+ border: 1px solid var(--border);
7138
+ border-radius: 10px;
7139
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
7140
+ outline: none;
7141
+ }
7142
+
7143
+ .scratchpad-emoji-picker.open {
7144
+ display: block;
7145
+ }
7146
+
7147
+ .scratchpad-emoji-grid {
7148
+ display: grid;
7149
+ gap: 2px;
7150
+ }
7151
+
7152
+ .scratchpad-emoji-grid button {
7153
+ padding: 4px 0;
7154
+ font-size: 18px;
7155
+ background: none;
7156
+ border: 0;
7157
+ border-radius: 6px;
7158
+ cursor: pointer;
7159
+ }
7160
+
7161
+ .scratchpad-emoji-grid button:hover,
7162
+ .scratchpad-emoji-grid button.kb {
7163
+ background: var(--bg-hover);
7164
+ }
7165
+
7166
+ .scratchpad-emoji-grid button.cur {
7167
+ background: var(--accent-dim);
7168
+ }
7169
+
7170
+ .scratchpad-emoji-row {
7171
+ display: flex;
7172
+ gap: 6px;
7173
+ margin-top: 8px;
7174
+ }
7175
+
7176
+ .scratchpad-emoji-row .form-input {
7177
+ flex: 1;
7178
+ min-width: 0;
7179
+ padding: 5px 8px;
7180
+ font-size: 13px;
7181
+ }
7182
+
7183
+ .scratchpad-badge.has-emoji {
7184
+ font-size: 13px;
7185
+ line-height: 18px;
7186
+ padding: 0 5px;
7187
+ }
7078
7188
  /* #endregion */
7079
7189
 
7080
7190
  /* #region STORAGE_MANAGER */
package/server.js CHANGED
@@ -41,13 +41,15 @@ const {
41
41
  const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
42
42
  const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
43
43
  const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
44
- const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
44
+ const { readTerminalConfig } = require('./lib/terminal');
45
+ const { createTerminalClient } = require('./lib/terminal-client');
46
+ const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
45
47
  const { createProcStats } = require('./lib/proc-stats');
46
- const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
48
+ const { createDispatchRegistry } = require('./lib/dispatch');
47
49
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
48
50
  const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
49
51
  const { createWorktreeStore } = require('./lib/worktrees');
50
- const { readGitBranch } = require('./lib/git-branch');
52
+ const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
51
53
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
52
54
  const { pickFolder } = require('./lib/folder-dialog');
53
55
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
@@ -301,7 +303,11 @@ function getContextStatus(sessionId, meta) {
301
303
 
302
304
  function getContextFields(sessionId, meta) {
303
305
  const contextStatus = getContextStatus(sessionId, meta);
304
- return { contextStatus, autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null };
306
+ return {
307
+ contextStatus,
308
+ autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null,
309
+ cacheTtl: meta?.cacheTtl || null,
310
+ };
305
311
  }
306
312
 
307
313
  function isAgentFresh(agent) {
@@ -343,15 +349,6 @@ const worktrees = createWorktreeStore({
343
349
  save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
344
350
  });
345
351
 
346
- // Only look up the branch when cwd has diverged from the launch project — that's
347
- // the only case the JSONL value is wrong.
348
- function resolveSessionGitBranch(meta) {
349
- if (meta.cwd && meta.project && meta.cwd !== meta.project) {
350
- return getGitBranch(meta.cwd) || meta.gitBranch || null;
351
- }
352
- return meta.gitBranch || null;
353
- }
354
-
355
352
  function getSessionLogStat(meta) {
356
353
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
357
354
  try {
@@ -476,25 +473,12 @@ let liveSessionsCache = null;
476
473
  let lastLiveSessionsScan = 0;
477
474
  const LIVE_SESSIONS_TTL = 5000;
478
475
 
479
- function loadLiveSessions(fresh = false) {
476
+ function loadLiveSessions() {
480
477
  const now = Date.now();
481
- if (!fresh && liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
482
- const sessions = [];
483
- if (existsSync(SESSIONS_DIR)) {
484
- try {
485
- for (const file of readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
486
- try {
487
- const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
488
- if (s?.sessionId && s.kind === 'interactive') {
489
- sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null, name: s.name || null });
490
- }
491
- } catch (_) { /* skip invalid */ }
492
- }
493
- } catch (_) { /* ignore */ }
494
- }
495
- liveSessionsCache = sessions;
478
+ if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
479
+ liveSessionsCache = readLiveSessions(SESSIONS_DIR);
496
480
  lastLiveSessionsScan = now;
497
- return sessions;
481
+ return liveSessionsCache;
498
482
  }
499
483
 
500
484
  // An open-but-idle interactive session keeps touching its JSONL (metadata-line
@@ -513,19 +497,6 @@ function getPeerName(sessionId) {
513
497
  return live?.name ?? null;
514
498
  }
515
499
 
516
- // A registry file outlives a crashed claude, so the pid is probed rather than trusted.
517
- function isSessionProcessAlive(sessionId, exceptPid = null) {
518
- return loadLiveSessions().some((s) => {
519
- if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
520
- return isPidAlive(s.pid);
521
- });
522
- }
523
-
524
- // EPERM means the process exists but belongs to someone else.
525
- function isPidAlive(pid) {
526
- try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
527
- }
528
-
529
500
  function hasRecentLogActivity(sessionId, logAge) {
530
501
  return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
531
502
  }
@@ -856,6 +827,7 @@ function refreshSessionMetadataPath(jsonlPath) {
856
827
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
857
828
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
858
829
  existing.permissionMode = info.permissionMode;
830
+ existing.cacheTtl = info.cacheTtl;
859
831
  return true;
860
832
  }
861
833
 
@@ -943,7 +915,8 @@ function loadSessionMetadata() {
943
915
  jsonlPath: jsonlPath,
944
916
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
945
917
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
946
- permissionMode: sessionInfo.permissionMode
918
+ permissionMode: sessionInfo.permissionMode,
919
+ cacheTtl: sessionInfo.cacheTtl || null
947
920
  };
948
921
  sessionIds.push(sessionId);
949
922
  }
@@ -1168,6 +1141,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1168
1141
  const logStat = overrides._logStat || getSessionLogStat(meta);
1169
1142
  const logMtime = logStat.mtime;
1170
1143
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
1144
+ const worktree = worktrees.resolve(meta.project);
1171
1145
  return {
1172
1146
  id,
1173
1147
  name: getSessionDisplayName(id, meta),
@@ -1176,8 +1150,8 @@ function buildSessionObject(id, meta, overrides = {}) {
1176
1150
  project: meta.project || null,
1177
1151
  cwd: meta.cwd || null,
1178
1152
  description: meta.description || null,
1179
- gitBranch: resolveSessionGitBranch(meta),
1180
- worktree: worktrees.resolve(meta.project),
1153
+ gitBranch: sessionGitBranch(meta, !!worktree, getGitBranch),
1154
+ worktree,
1181
1155
  customTitle: meta.customTitle || null,
1182
1156
  taskCount: 0,
1183
1157
  completed: 0,
@@ -1227,7 +1201,7 @@ app.get('/api/sessions', async (req, res) => {
1227
1201
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
1228
1202
  for (const id of includeIds) pinnedIds.add(id);
1229
1203
  const activeFilter = req.query.filter === 'active';
1230
- const terminalIds = activeFilter ? new Set(terminal.list().map((t) => t.id)) : new Set();
1204
+ const terminalIds = activeFilter ? new Set(terminal.ids()) : new Set();
1231
1205
 
1232
1206
  const metadata = loadSessionMetadata();
1233
1207
  const sessionsMap = new Map();
@@ -1631,6 +1605,30 @@ app.get('/api/sessions/search', (req, res) => {
1631
1605
  res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
1632
1606
  });
1633
1607
 
1608
+ app.get('/api/sessions/known', (_req, res) => {
1609
+ res.setHeader('Cache-Control', 'no-store');
1610
+ try {
1611
+ const known = new Map();
1612
+ for (const [id, meta] of Object.entries(loadSessionMetadata())) {
1613
+ known.set(id, { id, project: meta.project || null, name: getSessionDisplayName(id, meta) });
1614
+ }
1615
+ const add = (id, project = null) => {
1616
+ if (!known.has(id)) known.set(id, { id, project, name: null });
1617
+ };
1618
+ for (const dir of [TASKS_DIR, AGENT_ACTIVITY_DIR]) {
1619
+ if (!existsSync(dir)) continue;
1620
+ for (const d of readdirSync(dir, { withFileTypes: true })) if (d.isDirectory()) add(d.name);
1621
+ }
1622
+ for (const map of Object.values(loadAllTaskMaps().listToSessions)) {
1623
+ for (const [id, info] of Object.entries(map)) add(id, info.project || null);
1624
+ }
1625
+ res.json([...known.values()]);
1626
+ } catch (error) {
1627
+ console.error('Error listing known sessions:', error);
1628
+ res.status(500).json({ error: 'Failed to list known sessions' });
1629
+ }
1630
+ });
1631
+
1634
1632
  function isTempPath(p) {
1635
1633
  const rel = path.relative(TEMP_ROOT, p);
1636
1634
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -3256,29 +3254,34 @@ function resolveSessionFolder(id) {
3256
3254
  return meta ? meta.project || meta.cwd || null : null;
3257
3255
  }
3258
3256
 
3259
- let listenPort = null;
3260
- const terminal = createTerminalService({
3257
+ const terminal = createTerminalClient({
3261
3258
  config: readTerminalConfig({ getArgValue }),
3262
- serverUrl: () => (listenPort ? `http://127.0.0.1:${listenPort}` : null),
3263
3259
  net,
3264
3260
  claudeDir: CLAUDE_DIR,
3265
3261
  isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
3262
+ sessionsDir: SESSIONS_DIR,
3266
3263
  token: process.env.CCK_TERMINAL_TOKEN,
3267
3264
  load: () => {
3268
3265
  try { return JSON.parse(readFileSync(TERMINALS_FILE, 'utf8')); } catch { return null; }
3269
3266
  },
3270
3267
  save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
3271
- which: whichSync,
3272
- isLiveElsewhere: isSessionProcessAlive,
3273
3268
  // The project, not the last cwd: `claude --resume` finds a session under the
3274
3269
  // project dir it started in, and cwd drifts into subdirectories.
3275
3270
  resolveCwd: resolveSessionFolder,
3276
3271
  isAllowedFolder,
3277
- liveSessions: () => loadLiveSessions(true),
3278
- onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
3279
- onExit: (id) => dispatches.sessionExited(id),
3272
+ onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
3273
+ onExit: (id) => {
3274
+ if (dispatches.remove(id)) broadcast({ type: 'dispatch-update' });
3275
+ },
3280
3276
  });
3281
3277
 
3278
+ // A call to the terminal host fails with 503 while the host restarts.
3279
+ function terminalRoute(fn) {
3280
+ return (req, res) => fn(req, res).catch((e) => {
3281
+ if (!res.headersSent) res.status(e.status || 500).json({ error: e.message });
3282
+ });
3283
+ }
3284
+
3282
3285
  let folderDialogOpen = false;
3283
3286
  app.post('/api/terminal/pick-folder', async (req, res) => {
3284
3287
  const reason = terminal.unavailableReason();
@@ -3297,28 +3300,37 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3297
3300
  }
3298
3301
  });
3299
3302
 
3303
+ // The New session dialog's "Stay here": the PTY starts headless and the board keeps its view.
3304
+ app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3305
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3306
+ const { id, cwd, name, model, worktree, prompt } = req.body || {};
3307
+ const started = await terminal.startNew({ id, cwd, name, model, worktree, prompt });
3308
+ if (started.error) return res.status(started.status).json({ error: started.error });
3309
+ res.status(201).json({ session: started.id, cwd: started.cwd });
3310
+ }));
3311
+
3300
3312
  // The hub's eviction check reads this to keep a pool with live PTYs alive.
3301
- app.get('/api/terminals', (_req, res) => {
3313
+ app.get('/api/terminals', terminalRoute(async (_req, res) => {
3302
3314
  res.setHeader('Cache-Control', 'no-store');
3303
- res.json({ sessions: terminal.list() });
3304
- });
3315
+ res.json({ sessions: await terminal.sessions() });
3316
+ }));
3305
3317
 
3306
3318
  const terminalProcStats = createProcStats();
3307
- app.get('/api/terminals/stats', async (_req, res) => {
3319
+ app.get('/api/terminals/stats', terminalRoute(async (_req, res) => {
3308
3320
  res.setHeader('Cache-Control', 'no-store');
3309
- const pids = terminal.claudePids();
3310
- const byPid = await terminalProcStats([process.pid, ...Object.values(pids)]);
3321
+ const { hostPid, claudePids } = await terminal.stats();
3322
+ const byPid = await terminalProcStats([process.pid, hostPid, ...Object.values(claudePids)].filter(Boolean));
3311
3323
  const terminals = {};
3312
- for (const [id, pid] of Object.entries(pids)) if (byPid[pid]) terminals[id] = byPid[pid];
3313
- res.json({ cck: byPid[process.pid] || null, terminals });
3314
- });
3324
+ for (const [id, pid] of Object.entries(claudePids)) if (byPid[pid]) terminals[id] = byPid[pid];
3325
+ res.json({ cck: byPid[process.pid] || null, host: byPid[hostPid] || null, terminals });
3326
+ }));
3315
3327
 
3316
- app.delete('/api/terminals/:id', (req, res) => {
3317
- const err = terminal.end(req.params.id, req.get('x-terminal-token'));
3328
+ app.delete('/api/terminals/:id', terminalRoute(async (req, res) => {
3329
+ const err = await terminal.end(req.params.id, req.get('x-terminal-token'));
3318
3330
  if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
3319
3331
  if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
3320
3332
  res.status(204).end();
3321
- });
3333
+ }));
3322
3334
 
3323
3335
  // Served from node_modules, never a CDN: any script on this page can use the token.
3324
3336
  const XTERM_FILES = {
@@ -3346,20 +3358,14 @@ const dispatched = createDispatchedStore({
3346
3358
  save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
3347
3359
  });
3348
3360
 
3349
- const dispatches = createDispatchRegistry({
3350
- onSettle: (r) => {
3351
- if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
3352
- if (r.session) dispatched.settle(r.session, r.status);
3353
- broadcast({ type: 'dispatch-update' });
3354
- },
3355
- });
3361
+ const dispatches = createDispatchRegistry();
3356
3362
 
3357
3363
  const dispatchGroups = createGroupStore({
3358
3364
  load: () => {
3359
3365
  try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
3360
3366
  },
3361
3367
  save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
3362
- isAlive: (id) => terminal.isRunning(id) || isSessionProcessAlive(id),
3368
+ isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
3363
3369
  pinnedIds: () => new Set(Object.keys(readPins())),
3364
3370
  });
3365
3371
 
@@ -3371,8 +3377,6 @@ const linkedDocs = createLinkedDocStore({
3371
3377
  });
3372
3378
 
3373
3379
  // The board places a session from these alone, so it never has to move it later.
3374
- // `startedBy` lets it follow a starter the user put in a named group, which only the
3375
- // browser knows; it goes once the dispatch settles. `dispatched` stays for the card's marker.
3376
3380
  function withDispatchPlacement(sessions) {
3377
3381
  const groups = dispatchGroups.snapshot();
3378
3382
  return sessions.map((s) => {
@@ -3382,56 +3386,35 @@ function withDispatchPlacement(sessions) {
3382
3386
  return {
3383
3387
  ...s,
3384
3388
  dispatchGroup,
3385
- startedBy: marker?.status === 'running' ? marker.parent || undefined : undefined,
3386
- dispatched: marker ? { parent: marker.parent, outcome: DISPATCH_OUTCOME[marker.status] } : undefined,
3389
+ dispatched: marker ? { parent: marker.parent } : undefined,
3387
3390
  };
3388
3391
  });
3389
3392
  }
3390
3393
 
3391
3394
  // Starting a session needs the terminal token, as the browser does; the CLI reads it from
3392
- // TERMINAL_TOKEN_FILE. The child gets only its dispatch capability through the preamble.
3393
- app.post('/api/dispatch', (req, res) => {
3395
+ // TERMINAL_TOKEN_FILE. The started session never holds it.
3396
+ app.post('/api/dispatch', terminalRoute(async (req, res) => {
3394
3397
  if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3395
- const { cwd, spec, name, model, worktree, parent, group, report, peer } = req.body || {};
3398
+ const { cwd, spec, name, model, worktree, parent, group, claudeArgs } = req.body || {};
3396
3399
  if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3397
- if (peer != null && !isPeerName(peer)) return res.status(400).json({ error: 'peer must be a peer name as ListAgents prints it' });
3398
3400
  if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
3399
3401
  if (group != null && !isGroupName(group)) {
3400
3402
  return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3401
3403
  }
3402
- const target = group || (parent && dispatchGroups.groupOf(parent)) || null;
3403
- const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
3404
- const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
3405
- const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
3406
- if (started.error) {
3407
- dispatches.discard(r.id);
3408
- return res.status(started.status).json({ error: started.error });
3409
- }
3410
- dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
3404
+ const started = await terminal.startNew({ cwd, name, model, worktree, prompt: spec.trim(), extraArgs: claudeArgs });
3405
+ if (started.error) return res.status(started.status).json({ error: started.error });
3406
+ dispatches.add({ session: started.id, parent, cwd: started.cwd, name, group: group || null, worktree });
3411
3407
  dispatched.record(started.id, parent);
3412
3408
  // The starter stays where it is: moving it would jump it under the user.
3413
- if (target) dispatchGroups.join(target, [started.id], parent);
3409
+ if (group) dispatchGroups.join(group, [started.id]);
3414
3410
  broadcast({ type: 'dispatch-update' });
3415
- res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
3416
- });
3417
-
3418
- app.post('/api/dispatch/:id/done', (req, res) => {
3419
- const { cap, outcome, summary } = req.body || {};
3420
- const err = dispatches.settle(req.params.id, cap, outcome, summary);
3421
- if (err) return res.status(err.status).json({ error: err.error });
3422
- res.status(204).end();
3423
- });
3411
+ res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
3412
+ }));
3424
3413
 
3425
- app.get('/api/dispatch', async (req, res) => {
3414
+ app.get('/api/dispatch', (req, res) => {
3426
3415
  res.setHeader('Cache-Control', 'no-store');
3427
- const ids = typeof req.query.ids === 'string' && req.query.ids ? req.query.ids.split(',') : null;
3428
3416
  const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
3429
- // res, not req: a GET's request stream can close before the response is sent.
3430
- const out = await dispatches.wait({ ids, parent }, req.query.wait, (stop) => {
3431
- res.on('close', stop);
3432
- return () => res.off('close', stop);
3433
- });
3434
- if (!res.writableEnded) res.json(out);
3417
+ res.json({ running: dispatches.list({ parent }) });
3435
3418
  });
3436
3419
  // #endregion
3437
3420
 
@@ -3496,7 +3479,6 @@ const {
3496
3479
  formatTaskMoved,
3497
3480
  handleSessionEvents,
3498
3481
  hasSessionListener,
3499
- topicKey,
3500
3482
  } = require('./lib/session-events');
3501
3483
  app.get('/api/sessions/:sessionId/events', handleSessionEvents);
3502
3484
 
@@ -4036,7 +4018,7 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4036
4018
  terminal.isRunning(sessionId) &&
4037
4019
  !isWaitingOnUser(sessionId)
4038
4020
  ) {
4039
- if (terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
4021
+ if (await terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
4040
4022
  }
4041
4023
  res.json({ delivered, file, markdown });
4042
4024
  } catch (error) {
@@ -4405,7 +4387,7 @@ async function prewarmCaches() {
4405
4387
  }
4406
4388
 
4407
4389
  const onReady = (actualPort) => {
4408
- listenPort = actualPort;
4390
+ terminal.setServerUrl(`http://127.0.0.1:${actualPort}`);
4409
4391
  // The port is configurable and falls back to a random one when taken, so the postman
4410
4392
  // monitor cannot assume it -- publish the live one where it can read it.
4411
4393
  writeServerInfo(actualPort);
@@ -4415,12 +4397,13 @@ async function prewarmCaches() {
4415
4397
  migrateLegacyApprovalsConfig();
4416
4398
  const warning = net.exposureWarning();
4417
4399
  if (warning) console.log(warning);
4418
- const terminalReason = terminal.unavailableReason();
4419
- if (terminalReason && terminalReason !== 'disabled') console.log(`Terminal unavailable: ${terminalReason}`);
4420
- // Under the hub the hub owns the token and hands it to the iframe itself.
4421
- if (!terminalReason && !process.env.CLAUDE_HUB) {
4422
- console.log(`Terminal enabled - open http://localhost:${actualPort}/#t=${terminal.token}`);
4423
- }
4400
+ terminal.started.then((terminalReason) => {
4401
+ if (terminalReason && terminalReason !== 'disabled') console.log(`Terminal unavailable: ${terminalReason}`);
4402
+ // Under the hub the hub owns the token and hands it to the iframe itself.
4403
+ if (!terminalReason && !process.env.CLAUDE_HUB) {
4404
+ console.log(`Terminal enabled - open http://localhost:${actualPort}/#t=${terminal.token}`);
4405
+ }
4406
+ });
4424
4407
 
4425
4408
  if (process.argv.includes('--open')) {
4426
4409
  import('open').then(open => open.default(`http://localhost:${actualPort}`));