claude-code-kanban 4.31.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -27,7 +27,7 @@ const {
27
27
  readScratchpadCreations,
28
28
  extractPromptFromTranscript,
29
29
  extractModelFromTranscript,
30
- extractStructuredResultFromTranscript,
30
+ extractAgentResultFromTranscript,
31
31
  extractTranscriptStats,
32
32
  readFullToolResult,
33
33
  readUserImage,
@@ -63,7 +63,7 @@ if (process.argv.includes("--install") || process.argv.includes("--uninstall"))
63
63
  function startServer() {
64
64
 
65
65
  const app = express();
66
- const PORT = process.env.PORT || 3541;
66
+ const PORT = parseInt(getArgValue('port') || process.env.PORT || '3541', 10);
67
67
 
68
68
  // Mounted before express.json() so a rejected request never buffers a body, and
69
69
  // before the /api catch-all so no route escapes the check.
@@ -94,6 +94,7 @@ const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
94
94
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
95
95
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
96
96
  const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
97
+ const TERMINALS_FILE = path.join(CCK_DIR, 'terminals.json');
97
98
  // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
98
99
  const TEMP_ROOT = (() => {
99
100
  try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
@@ -214,7 +215,7 @@ function readAgentJsonl(filePath) {
214
215
  }
215
216
 
216
217
  // Agent-activity record files in a session dir, excluding the `_`-prefixed sidecars
217
- // (_waiting.json, _name-*). Returns [] if the dir is missing/unreadable.
218
+ // (_waiting.json, _stop.json, _name-*). Returns [] if the dir is missing/unreadable.
218
219
  function listAgentFiles(agentDir) {
219
220
  try {
220
221
  return readdirSync(agentDir).filter((f) => f.endsWith('.jsonl') && !f.startsWith('_'));
@@ -374,13 +375,18 @@ function getSessionLogStat(meta) {
374
375
  }
375
376
 
376
377
  function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
377
- const result = { hasActive: false, hasRunning: false, waitingForUser: null };
378
- if (!existsSync(agentDir)) return result;
379
- result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
378
+ const result = { hasActive: false, hasRunning: false, waitingForUser: null, unread: false };
379
+ let names;
380
+ try { names = readdirSync(agentDir); } catch { return result; }
381
+ if (names.includes('_waiting.json')) result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
380
382
  if (result.waitingForUser) result.hasActive = true;
381
- if (stale && !isTeam) return result;
383
+ const stopped = names.includes('_stop.json');
384
+ if (stale && !isTeam) {
385
+ result.unread = stopped;
386
+ return result;
387
+ }
382
388
  try {
383
- for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
389
+ for (const file of names.filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
384
390
  try {
385
391
  const agent = readAgentJsonl(path.join(agentDir, file));
386
392
  // Idle agents never mark a session active (an idle teammate lingers and
@@ -393,6 +399,7 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
393
399
  } catch { /* skip invalid */ }
394
400
  }
395
401
  } catch { /* ignore */ }
402
+ result.unread = stopped && !result.hasRunning;
396
403
  return result;
397
404
  }
398
405
 
@@ -481,7 +488,7 @@ function loadLiveSessions(fresh = false) {
481
488
  try {
482
489
  const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
483
490
  if (s?.sessionId && s.kind === 'interactive') {
484
- sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
491
+ 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 });
485
492
  }
486
493
  } catch (_) { /* skip invalid */ }
487
494
  }
@@ -502,6 +509,12 @@ function isRegistryIdle(sessionId) {
502
509
  return live?.status === 'idle';
503
510
  }
504
511
 
512
+ // The registry name is the address SendMessage and ListAgents use.
513
+ function getPeerName(sessionId) {
514
+ const live = loadLiveSessions().find((s) => s.sessionId === sessionId && s.name && s.pid && isPidAlive(s.pid));
515
+ return live?.name ?? null;
516
+ }
517
+
505
518
  // A registry file outlives a crashed claude, so the pid is probed rather than trusted.
506
519
  function isSessionProcessAlive(sessionId, exceptPid = null) {
507
520
  return loadLiveSessions().some((s) => {
@@ -614,9 +627,8 @@ app.use(express.json());
614
627
 
615
628
  // #region STATIC
616
629
 
617
- app.get('/hub-config', (_req, res) => {
618
- res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
619
- });
630
+ // Under a hub, the hub hands over its SDK. Alone, public/vendor/claude-hub-sdk.js is a stub.
631
+ if (process.env.HUB_SDK_SERVER) require(process.env.HUB_SDK_SERVER).mount(app);
620
632
 
621
633
  // app.js reads localStorage synchronously at startup, before any fetch could deliver the
622
634
  // namespace, so it is injected into the page instead of served from /hub-config.
@@ -1152,6 +1164,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1152
1164
  return {
1153
1165
  id,
1154
1166
  name: getSessionDisplayName(id, meta),
1167
+ peerName: getPeerName(id),
1155
1168
  slug: meta.slug || null,
1156
1169
  project: meta.project || null,
1157
1170
  cwd: meta.cwd || null,
@@ -1171,6 +1184,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1171
1184
  hasActiveAgents: false,
1172
1185
  hasRunningAgents: false,
1173
1186
  hasWaitingForUser: false,
1187
+ unread: false,
1174
1188
  hasRecentLog: hasRecentLogActivity(id, logAge),
1175
1189
  hasRecentActivity: hasVisibleLogActivity(id, logAge),
1176
1190
  jsonlPath: meta.jsonlPath || null,
@@ -1268,6 +1282,7 @@ app.get('/api/sessions', async (req, res) => {
1268
1282
  hasActiveAgents: agentStatus.hasActive,
1269
1283
  hasRunningAgents: agentStatus.hasRunning,
1270
1284
  hasWaitingForUser: !!agentStatus.waitingForUser,
1285
+ unread: agentStatus.unread,
1271
1286
  tasksDir: sessionPath,
1272
1287
  ...planInfo
1273
1288
  }));
@@ -1316,6 +1331,7 @@ app.get('/api/sessions', async (req, res) => {
1316
1331
  hasActiveAgents: agentStatus.hasActive,
1317
1332
  hasRunningAgents: agentStatus.hasRunning,
1318
1333
  hasWaitingForUser: !!agentStatus.waitingForUser,
1334
+ unread: agentStatus.unread,
1319
1335
  tasksDir: customTaskDir,
1320
1336
  sharedTaskList: taskListName,
1321
1337
  }));
@@ -1354,6 +1370,7 @@ app.get('/api/sessions', async (req, res) => {
1354
1370
  hasActiveAgents: metaAgentStatus.hasActive,
1355
1371
  hasRunningAgents: metaAgentStatus.hasRunning,
1356
1372
  hasWaitingForUser: !!metaAgentStatus.waitingForUser,
1373
+ unread: metaAgentStatus.unread,
1357
1374
  }));
1358
1375
  }
1359
1376
  }
@@ -1425,6 +1442,7 @@ app.get('/api/sessions', async (req, res) => {
1425
1442
  hasActiveAgents: agentStatus.hasActive,
1426
1443
  hasRunningAgents: agentStatus.hasRunning,
1427
1444
  hasWaitingForUser: !!agentStatus.waitingForUser,
1445
+ unread: agentStatus.unread,
1428
1446
  });
1429
1447
  attachTeamTasks(card, teamTaskDir, dir.name, counts);
1430
1448
  sessionsMap.set(leaderId, card);
@@ -2338,6 +2356,10 @@ app.get('/api/teams/:name', (req, res) => {
2338
2356
  });
2339
2357
 
2340
2358
  // API: Get agents for a session
2359
+ // The resultUnavailable latch is persisted on the agent record. Bump this when
2360
+ // extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
2361
+ const RESULT_SCAN = 2;
2362
+
2341
2363
  app.get('/api/sessions/:sessionId/agents', (req, res) => {
2342
2364
  const sessionId = resolveSessionId(req.params.sessionId);
2343
2365
  const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
@@ -2498,22 +2520,23 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2498
2520
  }
2499
2521
 
2500
2522
  // Workflow-spawned subagents given a schema end on a forced StructuredOutput
2501
- // tool call and never emit a text lastMessage — surface that structured result
2502
- // as the agent's response. Only stopped agents (complete transcript); latch
2523
+ // tool call (background subagents on SubagentHandback) and never emit a text
2524
+ // lastMessage — surface that result as the agent's response.
2525
+ // Only stopped agents (complete transcript); latch
2503
2526
  // resultUnavailable so we tail-read at most once per agent. Workflow subagents
2504
2527
  // are exempt from the latch: the poll that flips them to "stopped" can beat the
2505
2528
  // transcript's final StructuredOutput line to disk, latching resultUnavailable
2506
2529
  // against an incomplete transcript — so always re-attempt for them, mirroring
2507
2530
  // the prompt/name/description reconcile above.
2508
2531
  const agentsNeedingResult = agents.filter(
2509
- (a) => !a.lastMessage && !isAgentLive(a) && (!a.resultUnavailable || a.type === 'workflow-subagent'),
2532
+ (a) => !a.lastMessage && !isAgentLive(a) && (a.resultUnavailable !== RESULT_SCAN || a.type === 'workflow-subagent'),
2510
2533
  );
2511
2534
  if (agentsNeedingResult.length && meta.jsonlPath) {
2512
2535
  for (const agent of agentsNeedingResult) {
2513
2536
  let result = null;
2514
- try { result = extractStructuredResultFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
2537
+ try { result = extractAgentResultFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
2515
2538
  if (result) agent.lastMessage = result;
2516
- else agent.resultUnavailable = true;
2539
+ else agent.resultUnavailable = RESULT_SCAN;
2517
2540
  dirty.add(agent);
2518
2541
  }
2519
2542
  }
@@ -2570,20 +2593,29 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2570
2593
  }
2571
2594
  });
2572
2595
 
2573
- function clearWaitingFile(sessionId) {
2574
- try { unlinkSync(path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json')); }
2596
+ function clearActivityMarker(sessionId, file) {
2597
+ try { unlinkSync(path.join(AGENT_ACTIVITY_DIR, sessionId, file)); }
2575
2598
  catch (e) { if (e.code !== 'ENOENT') throw e; }
2576
2599
  }
2577
2600
 
2578
2601
  app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
2579
2602
  try {
2580
- clearWaitingFile(resolveSessionId(req.params.sessionId));
2603
+ clearActivityMarker(resolveSessionId(req.params.sessionId), '_waiting.json');
2581
2604
  res.json({ ok: true });
2582
2605
  } catch {
2583
2606
  res.status(500).json({ error: 'Failed to discard waiting' });
2584
2607
  }
2585
2608
  });
2586
2609
 
2610
+ app.post('/api/sessions/:sessionId/read', (req, res) => {
2611
+ try {
2612
+ clearActivityMarker(resolveSessionId(req.params.sessionId), '_stop.json');
2613
+ res.json({ ok: true });
2614
+ } catch {
2615
+ res.status(500).json({ error: 'Failed to mark session read' });
2616
+ }
2617
+ });
2618
+
2587
2619
  // UI-driven approvals: answers the ask approval-gate.sh is blocking on by
2588
2620
  // writing _decision-<id>.json next to the marker. The hook consumes and deletes
2589
2621
  // both files; a write for a hook that already gave up is accepted anyway (D13)
@@ -2619,7 +2651,7 @@ app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
2619
2651
  agent.stoppedAt = new Date().toISOString();
2620
2652
  const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
2621
2653
  writeFileSync(agentFile, `${readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt)}\n`, 'utf8'); // sync — response depends on write
2622
- clearWaitingFile(sessionId);
2654
+ clearActivityMarker(sessionId, '_waiting.json');
2623
2655
  res.json({ ok: true });
2624
2656
  } catch {
2625
2657
  res.status(500).json({ error: 'Failed to stop agent' });
@@ -3187,6 +3219,32 @@ function isAllowedFolder(dir) {
3187
3219
  try { return known && statSync(dir).isDirectory(); } catch { return false; }
3188
3220
  }
3189
3221
 
3222
+ // Restore runs before the first full metadata scan, so until that scan has run a session's
3223
+ // folder is read from its own JSONL, by the same rules the scan uses.
3224
+ function resolveSessionFolder(id) {
3225
+ if (lastMetadataRefresh || !isSafeId(id)) {
3226
+ const meta = loadSessionMetadata()[id];
3227
+ return meta ? meta.project || meta.cwd || null : null;
3228
+ }
3229
+ try {
3230
+ for (const dir of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
3231
+ if (!dir.isDirectory()) continue;
3232
+ const jsonlPath = path.join(PROJECTS_DIR, dir.name, `${id}.jsonl`);
3233
+ if (!existsSync(jsonlPath)) continue;
3234
+ let indexProject = null;
3235
+ try {
3236
+ const index = JSON.parse(readFileSync(path.join(PROJECTS_DIR, dir.name, 'sessions-index.json'), 'utf8'));
3237
+ indexProject = (index.entries || []).find((e) => e.projectPath)?.projectPath || null;
3238
+ } catch {}
3239
+ const info = readSessionInfoFromJsonl(jsonlPath);
3240
+ const folder = indexProject || info.projectPath || info.cwd;
3241
+ if (folder) return folder;
3242
+ }
3243
+ } catch {}
3244
+ const meta = loadSessionMetadata()[id];
3245
+ return meta ? meta.project || meta.cwd || null : null;
3246
+ }
3247
+
3190
3248
  let listenPort = null;
3191
3249
  const terminal = createTerminalService({
3192
3250
  config: readTerminalConfig({ getArgValue }),
@@ -3195,14 +3253,15 @@ const terminal = createTerminalService({
3195
3253
  claudeDir: CLAUDE_DIR,
3196
3254
  isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
3197
3255
  token: process.env.CCK_TERMINAL_TOKEN,
3256
+ load: () => {
3257
+ try { return JSON.parse(readFileSync(TERMINALS_FILE, 'utf8')); } catch { return null; }
3258
+ },
3259
+ save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
3198
3260
  which: whichSync,
3199
3261
  isLiveElsewhere: isSessionProcessAlive,
3200
3262
  // The project, not the last cwd: `claude --resume` finds a session under the
3201
3263
  // project dir it started in, and cwd drifts into subdirectories.
3202
- resolveCwd: (id) => {
3203
- const meta = loadSessionMetadata()[id];
3204
- return meta ? meta.project || meta.cwd || null : null;
3205
- },
3264
+ resolveCwd: resolveSessionFolder,
3206
3265
  isAllowedFolder,
3207
3266
  liveSessions: () => loadLiveSessions(true),
3208
3267
  onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
@@ -4027,7 +4086,7 @@ const AGENT_FILE_CAP = 20;
4027
4086
 
4028
4087
  agentActivityWatcher.on('all', (event, filePath) => {
4029
4088
  const base = path.basename(filePath);
4030
- const isAgentEvent = filePath.endsWith('.jsonl') || base === '_waiting.json';
4089
+ const isAgentEvent = filePath.endsWith('.jsonl') || base === '_waiting.json' || base === '_stop.json';
4031
4090
  if ((event === 'add' || event === 'change' || event === 'unlink') && isAgentEvent) {
4032
4091
  const relativePath = path.relative(AGENT_ACTIVITY_DIR, filePath);
4033
4092
  const sessionId = relativePath.split(path.sep)[0];
@@ -4048,7 +4107,8 @@ agentActivityWatcher.on('all', (event, filePath) => {
4048
4107
  }
4049
4108
  } catch { /* ignore */ }
4050
4109
  }
4051
- broadcast({ type: 'agent-update', sessionId });
4110
+ const unreadOnly = base === '_stop.json';
4111
+ broadcast({ type: 'agent-update', sessionId, unreadOnly });
4052
4112
  // For team sessions, also broadcast with team name so frontend picks it up
4053
4113
  if (existsSync(TEAMS_DIR)) {
4054
4114
  try {
@@ -4056,7 +4116,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
4056
4116
  for (const td of teamDirs) {
4057
4117
  const cfg = loadTeamConfig(td.name);
4058
4118
  if (cfg && cfg.leadSessionId === sessionId) {
4059
- broadcast({ type: 'agent-update', sessionId: td.name });
4119
+ broadcast({ type: 'agent-update', sessionId: td.name, unreadOnly });
4060
4120
  break;
4061
4121
  }
4062
4122
  }
@@ -4210,6 +4270,7 @@ async function prewarmCaches() {
4210
4270
  if (process.argv.includes('--open')) {
4211
4271
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
4212
4272
  }
4273
+ terminal.restore();
4213
4274
  setTimeout(startPrewarm, PREWARM_FALLBACK_MS).unref();
4214
4275
  };
4215
4276