claude-code-kanban 4.31.1 → 5.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/cli.js +16 -5
- package/hub-app.json +25 -0
- package/lib/net-guard.js +6 -1
- package/lib/parsers.js +21 -12
- package/lib/terminal.js +73 -8
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +12 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +10 -3
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +13 -2
- package/public/app.js +751 -387
- package/public/index.html +31 -4
- package/public/style.css +202 -15
- package/public/vendor/claude-hub-sdk.js +50 -0
- package/server.js +90 -29
package/server.js
CHANGED
|
@@ -27,7 +27,7 @@ const {
|
|
|
27
27
|
readScratchpadCreations,
|
|
28
28
|
extractPromptFromTranscript,
|
|
29
29
|
extractModelFromTranscript,
|
|
30
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
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);
|
|
@@ -1562,7 +1580,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1562
1580
|
if (recentHours > 0) {
|
|
1563
1581
|
const cutoff = Date.now() - recentHours * 3600 * 1000;
|
|
1564
1582
|
const activity = projectActivity();
|
|
1565
|
-
sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff ||
|
|
1583
|
+
sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff || pinnedIds.has(s.id));
|
|
1566
1584
|
}
|
|
1567
1585
|
|
|
1568
1586
|
const paged = limit !== null && limit > 0;
|
|
@@ -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
|
|
2502
|
-
// as the agent's response.
|
|
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) && (
|
|
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 =
|
|
2537
|
+
try { result = extractAgentResultFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
|
|
2515
2538
|
if (result) agent.lastMessage = result;
|
|
2516
|
-
else agent.resultUnavailable =
|
|
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
|
|
2574
|
-
try { unlinkSync(path.join(AGENT_ACTIVITY_DIR, sessionId,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|