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/README.md +21 -11
- package/lib/live-sessions.js +39 -0
- package/lib/parsers.js +18 -3
- package/lib/session-cache.js +1 -1
- package/lib/terminal-client.js +221 -0
- package/lib/terminal-host.js +95 -0
- package/lib/terminal.js +73 -38
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +5 -2
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +14 -1
- package/public/app.js +135 -27
- package/public/index.html +2 -1
- package/public/style.css +11 -0
- package/server.js +60 -65
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 {
|
|
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 {
|
|
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(
|
|
476
|
+
function loadLiveSessions() {
|
|
471
477
|
const now = Date.now();
|
|
472
|
-
if (
|
|
473
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
3328
|
-
const byPid = await terminalProcStats([process.pid, ...Object.values(
|
|
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(
|
|
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) ||
|
|
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
|
-
|
|
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
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
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}`));
|