claude-code-kanban 6.0.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/server.js CHANGED
@@ -41,7 +41,9 @@ 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
48
  const { createDispatchRegistry } = require('./lib/dispatch');
47
49
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
@@ -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) {
@@ -467,25 +473,12 @@ let liveSessionsCache = null;
467
473
  let lastLiveSessionsScan = 0;
468
474
  const LIVE_SESSIONS_TTL = 5000;
469
475
 
470
- function loadLiveSessions(fresh = false) {
476
+ function loadLiveSessions() {
471
477
  const now = Date.now();
472
- if (!fresh && liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
473
- const sessions = [];
474
- if (existsSync(SESSIONS_DIR)) {
475
- try {
476
- for (const file of readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
477
- try {
478
- const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
479
- if (s?.sessionId && s.kind === 'interactive') {
480
- 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 });
481
- }
482
- } catch (_) { /* skip invalid */ }
483
- }
484
- } catch (_) { /* ignore */ }
485
- }
486
- liveSessionsCache = sessions;
478
+ if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
479
+ liveSessionsCache = readLiveSessions(SESSIONS_DIR);
487
480
  lastLiveSessionsScan = now;
488
- return sessions;
481
+ return liveSessionsCache;
489
482
  }
490
483
 
491
484
  // An open-but-idle interactive session keeps touching its JSONL (metadata-line
@@ -504,19 +497,6 @@ function getPeerName(sessionId) {
504
497
  return live?.name ?? null;
505
498
  }
506
499
 
507
- // A registry file outlives a crashed claude, so the pid is probed rather than trusted.
508
- function isSessionProcessAlive(sessionId, exceptPid = null) {
509
- return loadLiveSessions().some((s) => {
510
- if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
511
- return isPidAlive(s.pid);
512
- });
513
- }
514
-
515
- // EPERM means the process exists but belongs to someone else.
516
- function isPidAlive(pid) {
517
- try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
518
- }
519
-
520
500
  function hasRecentLogActivity(sessionId, logAge) {
521
501
  return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
522
502
  }
@@ -847,6 +827,7 @@ function refreshSessionMetadataPath(jsonlPath) {
847
827
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
848
828
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
849
829
  existing.permissionMode = info.permissionMode;
830
+ existing.cacheTtl = info.cacheTtl;
850
831
  return true;
851
832
  }
852
833
 
@@ -934,7 +915,8 @@ function loadSessionMetadata() {
934
915
  jsonlPath: jsonlPath,
935
916
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
936
917
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
937
- permissionMode: sessionInfo.permissionMode
918
+ permissionMode: sessionInfo.permissionMode,
919
+ cacheTtl: sessionInfo.cacheTtl || null
938
920
  };
939
921
  sessionIds.push(sessionId);
940
922
  }
@@ -1219,7 +1201,7 @@ app.get('/api/sessions', async (req, res) => {
1219
1201
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
1220
1202
  for (const id of includeIds) pinnedIds.add(id);
1221
1203
  const activeFilter = req.query.filter === 'active';
1222
- const terminalIds = activeFilter ? new Set(terminal.list().map((t) => t.id)) : new Set();
1204
+ const terminalIds = activeFilter ? new Set(terminal.ids()) : new Set();
1223
1205
 
1224
1206
  const metadata = loadSessionMetadata();
1225
1207
  const sessionsMap = new Map();
@@ -3272,31 +3254,34 @@ function resolveSessionFolder(id) {
3272
3254
  return meta ? meta.project || meta.cwd || null : null;
3273
3255
  }
3274
3256
 
3275
- let listenPort = null;
3276
- const terminal = createTerminalService({
3257
+ const terminal = createTerminalClient({
3277
3258
  config: readTerminalConfig({ getArgValue }),
3278
- serverUrl: () => (listenPort ? `http://127.0.0.1:${listenPort}` : null),
3279
3259
  net,
3280
3260
  claudeDir: CLAUDE_DIR,
3281
3261
  isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
3262
+ sessionsDir: SESSIONS_DIR,
3282
3263
  token: process.env.CCK_TERMINAL_TOKEN,
3283
3264
  load: () => {
3284
3265
  try { return JSON.parse(readFileSync(TERMINALS_FILE, 'utf8')); } catch { return null; }
3285
3266
  },
3286
3267
  save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
3287
- which: whichSync,
3288
- isLiveElsewhere: isSessionProcessAlive,
3289
3268
  // The project, not the last cwd: `claude --resume` finds a session under the
3290
3269
  // project dir it started in, and cwd drifts into subdirectories.
3291
3270
  resolveCwd: resolveSessionFolder,
3292
3271
  isAllowedFolder,
3293
- liveSessions: () => loadLiveSessions(true),
3294
- onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
3272
+ onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
3295
3273
  onExit: (id) => {
3296
3274
  if (dispatches.remove(id)) broadcast({ type: 'dispatch-update' });
3297
3275
  },
3298
3276
  });
3299
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
+
3300
3285
  let folderDialogOpen = false;
3301
3286
  app.post('/api/terminal/pick-folder', async (req, res) => {
3302
3287
  const reason = terminal.unavailableReason();
@@ -3315,28 +3300,37 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3315
3300
  }
3316
3301
  });
3317
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
+
3318
3312
  // The hub's eviction check reads this to keep a pool with live PTYs alive.
3319
- app.get('/api/terminals', (_req, res) => {
3313
+ app.get('/api/terminals', terminalRoute(async (_req, res) => {
3320
3314
  res.setHeader('Cache-Control', 'no-store');
3321
- res.json({ sessions: terminal.list() });
3322
- });
3315
+ res.json({ sessions: await terminal.sessions() });
3316
+ }));
3323
3317
 
3324
3318
  const terminalProcStats = createProcStats();
3325
- app.get('/api/terminals/stats', async (_req, res) => {
3319
+ app.get('/api/terminals/stats', terminalRoute(async (_req, res) => {
3326
3320
  res.setHeader('Cache-Control', 'no-store');
3327
- const pids = terminal.claudePids();
3328
- 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));
3329
3323
  const terminals = {};
3330
- for (const [id, pid] of Object.entries(pids)) if (byPid[pid]) terminals[id] = byPid[pid];
3331
- res.json({ cck: byPid[process.pid] || null, terminals });
3332
- });
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
+ }));
3333
3327
 
3334
- app.delete('/api/terminals/:id', (req, res) => {
3335
- 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'));
3336
3330
  if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
3337
3331
  if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
3338
3332
  res.status(204).end();
3339
- });
3333
+ }));
3340
3334
 
