claude-code-kanban 5.3.0 → 5.4.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 +2 -2
- package/cli.js +10 -5
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/git-branch.js +47 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +2 -1
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +8 -8
- package/server.js +106 -95
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/server.js
CHANGED
|
@@ -9,7 +9,7 @@ const _readline = require('node:readline');
|
|
|
9
9
|
const chokidar = require('chokidar');
|
|
10
10
|
const os = require('node:os');
|
|
11
11
|
const crypto = require('node:crypto');
|
|
12
|
-
const {
|
|
12
|
+
const { spawn } = require('node:child_process');
|
|
13
13
|
const { assertOpenTarget, openInEditor, whichSync, exeBehindShim } = require('./lib/open-editor');
|
|
14
14
|
const { createNetGuard } = require('./lib/net-guard');
|
|
15
15
|
const { isContained } = require('./lib/contain');
|
|
@@ -35,7 +35,8 @@ const {
|
|
|
35
35
|
readToolResultImage,
|
|
36
36
|
readCachedImage,
|
|
37
37
|
updateLoopInfo,
|
|
38
|
-
buildLoopInfoFromState
|
|
38
|
+
buildLoopInfoFromState,
|
|
39
|
+
readLines
|
|
39
40
|
} = require('./lib/parsers');
|
|
40
41
|
const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
|
|
41
42
|
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
@@ -46,6 +47,7 @@ const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName,
|
|
|
46
47
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
47
48
|
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
|
|
48
49
|
const { createWorktreeStore } = require('./lib/worktrees');
|
|
50
|
+
const { readGitBranch } = require('./lib/git-branch');
|
|
49
51
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
50
52
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
51
53
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
@@ -55,8 +57,8 @@ const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
|
55
57
|
|
|
56
58
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
57
59
|
const { runInstall, runUninstall } = require("./install");
|
|
58
|
-
const
|
|
59
|
-
(process.argv.includes("--install") ? runInstall({
|
|
60
|
+
const yes = process.argv.includes("--yes");
|
|
61
|
+
(process.argv.includes("--install") ? runInstall({ yes }) : runUninstall())
|
|
60
62
|
.then(() => process.exit(0))
|
|
61
63
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
62
64
|
} else if (!require("./cli").runCli(process.argv)) {
|
|
@@ -136,7 +138,7 @@ function writePins(pins) {
|
|
|
136
138
|
writeJsonAtomic(PINS_FILE, pins);
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
// Port discovery for out-of-process helpers (the postman monitor,
|
|
141
|
+
// Port discovery for out-of-process helpers (the postman monitor, the CLI).
|
|
140
142
|
// The pid rides along so a reader can tell a live server from a file left behind by
|
|
141
143
|
// a crashed one.
|
|
142
144
|
function writeServerInfo(port) {
|
|
@@ -165,8 +167,8 @@ function ownerPid(file) {
|
|
|
165
167
|
try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
// A beacon that outlives its server
|
|
169
|
-
//
|
|
170
|
+
// A beacon that outlives its server sends the postman monitor and the CLI to a closed
|
|
171
|
+
// port. The hub spawns every sub-app on
|
|
170
172
|
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
171
173
|
// the way out. Only when the file is still ours: a newer server on the same config
|
|
172
174
|
// dir has already claimed it. The terminal token file goes the same way.
|
|
@@ -271,7 +273,10 @@ function checkWaitingForUser(agentDir, logMtime) {
|
|
|
271
273
|
const age = Date.now() - waitTime;
|
|
272
274
|
if (age >= PERMISSION_TTL_MS) return null;
|
|
273
275
|
// After grace period, check if session resumed activity (user already responded)
|
|
274
|
-
if (
|
|
276
|
+
if (movedOnSince(waitTime, logMtime)) return null;
|
|
277
|
+
// The mod sees the ask before auto mode's classifier decides it, and no dialog may follow.
|
|
278
|
+
// An ask from a settings rule always opens the dialog.
|
|
279
|
+
if (data.kind === 'permission' && !data.rule && loadSessionMetadata()[path.basename(agentDir)]?.permissionMode === 'auto') return null;
|
|
275
280
|
// Opted out reads the same as lapsed to the board: the ask is only
|
|
276
281
|
// answerable in the terminal, so no buttons.
|
|
277
282
|
if (boardRefusal(data, approvalsConfig())) return { ...data, lapsed: true };
|
|
@@ -313,7 +318,7 @@ function isAgentLive(agent) {
|
|
|
313
318
|
// Claude Code records gitBranch from the launch-time repo and never updates it
|
|
314
319
|
// when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
|
|
315
320
|
// the live cwd instead. Cached per-cwd with a short TTL so a list refresh
|
|
316
|
-
// across N sessions sharing one cwd
|
|
321
|
+
// across N sessions sharing one cwd reads HEAD at most once per TTL window.
|
|
317
322
|
const gitBranchCache = new Map();
|
|
318
323
|
const GIT_BRANCH_TTL_MS = 30000;
|
|
319
324
|
const GIT_BRANCH_CACHE_MAX = 500;
|
|
@@ -322,18 +327,7 @@ function getGitBranch(cwd) {
|
|
|
322
327
|
const now = Date.now();
|
|
323
328
|
const cached = gitBranchCache.get(cwd);
|
|
324
329
|
if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
|
|
325
|
-
|
|
326
|
-
try {
|
|
327
|
-
// cwd rather than `-C <cwd>`: a path beginning with a dash would otherwise be
|
|
328
|
-
// read by git as an option.
|
|
329
|
-
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
330
|
-
cwd, encoding: 'utf8', timeout: 500, windowsHide: true
|
|
331
|
-
});
|
|
332
|
-
if (r.status === 0) {
|
|
333
|
-
const out = (r.stdout || '').trim();
|
|
334
|
-
if (out && out !== 'HEAD') branch = out;
|
|
335
|
-
}
|
|
336
|
-
} catch (_) {}
|
|
330
|
+
const branch = readGitBranch(cwd);
|
|
337
331
|
gitBranchCache.set(cwd, { branch, ts: now });
|
|
338
332
|
if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
|
|
339
333
|
const firstKey = gitBranchCache.keys().next().value;
|
|
@@ -349,8 +343,8 @@ const worktrees = createWorktreeStore({
|
|
|
349
343
|
save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
|
|
350
344
|
});
|
|
351
345
|
|
|
352
|
-
// Only
|
|
353
|
-
// only case the JSONL value is wrong.
|
|
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.
|
|
354
348
|
function resolveSessionGitBranch(meta) {
|
|
355
349
|
if (meta.cwd && meta.project && meta.cwd !== meta.project) {
|
|
356
350
|
return getGitBranch(meta.cwd) || meta.gitBranch || null;
|
|
@@ -366,13 +360,25 @@ function getSessionLogStat(meta) {
|
|
|
366
360
|
} catch { return { mtime: null, hasMessages: false }; }
|
|
367
361
|
}
|
|
368
362
|
|
|
363
|
+
// A transcript write past the grace window after a marker means the session went on.
|
|
364
|
+
function movedOnSince(markerMs, logMtime) {
|
|
365
|
+
return !!logMtime && logMtime > markerMs + WAITING_RESOLVE_GRACE_MS;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The mod cannot delete _stop.json when the session works again.
|
|
369
|
+
function resumedSince(markerPath, logMtime) {
|
|
370
|
+
if (!logMtime) return false;
|
|
371
|
+
try { return movedOnSince(statSync(markerPath).mtimeMs, logMtime); }
|
|
372
|
+
catch { return true; }
|
|
373
|
+
}
|
|
374
|
+
|
|
369
375
|
function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
370
376
|
const result = { hasActive: false, hasRunning: false, waitingForUser: null, unread: false };
|
|
371
377
|
let names;
|
|
372
378
|
try { names = readdirSync(agentDir); } catch { return result; }
|
|
373
379
|
if (names.includes('_waiting.json')) result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
|
|
374
380
|
if (result.waitingForUser) result.hasActive = true;
|
|
375
|
-
const stopped = names.includes('_stop.json');
|
|
381
|
+
const stopped = names.includes('_stop.json') && !resumedSince(path.join(agentDir, '_stop.json'), logMtime);
|
|
376
382
|
if (stale && !isTeam) {
|
|
377
383
|
result.unread = stopped;
|
|
378
384
|
return result;
|
|
@@ -774,33 +780,46 @@ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
|
|
|
774
780
|
return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
|
|
775
781
|
}
|
|
776
782
|
|
|
783
|
+
// A promise from loadFn is cached as is, so concurrent callers share one read, and a newer
|
|
784
|
+
// mtime starts no second read while it is pending: full-transcript scans stream for seconds.
|
|
777
785
|
function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
|
|
778
786
|
try {
|
|
779
787
|
const cached = cache.get(cacheKey);
|
|
780
|
-
if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.data;
|
|
788
|
+
if (cached && (cached.pending || Date.now() - cached.ts < MESSAGE_CACHE_TTL)) return cached.data;
|
|
781
789
|
const st = statSync(filePath);
|
|
782
790
|
if (cached && cached.mtime === st.mtimeMs) {
|
|
783
791
|
cached.ts = Date.now();
|
|
784
792
|
return cached.data;
|
|
785
793
|
}
|
|
786
|
-
const
|
|
787
|
-
|
|
794
|
+
const entry = { data: loadFn(), mtime: st.mtimeMs, ts: Date.now() };
|
|
795
|
+
if (typeof entry.data?.then === 'function') {
|
|
796
|
+
entry.pending = true;
|
|
797
|
+
entry.data = entry.data.catch(() => {
|
|
798
|
+
if (cache.get(cacheKey) === entry) cache.delete(cacheKey);
|
|
799
|
+
return fallback;
|
|
800
|
+
}).finally(() => {
|
|
801
|
+
entry.pending = false;
|
|
802
|
+
entry.ts = Date.now();
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
cache.set(cacheKey, entry);
|
|
788
806
|
evictStaleCache(cache);
|
|
789
|
-
return data;
|
|
807
|
+
return entry.data;
|
|
790
808
|
} catch (_) { return fallback; }
|
|
791
809
|
}
|
|
792
810
|
|
|
811
|
+
// Express 4 does not pass a rejected handler promise to next(), and Node 24 exits on it.
|
|
812
|
+
function asyncRoute(fn) {
|
|
813
|
+
return (req, res, next) => fn(req, res, next).catch(next);
|
|
814
|
+
}
|
|
815
|
+
|
|
793
816
|
const sessionDigestCache = new Map();
|
|
794
817
|
function getSessionDigest(jsonlPath) {
|
|
795
818
|
return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
|
|
796
819
|
}
|
|
797
820
|
|
|
798
|
-
function getProgressMap(jsonlPath) {
|
|
799
|
-
return getSessionDigest(jsonlPath).progressMap;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function getTerminatedTeammates(jsonlPath) {
|
|
803
|
-
return getSessionDigest(jsonlPath).terminated;
|
|
821
|
+
async function getProgressMap(jsonlPath) {
|
|
822
|
+
return (await getSessionDigest(jsonlPath)).progressMap;
|
|
804
823
|
}
|
|
805
824
|
|
|
806
825
|
function readRecentMessages(jsonlPath, limit = 10) {
|
|
@@ -836,6 +855,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
836
855
|
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
837
856
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
838
857
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
858
|
+
existing.permissionMode = info.permissionMode;
|
|
839
859
|
return true;
|
|
840
860
|
}
|
|
841
861
|
|
|
@@ -922,7 +942,8 @@ function loadSessionMetadata() {
|
|
|
922
942
|
customTitle: sessionInfo.customTitle || null,
|
|
923
943
|
jsonlPath: jsonlPath,
|
|
924
944
|
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
925
|
-
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
|
|
945
|
+
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
|
|
946
|
+
permissionMode: sessionInfo.permissionMode
|
|
926
947
|
};
|
|
927
948
|
sessionIds.push(sessionId);
|
|
928
949
|
}
|
|
@@ -1775,15 +1796,20 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
|
1775
1796
|
|
|
1776
1797
|
// Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
|
|
1777
1798
|
// it already pays to notice the file grew. `load` may return a promise: the promise is
|
|
1778
|
-
// what gets cached, so concurrent callers share a single read
|
|
1799
|
+
// what gets cached, so concurrent callers share a single read, and a file that grows while
|
|
1800
|
+
// it is pending starts no second one. Null when the file is gone.
|
|
1779
1801
|
function cachedByFileStat(cache, filePath, load) {
|
|
1780
1802
|
let stat;
|
|
1781
1803
|
try { stat = statSync(filePath); } catch (_) { return null; }
|
|
1782
1804
|
const hit = cache.get(filePath);
|
|
1783
|
-
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1805
|
+
if (hit && (hit.pending || (hit.mtimeMs === stat.mtimeMs && hit.size === stat.size))) return hit.value;
|
|
1806
|
+
const entry = { mtimeMs: stat.mtimeMs, size: stat.size, value: load() };
|
|
1807
|
+
if (typeof entry.value?.then === 'function') {
|
|
1808
|
+
entry.pending = true;
|
|
1809
|
+
entry.value.finally(() => { entry.pending = false; }).catch(() => {});
|
|
1810
|
+
}
|
|
1811
|
+
cache.set(filePath, entry);
|
|
1812
|
+
return entry.value;
|
|
1787
1813
|
}
|
|
1788
1814
|
|
|
1789
1815
|
// Cold path — a full transcript scan, so the result is held until the file grows.
|
|
@@ -1794,12 +1820,12 @@ function getArtifactLinks(jsonlPath) {
|
|
|
1794
1820
|
return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
|
|
1795
1821
|
}
|
|
1796
1822
|
|
|
1797
|
-
app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
|
|
1823
|
+
app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
|
|
1798
1824
|
try {
|
|
1799
1825
|
const metadata = loadSessionMetadata();
|
|
1800
1826
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1801
1827
|
if (!meta?.jsonlPath) return res.json({ artifacts: [] });
|
|
1802
|
-
res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
|
|
1828
|
+
res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
|
|
1803
1829
|
} catch (error) {
|
|
1804
1830
|
console.error('Error reading artifacts:', error);
|
|
1805
1831
|
res.status(500).json({ error: 'Failed to read artifacts' });
|
|
@@ -1844,7 +1870,7 @@ async function findPads(root, depth = 0) {
|
|
|
1844
1870
|
// stored, so re-reading a transcript cannot drift; the client links each pad once
|
|
1845
1871
|
// and records that it did, which is what stops a re-read undoing an unlink.
|
|
1846
1872
|
async function readCreatedPads(meta) {
|
|
1847
|
-
const creations = readScratchpadCreations(meta.jsonlPath);
|
|
1873
|
+
const creations = await readScratchpadCreations(meta.jsonlPath);
|
|
1848
1874
|
// No `scratch new` in the transcript means no disk work at all, so sessions that
|
|
1849
1875
|
// never touch the CLI pay a substring pass over the transcript and nothing more.
|
|
1850
1876
|
if (!creations.length) return [];
|
|
@@ -2122,7 +2148,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
2122
2148
|
try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
|
|
2123
2149
|
for (const f of files) {
|
|
2124
2150
|
const agentId = f.slice('agent-'.length, -'.jsonl'.length);
|
|
2125
|
-
const stats = extractTranscriptStats(path.join(runDir, f)) || {};
|
|
2151
|
+
const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
|
|
2126
2152
|
let type = null;
|
|
2127
2153
|
try {
|
|
2128
2154
|
type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
|
|
@@ -2357,9 +2383,9 @@ app.get('/api/teams/:name', (req, res) => {
|
|
|
2357
2383
|
// API: Get agents for a session
|
|
2358
2384
|
// The resultUnavailable latch is persisted on the agent record. Bump this when
|
|
2359
2385
|
// extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
|
|
2360
|
-
const RESULT_SCAN =
|
|
2386
|
+
const RESULT_SCAN = 3;
|
|
2361
2387
|
|
|
2362
|
-
app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
2388
|
+
app.get('/api/sessions/:sessionId/agents', async (req, res) => {
|
|
2363
2389
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2364
2390
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
2365
2391
|
if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
|
|
@@ -2403,10 +2429,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2403
2429
|
agents.push(agent);
|
|
2404
2430
|
} catch { /* skip invalid */ }
|
|
2405
2431
|
}
|
|
2432
|
+
const digest = meta.jsonlPath ? await getSessionDigest(meta.jsonlPath) : null;
|
|
2406
2433
|
const liveAgents = agents.filter(isAgentLive);
|
|
2407
2434
|
if (liveAgents.length && meta.jsonlPath) {
|
|
2408
2435
|
try {
|
|
2409
|
-
const terminated =
|
|
2436
|
+
const { terminated } = digest;
|
|
2410
2437
|
if (terminated.size) {
|
|
2411
2438
|
for (const agent of liveAgents) {
|
|
2412
2439
|
const agentName = agentDisplayName(agent);
|
|
@@ -2423,10 +2450,9 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2423
2450
|
// Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
|
|
2424
2451
|
// the parent will never read their output, so they're orphans. Match by agentId
|
|
2425
2452
|
// when the digest already correlated tool_use→agent, else fall back to prompt text
|
|
2426
|
-
// (the
|
|
2453
|
+
// (the plugin's mod doesn't record the spawning tool_use_id).
|
|
2427
2454
|
try {
|
|
2428
|
-
const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
|
|
2429
|
-
getSessionDigest(meta.jsonlPath);
|
|
2455
|
+
const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } = digest;
|
|
2430
2456
|
if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
|
|
2431
2457
|
for (const agent of liveAgents) {
|
|
2432
2458
|
if (!isAgentLive(agent)) continue;
|
|
@@ -2457,8 +2483,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2457
2483
|
const byAgentId = {};
|
|
2458
2484
|
if (meta.jsonlPath) {
|
|
2459
2485
|
try {
|
|
2460
|
-
const
|
|
2461
|
-
for (const entry of Object.values(progressMap)) {
|
|
2486
|
+
for (const entry of Object.values(digest.progressMap)) {
|
|
2462
2487
|
byAgentId[entry.agentId] ||= {};
|
|
2463
2488
|
const e = byAgentId[entry.agentId];
|
|
2464
2489
|
for (const f of ['prompt', 'name', 'description', 'usage']) {
|
|
@@ -2615,10 +2640,10 @@ app.post('/api/sessions/:sessionId/read', (req, res) => {
|
|
|
2615
2640
|
}
|
|
2616
2641
|
});
|
|
2617
2642
|
|
|
2618
|
-
// UI-driven approvals: answers the ask
|
|
2619
|
-
//
|
|
2620
|
-
//
|
|
2621
|
-
//
|
|
2643
|
+
// UI-driven approvals: answers the ask the plugin's mod is polling for by writing
|
|
2644
|
+
// _decision-<id>.json next to the marker. The mod cannot delete files, so it clears
|
|
2645
|
+
// the marker and leaves the decision, and a write for an ask that is already over
|
|
2646
|
+
// is accepted anyway (D13); cleanupAgentActivity's sweep removes both kinds.
|
|
2622
2647
|
app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
2623
2648
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2624
2649
|
const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
@@ -2935,7 +2960,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
|
|
|
2935
2960
|
res.on('error', cleanup);
|
|
2936
2961
|
});
|
|
2937
2962
|
|
|
2938
|
-
|
|
2963
|
+
async function sendSessionMessages(req, res) {
|
|
2939
2964
|
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
|
|
2940
2965
|
const before = req.query.before || null;
|
|
2941
2966
|
const metadata = loadSessionMetadata();
|
|
@@ -2952,9 +2977,10 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
2952
2977
|
hasMore = messages.length > limit;
|
|
2953
2978
|
if (hasMore) messages = messages.slice(-limit);
|
|
2954
2979
|
}
|
|
2980
|
+
const compactPromise = cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), []);
|
|
2955
2981
|
const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
|
|
2956
2982
|
if (agentMessages.length) {
|
|
2957
|
-
const progressMap = getProgressMap(jsonlPath);
|
|
2983
|
+
const progressMap = await getProgressMap(jsonlPath);
|
|
2958
2984
|
const resolvedSid = resolveSessionId(req.params.sessionId);
|
|
2959
2985
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedSid);
|
|
2960
2986
|
for (const msg of agentMessages) {
|
|
@@ -3005,15 +3031,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
3005
3031
|
}
|
|
3006
3032
|
}
|
|
3007
3033
|
}
|
|
3008
|
-
const
|
|
3009
|
-
let compactSummaries;
|
|
3010
|
-
if (cachedCompact && Date.now() - cachedCompact.ts < MESSAGE_CACHE_TTL) {
|
|
3011
|
-
compactSummaries = cachedCompact.data;
|
|
3012
|
-
} else {
|
|
3013
|
-
compactSummaries = readCompactSummaries(jsonlPath);
|
|
3014
|
-
compactSummaryCache.set(jsonlPath, { data: compactSummaries, ts: Date.now() });
|
|
3015
|
-
evictStaleCache(compactSummaryCache);
|
|
3016
|
-
}
|
|
3034
|
+
const compactSummaries = await compactPromise;
|
|
3017
3035
|
// Match compaction messages to summaries by chronological order
|
|
3018
3036
|
const compactedMsgs = messages
|
|
3019
3037
|
.filter(m => m.systemLabel === 'Compacted')
|
|
@@ -3031,29 +3049,30 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
3031
3049
|
delete msg.promptId;
|
|
3032
3050
|
}
|
|
3033
3051
|
res.json({ messages, hasMore, sessionId: req.params.sessionId });
|
|
3034
|
-
}
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
|
|
3035
3055
|
|
|
3036
|
-
app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
|
|
3056
|
+
app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
|
|
3037
3057
|
const metadata = loadSessionMetadata();
|
|
3038
3058
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3039
3059
|
const jsonlPath = meta?.jsonlPath;
|
|
3040
3060
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3041
|
-
const content = readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
3061
|
+
const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
3042
3062
|
if (content == null) return res.status(404).json({ error: 'tool result not found' });
|
|
3043
3063
|
res.json({ toolUseId: req.params.toolUseId, content });
|
|
3044
|
-
});
|
|
3064
|
+
}));
|
|
3045
3065
|
|
|
3046
3066
|
const toolStatsCache = new Map();
|
|
3047
3067
|
|
|
3048
|
-
function buildToolStats(jsonlPath) {
|
|
3068
|
+
async function buildToolStats(jsonlPath) {
|
|
3049
3069
|
const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
|
|
3050
3070
|
const seenResults = new Set();
|
|
3051
3071
|
const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
|
|
3052
3072
|
const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
|
|
3053
3073
|
const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
|
|
3054
3074
|
|
|
3055
|
-
const
|
|
3056
|
-
for (const line of content.split('\n')) {
|
|
3075
|
+
for await (const line of readLines(jsonlPath)) {
|
|
3057
3076
|
if (!line) continue;
|
|
3058
3077
|
let obj;
|
|
3059
3078
|
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
@@ -3140,13 +3159,13 @@ function buildToolStats(jsonlPath) {
|
|
|
3140
3159
|
return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
|
|
3141
3160
|
}
|
|
3142
3161
|
|
|
3143
|
-
app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
|
|
3162
|
+
app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
|
|
3144
3163
|
const metadata = loadSessionMetadata();
|
|
3145
3164
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3146
3165
|
const jsonlPath = meta?.jsonlPath;
|
|
3147
3166
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3148
3167
|
try {
|
|
3149
|
-
const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
|
|
3168
|
+
const data = await cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
|
|
3150
3169
|
if (!data) return res.status(404).json({ error: 'could not parse session' });
|
|
3151
3170
|
res.json({ sessionId: req.params.sessionId, ...data });
|
|
3152
3171
|
} catch (e) {
|
|
@@ -3154,31 +3173,24 @@ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
|
|
|
3154
3173
|
}
|
|
3155
3174
|
});
|
|
3156
3175
|
|
|
3157
|
-
|
|
3176
|
+
async function sendTranscriptImage(req, res, read) {
|
|
3158
3177
|
const metadata = loadSessionMetadata();
|
|
3159
3178
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3160
3179
|
const jsonlPath = meta?.jsonlPath;
|
|
3161
3180
|
if (!jsonlPath) return res.status(404).end();
|
|
3162
|
-
const img =
|
|
3181
|
+
const img = await read(jsonlPath);
|
|
3163
3182
|
if (!img) return res.status(404).end();
|
|
3164
3183
|
const buf = Buffer.from(img.data, 'base64');
|
|
3165
3184
|
res.setHeader('Content-Type', img.mediaType);
|
|
3166
3185
|
res.setHeader('Cache-Control', 'no-store');
|
|
3167
3186
|
res.end(buf);
|
|
3168
|
-
}
|
|
3187
|
+
}
|
|
3169
3188
|
|
|
3170
|
-
app.get('/api/sessions/:sessionId/
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
const img = readToolResultImage(jsonlPath, req.params.toolUseId, req.params.n);
|
|
3176
|
-
if (!img) return res.status(404).end();
|
|
3177
|
-
const buf = Buffer.from(img.data, 'base64');
|
|
3178
|
-
res.setHeader('Content-Type', img.mediaType);
|
|
3179
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
3180
|
-
res.end(buf);
|
|
3181
|
-
});
|
|
3189
|
+
app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', asyncRoute((req, res) =>
|
|
3190
|
+
sendTranscriptImage(req, res, (p) => readUserImage(p, req.params.msgUuid, req.params.blockIndex))));
|
|
3191
|
+
|
|
3192
|
+
app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', asyncRoute((req, res) =>
|
|
3193
|
+
sendTranscriptImage(req, res, (p) => readToolResultImage(p, req.params.toolUseId, req.params.n))));
|
|
3182
3194
|
|
|
3183
3195
|
app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
|
|
3184
3196
|
const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
|
|
@@ -4248,7 +4260,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
|
|
|
4248
4260
|
}
|
|
4249
4261
|
});
|
|
4250
4262
|
|
|
4251
|
-
// Watch context-status directory for
|
|
4263
|
+
// Watch context-status directory for the plugin's context and cost updates
|
|
4252
4264
|
const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
|
|
4253
4265
|
persistent: true,
|
|
4254
4266
|
ignoreInitial: false,
|
|
@@ -4308,9 +4320,8 @@ async function cleanupAgentActivity() {
|
|
|
4308
4320
|
await fs.rm(dirPath, { recursive: true, force: true });
|
|
4309
4321
|
continue;
|
|
4310
4322
|
}
|
|
4311
|
-
//
|
|
4312
|
-
//
|
|
4313
|
-
// most PERMISSION_TTL_MS, so anything older is unclaimable.
|
|
4323
|
+
// Mods cannot delete files, so every decision file is swept here. The mod
|
|
4324
|
+
// waits at most PERMISSION_TTL_MS, so anything older is unclaimable.
|
|
4314
4325
|
for (const f of contents) {
|
|
4315
4326
|
if (!isDecisionFile(f)) continue;
|
|
4316
4327
|
const fp = path.join(dirPath, f);
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Tracks subagent lifecycle: one append-only JSONL file per agent, grouped by session
|
|
3
|
-
# Layout: <CLAUDE_CONFIG_DIR or ~/.claude>/.cck/agent-activity/{sessionId}/{agentId}.jsonl
|
|
4
|
-
# Each line is a lifecycle event (start | idle | stop). Server folds last-line-wins.
|
|
5
|
-
|
|
6
|
-
INPUT=$(cat)
|
|
7
|
-
|
|
8
|
-
# Single jq call to extract all routing fields
|
|
9
|
-
eval "$(echo "$INPUT" | jq -r '
|
|
10
|
-
@sh "SESSION_ID=\(.session_id // "")",
|
|
11
|
-
@sh "AGENT_ID=\(.agent_id // "")",
|
|
12
|
-
@sh "EVENT=\(.hook_event_name // "")",
|
|
13
|
-
@sh "TOOL_NAME=\(.tool_name // "")",
|
|
14
|
-
@sh "AGENT_TYPE_RAW=\(.agent_type // "")",
|
|
15
|
-
@sh "TEAMMATE_NAME=\(.teammate_name // "")"
|
|
16
|
-
')"
|
|
17
|
-
|
|
18
|
-
[ -z "$SESSION_ID" ] && exit 0
|
|
19
|
-
|
|
20
|
-
CCK_ACTIVITY="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck/agent-activity"
|
|
21
|
-
|
|
22
|
-
# Map session to custom task list on session start
|
|
23
|
-
if [ "$EVENT" = "SessionStart" ]; then
|
|
24
|
-
TASK_LIST_ID="${CLAUDE_CODE_TASK_LIST_ID:-}"
|
|
25
|
-
if [ -n "$TASK_LIST_ID" ]; then
|
|
26
|
-
CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
|
|
27
|
-
MAPS_DIR="$CCK_ACTIVITY/_task-maps"
|
|
28
|
-
mkdir -p "$MAPS_DIR"
|
|
29
|
-
MAP_FILE="$MAPS_DIR/$TASK_LIST_ID.json"
|
|
30
|
-
TMP_FILE="$MAP_FILE.$$"
|
|
31
|
-
TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
32
|
-
EXISTING="{}"
|
|
33
|
-
[ -f "$MAP_FILE" ] && EXISTING=$(cat "$MAP_FILE")
|
|
34
|
-
echo "$EXISTING" | jq -c --arg sid "$SESSION_ID" --arg cwd "$CWD" --arg ts "$TS" \
|
|
35
|
-
'.[$sid] = {project: $cwd, updatedAt: $ts}' > "$TMP_FILE" && mv "$TMP_FILE" "$MAP_FILE"
|
|
36
|
-
fi
|
|
37
|
-
exit 0
|
|
38
|
-
fi
|
|
39
|
-
|
|
40
|
-
# Stop: the session finished its turn. The marker means unread; the board deletes it on open.
|
|
41
|
-
if [ "$EVENT" = "Stop" ]; then
|
|
42
|
-
DIR="$CCK_ACTIVITY/$SESSION_ID"
|
|
43
|
-
[ -d "$DIR" ] || mkdir -p "$DIR"
|
|
44
|
-
: > "$DIR/_stop.json"
|
|
45
|
-
exit 0
|
|
46
|
-
fi
|
|
47
|
-
|
|
48
|
-
# PostToolUse / non-waiting PreToolUse: clear waiting state, and unread since the session is working again
|
|
49
|
-
if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ] && [ "$TOOL_NAME" != "ExitPlanMode" ]; }; then
|
|
50
|
-
rm -f "$CCK_ACTIVITY/$SESSION_ID/_waiting.json" "$CCK_ACTIVITY/$SESSION_ID/_stop.json"
|
|
51
|
-
[ "$EVENT" = "PostToolUse" ] && exit 0
|
|
52
|
-
fi
|
|
53
|
-
|
|
54
|
-
# EnterPlanMode has no waiting semantics — skip
|
|
55
|
-
[ "$TOOL_NAME" = "EnterPlanMode" ] && exit 0
|
|
56
|
-
|
|
57
|
-
# Legacy-config shim: shipped hooks.json routes PermissionRequest to
|
|
58
|
-
# approval-gate.sh (which owns the id-bearing marker) and registers no
|
|
59
|
-
# PreToolUse, so the suppression and writer below only run for configs that
|
|
60
|
-
# still route those events here. Suppressing question/plan PermissionRequest
|
|
61
|
-
# keeps such a config from overwriting the gate's marker with an id-less one.
|
|
62
|
-
if [ "$EVENT" = "PermissionRequest" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; then
|
|
63
|
-
exit 0
|
|
64
|
-
fi
|
|
65
|
-
|
|
66
|
-
# Waiting-for-user events → write _waiting.json marker
|
|
67
|
-
if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; }; then
|
|
68
|
-
DIR="$CCK_ACTIVITY/$SESSION_ID"
|
|
69
|
-
mkdir -p "$DIR"
|
|
70
|
-
KIND="permission"
|
|
71
|
-
[ "$EVENT" = "PreToolUse" ] && KIND="question"
|
|
72
|
-
TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
73
|
-
echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" '{
|
|
74
|
-
status: "waiting",
|
|
75
|
-
kind: $kind,
|
|
76
|
-
toolName: (.tool_name // "unknown"),
|
|
77
|
-
toolInput: ((.tool_input | tostring) // ""),
|
|
78
|
-
timestamp: $ts
|
|
79
|
-
}' > "$DIR/_waiting.json"
|
|
80
|
-
exit 0
|
|
81
|
-
fi
|
|
82
|
-
|
|
83
|
-
# TeammateIdle has no agent_id — resolve via name→id mapping file
|
|
84
|
-
if [ "$EVENT" = "TeammateIdle" ] && [ -z "$AGENT_ID" ] && [ -n "$TEAMMATE_NAME" ]; then
|
|
85
|
-
DIR="$CCK_ACTIVITY/$SESSION_ID"
|
|
86
|
-
MAP_FILE="$DIR/_name-${TEAMMATE_NAME}.id"
|
|
87
|
-
[ ! -f "$MAP_FILE" ] && exit 0
|
|
88
|
-
AGENT_ID=$(cat "$MAP_FILE")
|
|
89
|
-
[ -z "$AGENT_ID" ] && exit 0
|
|
90
|
-
FILE="$DIR/$AGENT_ID.jsonl"
|
|
91
|
-
TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
92
|
-
echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$TEAMMATE_NAME\",\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
|
|
93
|
-
exit 0
|
|
94
|
-
fi
|
|
95
|
-
|
|
96
|
-
[ -z "$AGENT_ID" ] && exit 0
|
|
97
|
-
|
|
98
|
-
DIR="$CCK_ACTIVITY/$SESSION_ID"
|
|
99
|
-
FILE="$DIR/$AGENT_ID.jsonl"
|
|
100
|
-
|
|
101
|
-
# On Start: skip if no type (internal agents like AskUserQuestion)
|
|
102
|
-
# On Stop/Idle: only skip if no existing file (never tracked)
|
|
103
|
-
if [ -z "$AGENT_TYPE_RAW" ]; then
|
|
104
|
-
if [ "$EVENT" = "SubagentStart" ]; then
|
|
105
|
-
exit 0
|
|
106
|
-
elif [ ! -f "$FILE" ]; then
|
|
107
|
-
exit 0
|
|
108
|
-
fi
|
|
109
|
-
fi
|
|
110
|
-
|
|
111
|
-
mkdir -p "$DIR"
|
|
112
|
-
TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
113
|
-
|
|
114
|
-
if [ "$EVENT" = "SubagentStart" ]; then
|
|
115
|
-
echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$AGENT_TYPE_RAW\",\"event\":\"start\",\"status\":\"active\",\"startedAt\":\"$TS\",\"updatedAt\":\"$TS\"}" >> "$FILE"
|
|
116
|
-
# Mapping always points at latest agent of this type (used by TeammateIdle resolution).
|
|
117
|
-
if [ -n "$AGENT_TYPE_RAW" ]; then
|
|
118
|
-
echo -n "$AGENT_ID" > "$DIR/_name-${AGENT_TYPE_RAW}.id"
|
|
119
|
-
fi
|
|
120
|
-
|
|
121
|
-
elif [ "$EVENT" = "SubagentStop" ]; then
|
|
122
|
-
# Omit empty type: the server folds lines last-key-wins, so an empty type
|
|
123
|
-
# here would clobber the type recorded by the start line
|
|
124
|
-
echo "$INPUT" | jq -c \
|
|
125
|
-
--arg id "$AGENT_ID" --arg type "$AGENT_TYPE_RAW" --arg ts "$TS" \
|
|
126
|
-
'{agentId: $id, event: "stop", status: "stopped",
|
|
127
|
-
lastMessage: (.last_assistant_message // ""), stoppedAt: $ts, updatedAt: $ts}
|
|
128
|
-
+ (if $type == "" then {} else {type: $type} end)' \
|
|
129
|
-
>> "$FILE"
|
|
130
|
-
|
|
131
|
-
elif [ "$EVENT" = "TeammateIdle" ]; then
|
|
132
|
-
TYPE_FIELD=""
|
|
133
|
-
[ -n "$AGENT_TYPE_RAW" ] && TYPE_FIELD="\"type\":\"$AGENT_TYPE_RAW\","
|
|
134
|
-
echo "{\"agentId\":\"$AGENT_ID\",${TYPE_FIELD}\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
|
|
135
|
-
fi
|