claude-code-kanban 5.3.0 → 6.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/README.md +4 -4
- package/cli.js +30 -108
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +62 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal.js +30 -5
- 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/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- 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 +257 -74
- package/public/index.html +12 -2
- package/public/project-match.js +6 -1
- package/public/style.css +99 -0
- package/server.js +146 -147
- package/skill-guides/dispatch.md +16 -54
- 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,17 +35,19 @@ 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');
|
|
42
43
|
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
|
|
43
44
|
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
44
45
|
const { createProcStats } = require('./lib/proc-stats');
|
|
45
|
-
const { createDispatchRegistry
|
|
46
|
+
const { createDispatchRegistry } = require('./lib/dispatch');
|
|
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, sessionGitBranch } = 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,15 +343,6 @@ const worktrees = createWorktreeStore({
|
|
|
349
343
|
save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
|
|
350
344
|
});
|
|
351
345
|
|
|
352
|
-
// Only spawn git when cwd has diverged from the launch project — that's the
|
|
353
|
-
// only case the JSONL value is wrong. Saves N spawns on a typical list build.
|
|
354
|
-
function resolveSessionGitBranch(meta) {
|
|
355
|
-
if (meta.cwd && meta.project && meta.cwd !== meta.project) {
|
|
356
|
-
return getGitBranch(meta.cwd) || meta.gitBranch || null;
|
|
357
|
-
}
|
|
358
|
-
return meta.gitBranch || null;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
346
|
function getSessionLogStat(meta) {
|
|
362
347
|
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
|
|
363
348
|
try {
|
|
@@ -366,13 +351,25 @@ function getSessionLogStat(meta) {
|
|
|
366
351
|
} catch { return { mtime: null, hasMessages: false }; }
|
|
367
352
|
}
|
|
368
353
|
|
|
354
|
+
// A transcript write past the grace window after a marker means the session went on.
|
|
355
|
+
function movedOnSince(markerMs, logMtime) {
|
|
356
|
+
return !!logMtime && logMtime > markerMs + WAITING_RESOLVE_GRACE_MS;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The mod cannot delete _stop.json when the session works again.
|
|
360
|
+
function resumedSince(markerPath, logMtime) {
|
|
361
|
+
if (!logMtime) return false;
|
|
362
|
+
try { return movedOnSince(statSync(markerPath).mtimeMs, logMtime); }
|
|
363
|
+
catch { return true; }
|
|
364
|
+
}
|
|
365
|
+
|
|
369
366
|
function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
370
367
|
const result = { hasActive: false, hasRunning: false, waitingForUser: null, unread: false };
|
|
371
368
|
let names;
|
|
372
369
|
try { names = readdirSync(agentDir); } catch { return result; }
|
|
373
370
|
if (names.includes('_waiting.json')) result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
|
|
374
371
|
if (result.waitingForUser) result.hasActive = true;
|
|
375
|
-
const stopped = names.includes('_stop.json');
|
|
372
|
+
const stopped = names.includes('_stop.json') && !resumedSince(path.join(agentDir, '_stop.json'), logMtime);
|
|
376
373
|
if (stale && !isTeam) {
|
|
377
374
|
result.unread = stopped;
|
|
378
375
|
return result;
|
|
@@ -774,33 +771,46 @@ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
|
|
|
774
771
|
return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
|
|
775
772
|
}
|
|
776
773
|
|
|
774
|
+
// A promise from loadFn is cached as is, so concurrent callers share one read, and a newer
|
|
775
|
+
// mtime starts no second read while it is pending: full-transcript scans stream for seconds.
|
|
777
776
|
function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
|
|
778
777
|
try {
|
|
779
778
|
const cached = cache.get(cacheKey);
|
|
780
|
-
if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.data;
|
|
779
|
+
if (cached && (cached.pending || Date.now() - cached.ts < MESSAGE_CACHE_TTL)) return cached.data;
|
|
781
780
|
const st = statSync(filePath);
|
|
782
781
|
if (cached && cached.mtime === st.mtimeMs) {
|
|
783
782
|
cached.ts = Date.now();
|
|
784
783
|
return cached.data;
|
|
785
784
|
}
|
|
786
|
-
const
|
|
787
|
-
|
|
785
|
+
const entry = { data: loadFn(), mtime: st.mtimeMs, ts: Date.now() };
|
|
786
|
+
if (typeof entry.data?.then === 'function') {
|
|
787
|
+
entry.pending = true;
|
|
788
|
+
entry.data = entry.data.catch(() => {
|
|
789
|
+
if (cache.get(cacheKey) === entry) cache.delete(cacheKey);
|
|
790
|
+
return fallback;
|
|
791
|
+
}).finally(() => {
|
|
792
|
+
entry.pending = false;
|
|
793
|
+
entry.ts = Date.now();
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
cache.set(cacheKey, entry);
|
|
788
797
|
evictStaleCache(cache);
|
|
789
|
-
return data;
|
|
798
|
+
return entry.data;
|
|
790
799
|
} catch (_) { return fallback; }
|
|
791
800
|
}
|
|
792
801
|
|
|
802
|
+
// Express 4 does not pass a rejected handler promise to next(), and Node 24 exits on it.
|
|
803
|
+
function asyncRoute(fn) {
|
|
804
|
+
return (req, res, next) => fn(req, res, next).catch(next);
|
|
805
|
+
}
|
|
806
|
+
|
|
793
807
|
const sessionDigestCache = new Map();
|
|
794
808
|
function getSessionDigest(jsonlPath) {
|
|
795
809
|
return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
|
|
796
810
|
}
|
|
797
811
|
|
|
798
|
-
function getProgressMap(jsonlPath) {
|
|
799
|
-
return getSessionDigest(jsonlPath).progressMap;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function getTerminatedTeammates(jsonlPath) {
|
|
803
|
-
return getSessionDigest(jsonlPath).terminated;
|
|
812
|
+
async function getProgressMap(jsonlPath) {
|
|
813
|
+
return (await getSessionDigest(jsonlPath)).progressMap;
|
|
804
814
|
}
|
|
805
815
|
|
|
806
816
|
function readRecentMessages(jsonlPath, limit = 10) {
|
|
@@ -836,6 +846,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
836
846
|
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
837
847
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
838
848
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
849
|
+
existing.permissionMode = info.permissionMode;
|
|
839
850
|
return true;
|
|
840
851
|
}
|
|
841
852
|
|
|
@@ -922,7 +933,8 @@ function loadSessionMetadata() {
|
|
|
922
933
|
customTitle: sessionInfo.customTitle || null,
|
|
923
934
|
jsonlPath: jsonlPath,
|
|
924
935
|
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
925
|
-
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
|
|
936
|
+
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
|
|
937
|
+
permissionMode: sessionInfo.permissionMode
|
|
926
938
|
};
|
|
927
939
|
sessionIds.push(sessionId);
|
|
928
940
|
}
|
|
@@ -1147,6 +1159,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1147
1159
|
const logStat = overrides._logStat || getSessionLogStat(meta);
|
|
1148
1160
|
const logMtime = logStat.mtime;
|
|
1149
1161
|
const logAge = logMtime ? Date.now() - logMtime : Infinity;
|
|
1162
|
+
const worktree = worktrees.resolve(meta.project);
|
|
1150
1163
|
return {
|
|
1151
1164
|
id,
|
|
1152
1165
|
name: getSessionDisplayName(id, meta),
|
|
@@ -1155,8 +1168,8 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1155
1168
|
project: meta.project || null,
|
|
1156
1169
|
cwd: meta.cwd || null,
|
|
1157
1170
|
description: meta.description || null,
|
|
1158
|
-
gitBranch:
|
|
1159
|
-
worktree
|
|
1171
|
+
gitBranch: sessionGitBranch(meta, !!worktree, getGitBranch),
|
|
1172
|
+
worktree,
|
|
1160
1173
|
customTitle: meta.customTitle || null,
|
|
1161
1174
|
taskCount: 0,
|
|
1162
1175
|
completed: 0,
|
|
@@ -1610,6 +1623,30 @@ app.get('/api/sessions/search', (req, res) => {
|
|
|
1610
1623
|
res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
|
|
1611
1624
|
});
|
|
1612
1625
|
|
|
1626
|
+
app.get('/api/sessions/known', (_req, res) => {
|
|
1627
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1628
|
+
try {
|
|
1629
|
+
const known = new Map();
|
|
1630
|
+
for (const [id, meta] of Object.entries(loadSessionMetadata())) {
|
|
1631
|
+
known.set(id, { id, project: meta.project || null, name: getSessionDisplayName(id, meta) });
|
|
1632
|
+
}
|
|
1633
|
+
const add = (id, project = null) => {
|
|
1634
|
+
if (!known.has(id)) known.set(id, { id, project, name: null });
|
|
1635
|
+
};
|
|
1636
|
+
for (const dir of [TASKS_DIR, AGENT_ACTIVITY_DIR]) {
|
|
1637
|
+
if (!existsSync(dir)) continue;
|
|
1638
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) if (d.isDirectory()) add(d.name);
|
|
1639
|
+
}
|
|
1640
|
+
for (const map of Object.values(loadAllTaskMaps().listToSessions)) {
|
|
1641
|
+
for (const [id, info] of Object.entries(map)) add(id, info.project || null);
|
|
1642
|
+
}
|
|
1643
|
+
res.json([...known.values()]);
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
console.error('Error listing known sessions:', error);
|
|
1646
|
+
res.status(500).json({ error: 'Failed to list known sessions' });
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1613
1650
|
function isTempPath(p) {
|
|
1614
1651
|
const rel = path.relative(TEMP_ROOT, p);
|
|
1615
1652
|
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
@@ -1775,15 +1812,20 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
|
1775
1812
|
|
|
1776
1813
|
// Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
|
|
1777
1814
|
// 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
|
|
1815
|
+
// what gets cached, so concurrent callers share a single read, and a file that grows while
|
|
1816
|
+
// it is pending starts no second one. Null when the file is gone.
|
|
1779
1817
|
function cachedByFileStat(cache, filePath, load) {
|
|
1780
1818
|
let stat;
|
|
1781
1819
|
try { stat = statSync(filePath); } catch (_) { return null; }
|
|
1782
1820
|
const hit = cache.get(filePath);
|
|
1783
|
-
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1821
|
+
if (hit && (hit.pending || (hit.mtimeMs === stat.mtimeMs && hit.size === stat.size))) return hit.value;
|
|
1822
|
+
const entry = { mtimeMs: stat.mtimeMs, size: stat.size, value: load() };
|
|
1823
|
+
if (typeof entry.value?.then === 'function') {
|
|
1824
|
+
entry.pending = true;
|
|
1825
|
+
entry.value.finally(() => { entry.pending = false; }).catch(() => {});
|
|
1826
|
+
}
|
|
1827
|
+
cache.set(filePath, entry);
|
|
1828
|
+
return entry.value;
|
|
1787
1829
|
}
|
|
1788
1830
|
|
|
1789
1831
|
// Cold path — a full transcript scan, so the result is held until the file grows.
|
|
@@ -1794,12 +1836,12 @@ function getArtifactLinks(jsonlPath) {
|
|
|
1794
1836
|
return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
|
|
1795
1837
|
}
|
|
1796
1838
|
|
|
1797
|
-
app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
|
|
1839
|
+
app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
|
|
1798
1840
|
try {
|
|
1799
1841
|
const metadata = loadSessionMetadata();
|
|
1800
1842
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1801
1843
|
if (!meta?.jsonlPath) return res.json({ artifacts: [] });
|
|
1802
|
-
res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
|
|
1844
|
+
res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
|
|
1803
1845
|
} catch (error) {
|
|
1804
1846
|
console.error('Error reading artifacts:', error);
|
|
1805
1847
|
res.status(500).json({ error: 'Failed to read artifacts' });
|
|
@@ -1844,7 +1886,7 @@ async function findPads(root, depth = 0) {
|
|
|
1844
1886
|
// stored, so re-reading a transcript cannot drift; the client links each pad once
|
|
1845
1887
|
// and records that it did, which is what stops a re-read undoing an unlink.
|
|
1846
1888
|
async function readCreatedPads(meta) {
|
|
1847
|
-
const creations = readScratchpadCreations(meta.jsonlPath);
|
|
1889
|
+
const creations = await readScratchpadCreations(meta.jsonlPath);
|
|
1848
1890
|
// No `scratch new` in the transcript means no disk work at all, so sessions that
|
|
1849
1891
|
// never touch the CLI pay a substring pass over the transcript and nothing more.
|
|
1850
1892
|
if (!creations.length) return [];
|
|
@@ -2122,7 +2164,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
2122
2164
|
try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
|
|
2123
2165
|
for (const f of files) {
|
|
2124
2166
|
const agentId = f.slice('agent-'.length, -'.jsonl'.length);
|
|
2125
|
-
const stats = extractTranscriptStats(path.join(runDir, f)) || {};
|
|
2167
|
+
const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
|
|
2126
2168
|
let type = null;
|
|
2127
2169
|
try {
|
|
2128
2170
|
type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
|
|
@@ -2357,9 +2399,9 @@ app.get('/api/teams/:name', (req, res) => {
|
|
|
2357
2399
|
// API: Get agents for a session
|
|
2358
2400
|
// The resultUnavailable latch is persisted on the agent record. Bump this when
|
|
2359
2401
|
// extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
|
|
2360
|
-
const RESULT_SCAN =
|
|
2402
|
+
const RESULT_SCAN = 3;
|
|
2361
2403
|
|
|
2362
|
-
app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
2404
|
+
app.get('/api/sessions/:sessionId/agents', async (req, res) => {
|
|
2363
2405
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2364
2406
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
2365
2407
|
if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
|
|
@@ -2403,10 +2445,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2403
2445
|
agents.push(agent);
|
|
2404
2446
|
} catch { /* skip invalid */ }
|
|
2405
2447
|
}
|
|
2448
|
+
const digest = meta.jsonlPath ? await getSessionDigest(meta.jsonlPath) : null;
|
|
2406
2449
|
const liveAgents = agents.filter(isAgentLive);
|
|
2407
2450
|
if (liveAgents.length && meta.jsonlPath) {
|
|
2408
2451
|
try {
|
|
2409
|
-
const terminated =
|
|
2452
|
+
const { terminated } = digest;
|
|
2410
2453
|
if (terminated.size) {
|
|
2411
2454
|
for (const agent of liveAgents) {
|
|
2412
2455
|
const agentName = agentDisplayName(agent);
|
|
@@ -2423,10 +2466,9 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2423
2466
|
// Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
|
|
2424
2467
|
// the parent will never read their output, so they're orphans. Match by agentId
|
|
2425
2468
|
// when the digest already correlated tool_use→agent, else fall back to prompt text
|
|
2426
|
-
// (the
|
|
2469
|
+
// (the plugin's mod doesn't record the spawning tool_use_id).
|
|
2427
2470
|
try {
|
|
2428
|
-
const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
|
|
2429
|
-
getSessionDigest(meta.jsonlPath);
|
|
2471
|
+
const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } = digest;
|
|
2430
2472
|
if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
|
|
2431
2473
|
for (const agent of liveAgents) {
|
|
2432
2474
|
if (!isAgentLive(agent)) continue;
|
|
@@ -2457,8 +2499,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2457
2499
|
const byAgentId = {};
|
|
2458
2500
|
if (meta.jsonlPath) {
|
|
2459
2501
|
try {
|
|
2460
|
-
const
|
|
2461
|
-
for (const entry of Object.values(progressMap)) {
|
|
2502
|
+
for (const entry of Object.values(digest.progressMap)) {
|
|
2462
2503
|
byAgentId[entry.agentId] ||= {};
|
|
2463
2504
|
const e = byAgentId[entry.agentId];
|
|
2464
2505
|
for (const f of ['prompt', 'name', 'description', 'usage']) {
|
|
@@ -2615,10 +2656,10 @@ app.post('/api/sessions/:sessionId/read', (req, res) => {
|
|
|
2615
2656
|
}
|
|
2616
2657
|
});
|
|
2617
2658
|
|
|
2618
|
-
// UI-driven approvals: answers the ask
|
|
2619
|
-
//
|
|
2620
|
-
//
|
|
2621
|
-
//
|
|
2659
|
+
// UI-driven approvals: answers the ask the plugin's mod is polling for by writing
|
|
2660
|
+
// _decision-<id>.json next to the marker. The mod cannot delete files, so it clears
|
|
2661
|
+
// the marker and leaves the decision, and a write for an ask that is already over
|
|
2662
|
+
// is accepted anyway (D13); cleanupAgentActivity's sweep removes both kinds.
|
|
2622
2663
|
app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
2623
2664
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2624
2665
|
const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
@@ -2935,7 +2976,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
|
|
|
2935
2976
|
res.on('error', cleanup);
|
|
2936
2977
|
});
|
|
2937
2978
|
|
|
2938
|
-
|
|
2979
|
+
async function sendSessionMessages(req, res) {
|
|
2939
2980
|
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
|
|
2940
2981
|
const before = req.query.before || null;
|
|
2941
2982
|
const metadata = loadSessionMetadata();
|
|
@@ -2952,9 +2993,10 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
2952
2993
|
hasMore = messages.length > limit;
|
|
2953
2994
|
if (hasMore) messages = messages.slice(-limit);
|
|
2954
2995
|
}
|
|
2996
|
+
const compactPromise = cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), []);
|
|
2955
2997
|
const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
|
|
2956
2998
|
if (agentMessages.length) {
|
|
2957
|
-
const progressMap = getProgressMap(jsonlPath);
|
|
2999
|
+
const progressMap = await getProgressMap(jsonlPath);
|
|
2958
3000
|
const resolvedSid = resolveSessionId(req.params.sessionId);
|
|
2959
3001
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedSid);
|
|
2960
3002
|
for (const msg of agentMessages) {
|
|
@@ -3005,15 +3047,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
3005
3047
|
}
|
|
3006
3048
|
}
|
|
3007
3049
|
}
|
|
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
|
-
}
|
|
3050
|
+
const compactSummaries = await compactPromise;
|
|
3017
3051
|
// Match compaction messages to summaries by chronological order
|
|
3018
3052
|
const compactedMsgs = messages
|
|
3019
3053
|
.filter(m => m.systemLabel === 'Compacted')
|
|
@@ -3031,29 +3065,30 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
3031
3065
|
delete msg.promptId;
|
|
3032
3066
|
}
|
|
3033
3067
|
res.json({ messages, hasMore, sessionId: req.params.sessionId });
|
|
3034
|
-
}
|
|
3068
|
+
}
|
|
3035
3069
|
|
|
3036
|
-
app.get('/api/sessions/:sessionId/
|
|
3070
|
+
app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
|
|
3071
|
+
|
|
3072
|
+
app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
|
|
3037
3073
|
const metadata = loadSessionMetadata();
|
|
3038
3074
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3039
3075
|
const jsonlPath = meta?.jsonlPath;
|
|
3040
3076
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3041
|
-
const content = readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
3077
|
+
const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
3042
3078
|
if (content == null) return res.status(404).json({ error: 'tool result not found' });
|
|
3043
3079
|
res.json({ toolUseId: req.params.toolUseId, content });
|
|
3044
|
-
});
|
|
3080
|
+
}));
|
|
3045
3081
|
|
|
3046
3082
|
const toolStatsCache = new Map();
|
|
3047
3083
|
|
|
3048
|
-
function buildToolStats(jsonlPath) {
|
|
3084
|
+
async function buildToolStats(jsonlPath) {
|
|
3049
3085
|
const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
|
|
3050
3086
|
const seenResults = new Set();
|
|
3051
3087
|
const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
|
|
3052
3088
|
const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
|
|
3053
3089
|
const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
|
|
3054
3090
|
|
|
3055
|
-
const
|
|
3056
|
-
for (const line of content.split('\n')) {
|
|
3091
|
+
for await (const line of readLines(jsonlPath)) {
|
|
3057
3092
|
if (!line) continue;
|
|
3058
3093
|
let obj;
|
|
3059
3094
|
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
@@ -3140,13 +3175,13 @@ function buildToolStats(jsonlPath) {
|
|
|
3140
3175
|
return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
|
|
3141
3176
|
}
|
|
3142
3177
|
|
|
3143
|
-
app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
|
|
3178
|
+
app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
|
|
3144
3179
|
const metadata = loadSessionMetadata();
|
|
3145
3180
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3146
3181
|
const jsonlPath = meta?.jsonlPath;
|
|
3147
3182
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3148
3183
|
try {
|
|
3149
|
-
const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
|
|
3184
|
+
const data = await cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
|
|
3150
3185
|
if (!data) return res.status(404).json({ error: 'could not parse session' });
|
|
3151
3186
|
res.json({ sessionId: req.params.sessionId, ...data });
|
|
3152
3187
|
} catch (e) {
|
|
@@ -3154,31 +3189,24 @@ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
|
|
|
3154
3189
|
}
|
|
3155
3190
|
});
|
|
3156
3191
|
|
|
3157
|
-
|
|
3192
|
+
async function sendTranscriptImage(req, res, read) {
|
|
3158
3193
|
const metadata = loadSessionMetadata();
|
|
3159
3194
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3160
3195
|
const jsonlPath = meta?.jsonlPath;
|
|
3161
3196
|
if (!jsonlPath) return res.status(404).end();
|
|
3162
|
-
const img =
|
|
3197
|
+
const img = await read(jsonlPath);
|
|
3163
3198
|
if (!img) return res.status(404).end();
|
|
3164
3199
|
const buf = Buffer.from(img.data, 'base64');
|
|
3165
3200
|
res.setHeader('Content-Type', img.mediaType);
|
|
3166
3201
|
res.setHeader('Cache-Control', 'no-store');
|
|
3167
3202
|
res.end(buf);
|
|
3168
|
-
}
|
|
3203
|
+
}
|
|
3169
3204
|
|
|
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
|
-
});
|
|
3205
|
+
app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', asyncRoute((req, res) =>
|
|
3206
|
+
sendTranscriptImage(req, res, (p) => readUserImage(p, req.params.msgUuid, req.params.blockIndex))));
|
|
3207
|
+
|
|
3208
|
+
app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', asyncRoute((req, res) =>
|
|
3209
|
+
sendTranscriptImage(req, res, (p) => readToolResultImage(p, req.params.toolUseId, req.params.n))));
|
|
3182
3210
|
|
|
3183
3211
|
app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
|
|
3184
3212
|
const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
|
|
@@ -3264,7 +3292,9 @@ const terminal = createTerminalService({
|
|
|
3264
3292
|
isAllowedFolder,
|
|
3265
3293
|
liveSessions: () => loadLiveSessions(true),
|
|
3266
3294
|
onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
|
|
3267
|
-
onExit: (id) =>
|
|
3295
|
+
onExit: (id) => {
|
|
3296
|
+
if (dispatches.remove(id)) broadcast({ type: 'dispatch-update' });
|
|
3297
|
+
},
|
|
3268
3298
|
});
|
|
3269
3299
|
|
|
3270
3300
|
let folderDialogOpen = false;
|
|
@@ -3334,13 +3364,7 @@ const dispatched = createDispatchedStore({
|
|
|
3334
3364
|
save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
|
|
3335
3365
|
});
|
|
3336
3366
|
|
|
3337
|
-
const dispatches = createDispatchRegistry(
|
|
3338
|
-
onSettle: (r) => {
|
|
3339
|
-
if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
|
|
3340
|
-
if (r.session) dispatched.settle(r.session, r.status);
|
|
3341
|
-
broadcast({ type: 'dispatch-update' });
|
|
3342
|
-
},
|
|
3343
|
-
});
|
|
3367
|
+
const dispatches = createDispatchRegistry();
|
|
3344
3368
|
|
|
3345
3369
|
const dispatchGroups = createGroupStore({
|
|
3346
3370
|
load: () => {
|
|
@@ -3359,8 +3383,6 @@ const linkedDocs = createLinkedDocStore({
|
|
|
3359
3383
|
});
|
|
3360
3384
|
|
|
3361
3385
|
// The board places a session from these alone, so it never has to move it later.
|
|
3362
|
-
// `startedBy` lets it follow a starter the user put in a named group, which only the
|
|
3363
|
-
// browser knows; it goes once the dispatch settles. `dispatched` stays for the card's marker.
|
|
3364
3386
|
function withDispatchPlacement(sessions) {
|
|
3365
3387
|
const groups = dispatchGroups.snapshot();
|
|
3366
3388
|
return sessions.map((s) => {
|
|
@@ -3370,56 +3392,35 @@ function withDispatchPlacement(sessions) {
|
|
|
3370
3392
|
return {
|
|
3371
3393
|
...s,
|
|
3372
3394
|
dispatchGroup,
|
|
3373
|
-
|
|
3374
|
-
dispatched: marker ? { parent: marker.parent, outcome: DISPATCH_OUTCOME[marker.status] } : undefined,
|
|
3395
|
+
dispatched: marker ? { parent: marker.parent } : undefined,
|
|
3375
3396
|
};
|
|
3376
3397
|
});
|
|
3377
3398
|
}
|
|
3378
3399
|
|
|
3379
3400
|
// Starting a session needs the terminal token, as the browser does; the CLI reads it from
|
|
3380
|
-
// TERMINAL_TOKEN_FILE. The
|
|
3401
|
+
// TERMINAL_TOKEN_FILE. The started session never holds it.
|
|
3381
3402
|
app.post('/api/dispatch', (req, res) => {
|
|
3382
3403
|
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3383
|
-
const { cwd, spec, name, model, worktree, parent, group,
|
|
3404
|
+
const { cwd, spec, name, model, worktree, parent, group, claudeArgs } = req.body || {};
|
|
3384
3405
|
if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
|
|
3385
|
-
if (peer != null && !isPeerName(peer)) return res.status(400).json({ error: 'peer must be a peer name as ListAgents prints it' });
|
|
3386
3406
|
if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
|
|
3387
3407
|
if (group != null && !isGroupName(group)) {
|
|
3388
3408
|
return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
|
|
3389
3409
|
}
|
|
3390
|
-
const
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
|
|
3394
|
-
if (started.error) {
|
|
3395
|
-
dispatches.discard(r.id);
|
|
3396
|
-
return res.status(started.status).json({ error: started.error });
|
|
3397
|
-
}
|
|
3398
|
-
dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
|
|
3410
|
+
const started = terminal.startNew({ cwd, name, model, worktree, prompt: spec.trim(), extraArgs: claudeArgs });
|
|
3411
|
+
if (started.error) return res.status(started.status).json({ error: started.error });
|
|
3412
|
+
dispatches.add({ session: started.id, parent, cwd: started.cwd, name, group: group || null, worktree });
|
|
3399
3413
|
dispatched.record(started.id, parent);
|
|
3400
3414
|
// The starter stays where it is: moving it would jump it under the user.
|
|
3401
|
-
if (
|
|
3415
|
+
if (group) dispatchGroups.join(group, [started.id]);
|
|
3402
3416
|
broadcast({ type: 'dispatch-update' });
|
|
3403
|
-
res.status(201).json({
|
|
3417
|
+
res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
|
|
3404
3418
|
});
|
|
3405
3419
|
|
|
3406
|
-
app.
|
|
3407
|
-
const { cap, outcome, summary } = req.body || {};
|
|
3408
|
-
const err = dispatches.settle(req.params.id, cap, outcome, summary);
|
|
3409
|
-
if (err) return res.status(err.status).json({ error: err.error });
|
|
3410
|
-
res.status(204).end();
|
|
3411
|
-
});
|
|
3412
|
-
|
|
3413
|
-
app.get('/api/dispatch', async (req, res) => {
|
|
3420
|
+
app.get('/api/dispatch', (req, res) => {
|
|
3414
3421
|
res.setHeader('Cache-Control', 'no-store');
|
|
3415
|
-
const ids = typeof req.query.ids === 'string' && req.query.ids ? req.query.ids.split(',') : null;
|
|
3416
3422
|
const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
|
|
3417
|
-
|
|
3418
|
-
const out = await dispatches.wait({ ids, parent }, req.query.wait, (stop) => {
|
|
3419
|
-
res.on('close', stop);
|
|
3420
|
-
return () => res.off('close', stop);
|
|
3421
|
-
});
|
|
3422
|
-
if (!res.writableEnded) res.json(out);
|
|
3423
|
+
res.json({ running: dispatches.list({ parent }) });
|
|
3423
3424
|
});
|
|
3424
3425
|
// #endregion
|
|
3425
3426
|
|
|
@@ -3484,7 +3485,6 @@ const {
|
|
|
3484
3485
|
formatTaskMoved,
|
|
3485
3486
|
handleSessionEvents,
|
|
3486
3487
|
hasSessionListener,
|
|
3487
|
-
topicKey,
|
|
3488
3488
|
} = require('./lib/session-events');
|
|
3489
3489
|
app.get('/api/sessions/:sessionId/events', handleSessionEvents);
|
|
3490
3490
|
|
|
@@ -4248,7 +4248,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
|
|
|
4248
4248
|
}
|
|
4249
4249
|
});
|
|
4250
4250
|
|
|
4251
|
-
// Watch context-status directory for
|
|
4251
|
+
// Watch context-status directory for the plugin's context and cost updates
|
|
4252
4252
|
const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
|
|
4253
4253
|
persistent: true,
|
|
4254
4254
|
ignoreInitial: false,
|
|
@@ -4308,9 +4308,8 @@ async function cleanupAgentActivity() {
|
|
|
4308
4308
|
await fs.rm(dirPath, { recursive: true, force: true });
|
|
4309
4309
|
continue;
|
|
4310
4310
|
}
|
|
4311
|
-
//
|
|
4312
|
-
//
|
|
4313
|
-
// most PERMISSION_TTL_MS, so anything older is unclaimable.
|
|
4311
|
+
// Mods cannot delete files, so every decision file is swept here. The mod
|
|
4312
|
+
// waits at most PERMISSION_TTL_MS, so anything older is unclaimable.
|
|
4314
4313
|
for (const f of contents) {
|
|
4315
4314
|
if (!isDecisionFile(f)) continue;
|
|
4316
4315
|
const fp = path.join(dirPath, f);
|