3341
3335
  // Served from node_modules, never a CDN: any script on this page can use the token.
3342
3336
  const XTERM_FILES = {
@@ -3371,7 +3365,7 @@ const dispatchGroups = createGroupStore({
3371
3365
  try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
3372
3366
  },
3373
3367
  save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
3374
- isAlive: (id) => terminal.isRunning(id) || isSessionProcessAlive(id),
3368
+ isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
3375
3369
  pinnedIds: () => new Set(Object.keys(readPins())),
3376
3370
  });
3377
3371
 
@@ -3399,7 +3393,7 @@ function withDispatchPlacement(sessions) {
3399
3393
 
3400
3394
  // Starting a session needs the terminal token, as the browser does; the CLI reads it from
3401
3395
  // TERMINAL_TOKEN_FILE. The started session never holds it.
3402
- app.post('/api/dispatch', (req, res) => {
3396
+ app.post('/api/dispatch', terminalRoute(async (req, res) => {
3403
3397
  if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3404
3398
  const { cwd, spec, name, model, worktree, parent, group, claudeArgs } = req.body || {};
3405
3399
  if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
@@ -3407,7 +3401,7 @@ app.post('/api/dispatch', (req, res) => {
3407
3401
  if (group != null && !isGroupName(group)) {
3408
3402
  return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3409
3403
  }
3410
- const started = terminal.startNew({ cwd, name, model, worktree, prompt: spec.trim(), extraArgs: claudeArgs });
3404
+ const started = await terminal.startNew({ cwd, name, model, worktree, prompt: spec.trim(), extraArgs: claudeArgs });
3411
3405
  if (started.error) return res.status(started.status).json({ error: started.error });
3412
3406
  dispatches.add({ session: started.id, parent, cwd: started.cwd, name, group: group || null, worktree });
3413
3407
  dispatched.record(started.id, parent);
@@ -3415,7 +3409,7 @@ app.post('/api/dispatch', (req, res) => {
3415
3409
  if (group) dispatchGroups.join(group, [started.id]);
3416
3410
  broadcast({ type: 'dispatch-update' });
3417
3411
  res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
3418
- });
3412
+ }));
3419
3413
 
3420
3414
  app.get('/api/dispatch', (req, res) => {
3421
3415
  res.setHeader('Cache-Control', 'no-store');
@@ -4024,7 +4018,7 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4024
4018
  terminal.isRunning(sessionId) &&
4025
4019
  !isWaitingOnUser(sessionId)
4026
4020
  ) {
4027
- 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';
4028
4022
  }
4029
4023
  res.json({ delivered, file, markdown });
4030
4024
  } catch (error) {
@@ -4393,7 +4387,7 @@ async function prewarmCaches() {
4393
4387
  }
4394
4388
 
4395
4389
  const onReady = (actualPort) => {
4396
- listenPort = actualPort;
4390
+ terminal.setServerUrl(`http://127.0.0.1:${actualPort}`);
4397
4391
  // The port is configurable and falls back to a random one when taken, so the postman
4398
4392
  // monitor cannot assume it -- publish the live one where it can read it.
4399
4393
  writeServerInfo(actualPort);
@@ -4403,12 +4397,13 @@ async function prewarmCaches() {
4403
4397
  migrateLegacyApprovalsConfig();
4404
4398
  const warning = net.exposureWarning();
4405
4399
  if (warning) console.log(warning);
4406
- const terminalReason = terminal.unavailableReason();
4407
- if (terminalReason && terminalReason !== 'disabled') console.log(`Terminal unavailable: ${terminalReason}`);
4408
- // Under the hub the hub owns the token and hands it to the iframe itself.
4409
- if (!terminalReason && !process.env.CLAUDE_HUB) {
4410
- console.log(`Terminal enabled - open http://localhost:${actualPort}/#t=${terminal.token}`);
4411
- }
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
+ });
4412
4407
 
4413
4408
  if (process.argv.includes('--open')) {
4414
4409
  import('open').then(open => open.default(`http://localhost:${actualPort}`));