claude-code-kanban 5.2.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/claude-dir.js +7 -1
- package/lib/git-branch.js +47 -0
- package/lib/net-guard.js +41 -14
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +12 -9
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +19 -5
- package/lib/worktrees.js +105 -0
- 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/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +0 -1
- 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 +661 -380
- package/public/index.html +7 -3
- package/public/project-match.js +6 -4
- package/public/style.css +258 -79
- package/public/terminal-frame.js +341 -0
- package/public/terminal.html +27 -0
- package/public/vendor/claude-hub-sdk.js +13 -3
- package/server.js +163 -145
- package/skill-guides/dispatch.md +4 -4
- 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,26 +35,30 @@ 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
|
-
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
|
|
43
|
+
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
|
|
43
44
|
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
45
|
+
const { createProcStats } = require('./lib/proc-stats');
|
|
44
46
|
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
|
|
45
47
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
46
|
-
const { createDispatchedStore,
|
|
48
|
+
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
|
|
49
|
+
const { createWorktreeStore } = require('./lib/worktrees');
|
|
50
|
+
const { readGitBranch } = require('./lib/git-branch');
|
|
47
51
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
48
52
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
49
53
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
50
54
|
const { countTaskDir } = require('./lib/task-counts');
|
|
51
|
-
const { projectMatcher } = require('./public/project-match');
|
|
55
|
+
const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
|
|
52
56
|
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
53
57
|
|
|
54
58
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
55
59
|
const { runInstall, runUninstall } = require("./install");
|
|
56
|
-
const
|
|
57
|
-
(process.argv.includes("--install") ? runInstall({
|
|
60
|
+
const yes = process.argv.includes("--yes");
|
|
61
|
+
(process.argv.includes("--install") ? runInstall({ yes }) : runUninstall())
|
|
58
62
|
.then(() => process.exit(0))
|
|
59
63
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
60
64
|
} else if (!require("./cli").runCli(process.argv)) {
|
|
@@ -69,7 +73,8 @@ const PORT = parseInt(getArgValue('port') || process.env.PORT || '3541', 10);
|
|
|
69
73
|
|
|
70
74
|
// Mounted before express.json() so a rejected request never buffers a body, and
|
|
71
75
|
// before the /api catch-all so no route escapes the check.
|
|
72
|
-
|
|
76
|
+
// The board frames the terminal from its other loopback name, so Chrome runs it in its own process.
|
|
77
|
+
const net = createNetGuard({ appName: 'Claude Task Kanban', selfFramedPaths: ['/terminal.html'] });
|
|
73
78
|
app.use(net.hostGuard);
|
|
74
79
|
app.use(net.frameGuard);
|
|
75
80
|
app.use(net.originGuard);
|
|
@@ -93,6 +98,7 @@ const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
|
93
98
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
94
99
|
const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
|
|
95
100
|
const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
|
|
101
|
+
const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
|
|
96
102
|
const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
|
|
97
103
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
98
104
|
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
@@ -132,7 +138,7 @@ function writePins(pins) {
|
|
|
132
138
|
writeJsonAtomic(PINS_FILE, pins);
|
|
133
139
|
}
|
|
134
140
|
|
|
135
|
-
// Port discovery for out-of-process helpers (the postman monitor,
|
|
141
|
+
// Port discovery for out-of-process helpers (the postman monitor, the CLI).
|
|
136
142
|
// The pid rides along so a reader can tell a live server from a file left behind by
|
|
137
143
|
// a crashed one.
|
|
138
144
|
function writeServerInfo(port) {
|
|
@@ -161,8 +167,8 @@ function ownerPid(file) {
|
|
|
161
167
|
try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
// A beacon that outlives its server
|
|
165
|
-
//
|
|
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
|
|
166
172
|
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
167
173
|
// the way out. Only when the file is still ours: a newer server on the same config
|
|
168
174
|
// dir has already claimed it. The terminal token file goes the same way.
|
|
@@ -267,7 +273,10 @@ function checkWaitingForUser(agentDir, logMtime) {
|
|
|
267
273
|
const age = Date.now() - waitTime;
|
|
268
274
|
if (age >= PERMISSION_TTL_MS) return null;
|
|
269
275
|
// After grace period, check if session resumed activity (user already responded)
|
|
270
|
-
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;
|
|
271
280
|
// Opted out reads the same as lapsed to the board: the ask is only
|
|
272
281
|
// answerable in the terminal, so no buttons.
|
|
273
282
|
if (boardRefusal(data, approvalsConfig())) return { ...data, lapsed: true };
|
|
@@ -309,7 +318,7 @@ function isAgentLive(agent) {
|
|
|
309
318
|
// Claude Code records gitBranch from the launch-time repo and never updates it
|
|
310
319
|
// when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
|
|
311
320
|
// the live cwd instead. Cached per-cwd with a short TTL so a list refresh
|
|
312
|
-
// across N sessions sharing one cwd
|
|
321
|
+
// across N sessions sharing one cwd reads HEAD at most once per TTL window.
|
|
313
322
|
const gitBranchCache = new Map();
|
|
314
323
|
const GIT_BRANCH_TTL_MS = 30000;
|
|
315
324
|
const GIT_BRANCH_CACHE_MAX = 500;
|
|
@@ -318,18 +327,7 @@ function getGitBranch(cwd) {
|
|
|
318
327
|
const now = Date.now();
|
|
319
328
|
const cached = gitBranchCache.get(cwd);
|
|
320
329
|
if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
|
|
321
|
-
|
|
322
|
-
try {
|
|
323
|
-
// cwd rather than `-C <cwd>`: a path beginning with a dash would otherwise be
|
|
324
|
-
// read by git as an option.
|
|
325
|
-
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
326
|
-
cwd, encoding: 'utf8', timeout: 500, windowsHide: true
|
|
327
|
-
});
|
|
328
|
-
if (r.status === 0) {
|
|
329
|
-
const out = (r.stdout || '').trim();
|
|
330
|
-
if (out && out !== 'HEAD') branch = out;
|
|
331
|
-
}
|
|
332
|
-
} catch (_) {}
|
|
330
|
+
const branch = readGitBranch(cwd);
|
|
333
331
|
gitBranchCache.set(cwd, { branch, ts: now });
|
|
334
332
|
if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
|
|
335
333
|
const firstKey = gitBranchCache.keys().next().value;
|
|
@@ -338,36 +336,15 @@ function getGitBranch(cwd) {
|
|
|
338
336
|
return branch;
|
|
339
337
|
}
|
|
340
338
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const WORKTREE_CACHE_MAX = 500;
|
|
348
|
-
const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
349
|
-
|
|
350
|
-
function resolveWorktree(dir) {
|
|
351
|
-
if (!dir) return null;
|
|
352
|
-
if (worktreeCache.has(dir)) return worktreeCache.get(dir);
|
|
353
|
-
|
|
354
|
-
let worktree = null;
|
|
355
|
-
try {
|
|
356
|
-
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
|
|
357
|
-
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
358
|
-
const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
|
|
359
|
-
if (m) worktree = { repo: m[1], name: m[2] };
|
|
360
|
-
} catch (_) {}
|
|
361
|
-
|
|
362
|
-
worktreeCache.set(dir, worktree);
|
|
363
|
-
if (worktreeCache.size > WORKTREE_CACHE_MAX) {
|
|
364
|
-
worktreeCache.delete(worktreeCache.keys().next().value);
|
|
365
|
-
}
|
|
366
|
-
return worktree;
|
|
367
|
-
}
|
|
339
|
+
const worktrees = createWorktreeStore({
|
|
340
|
+
load: () => {
|
|
341
|
+
try { return JSON.parse(readFileSync(WORKTREES_FILE, 'utf8')); } catch { return null; }
|
|
342
|
+
},
|
|
343
|
+
save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
|
|
344
|
+
});
|
|
368
345
|
|
|
369
|
-
// Only
|
|
370
|
-
// 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.
|
|
371
348
|
function resolveSessionGitBranch(meta) {
|
|
372
349
|
if (meta.cwd && meta.project && meta.cwd !== meta.project) {
|
|
373
350
|
return getGitBranch(meta.cwd) || meta.gitBranch || null;
|
|
@@ -383,13 +360,25 @@ function getSessionLogStat(meta) {
|
|
|
383
360
|
} catch { return { mtime: null, hasMessages: false }; }
|
|
384
361
|
}
|
|
385
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
|
+
|
|
386
375
|
function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
387
376
|
const result = { hasActive: false, hasRunning: false, waitingForUser: null, unread: false };
|
|
388
377
|
let names;
|
|
389
378
|
try { names = readdirSync(agentDir); } catch { return result; }
|
|
390
379
|
if (names.includes('_waiting.json')) result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
|
|
391
380
|
if (result.waitingForUser) result.hasActive = true;
|
|
392
|
-
const stopped = names.includes('_stop.json');
|
|
381
|
+
const stopped = names.includes('_stop.json') && !resumedSince(path.join(agentDir, '_stop.json'), logMtime);
|
|
393
382
|
if (stale && !isTeam) {
|
|
394
383
|
result.unread = stopped;
|
|
395
384
|
return result;
|
|
@@ -791,33 +780,46 @@ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
|
|
|
791
780
|
return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
|
|
792
781
|
}
|
|
793
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.
|
|
794
785
|
function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
|
|
795
786
|
try {
|
|
796
787
|
const cached = cache.get(cacheKey);
|
|
797
|
-
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;
|
|
798
789
|
const st = statSync(filePath);
|
|
799
790
|
if (cached && cached.mtime === st.mtimeMs) {
|
|
800
791
|
cached.ts = Date.now();
|
|
801
792
|
return cached.data;
|
|
802
793
|
}
|
|
803
|
-
const
|
|
804
|
-
|
|
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);
|
|
805
806
|
evictStaleCache(cache);
|
|
806
|
-
return data;
|
|
807
|
+
return entry.data;
|
|
807
808
|
} catch (_) { return fallback; }
|
|
808
809
|
}
|
|
809
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
|
+
|
|
810
816
|
const sessionDigestCache = new Map();
|
|
811
817
|
function getSessionDigest(jsonlPath) {
|
|
812
818
|
return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
|
|
813
819
|
}
|
|
814
820
|
|
|
815
|
-
function getProgressMap(jsonlPath) {
|
|
816
|
-
return getSessionDigest(jsonlPath).progressMap;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function getTerminatedTeammates(jsonlPath) {
|
|
820
|
-
return getSessionDigest(jsonlPath).terminated;
|
|
821
|
+
async function getProgressMap(jsonlPath) {
|
|
822
|
+
return (await getSessionDigest(jsonlPath)).progressMap;
|
|
821
823
|
}
|
|
822
824
|
|
|
823
825
|
function readRecentMessages(jsonlPath, limit = 10) {
|
|
@@ -853,6 +855,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
853
855
|
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
854
856
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
855
857
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
858
|
+
existing.permissionMode = info.permissionMode;
|
|
856
859
|
return true;
|
|
857
860
|
}
|
|
858
861
|
|
|
@@ -939,7 +942,8 @@ function loadSessionMetadata() {
|
|
|
939
942
|
customTitle: sessionInfo.customTitle || null,
|
|
940
943
|
jsonlPath: jsonlPath,
|
|
941
944
|
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
942
|
-
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
|
|
945
|
+
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
|
|
946
|
+
permissionMode: sessionInfo.permissionMode
|
|
943
947
|
};
|
|
944
948
|
sessionIds.push(sessionId);
|
|
945
949
|
}
|
|
@@ -1140,12 +1144,6 @@ function getSessionDisplayName(_sessionId, meta) {
|
|
|
1140
1144
|
return null;
|
|
1141
1145
|
}
|
|
1142
1146
|
|
|
1143
|
-
// The harness spells a project path into a temp/log dir name by replacing every
|
|
1144
|
-
// non-alphanumeric character with a dash, drive colon and separators included.
|
|
1145
|
-
function encodeProjectDirName(p) {
|
|
1146
|
-
return p.replace(/[^a-zA-Z0-9]/g, '-');
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
1147
|
// Derived by convention, not looked up: the harness creates the dir lazily, so a
|
|
1150
1148
|
// stat here would report "missing" for every session that has not written a temp
|
|
1151
1149
|
// file yet — and it would put IO on the session-list hot path. Pure string join.
|
|
@@ -1160,7 +1158,7 @@ function getScratchpadDir(id, meta) {
|
|
|
1160
1158
|
// harness actually created. Probed only for worktree sessions, and only until one
|
|
1161
1159
|
// of them exists — before that there is nothing to disambiguate and `byProject`,
|
|
1162
1160
|
// right for a session started inside the worktree, stands.
|
|
1163
|
-
const wt =
|
|
1161
|
+
const wt = worktrees.resolve(meta.project);
|
|
1164
1162
|
if (!wt || existsSync(byProject)) return byProject;
|
|
1165
1163
|
const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
|
|
1166
1164
|
return existsSync(byRepo) ? byRepo : byProject;
|
|
@@ -1179,7 +1177,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1179
1177
|
cwd: meta.cwd || null,
|
|
1180
1178
|
description: meta.description || null,
|
|
1181
1179
|
gitBranch: resolveSessionGitBranch(meta),
|
|
1182
|
-
worktree:
|
|
1180
|
+
worktree: worktrees.resolve(meta.project),
|
|
1183
1181
|
customTitle: meta.customTitle || null,
|
|
1184
1182
|
taskCount: 0,
|
|
1185
1183
|
completed: 0,
|
|
@@ -1578,7 +1576,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1578
1576
|
// if the row is missing — that one is not a preference.
|
|
1579
1577
|
if (projectFilter) {
|
|
1580
1578
|
const matches = projectMatcher(String(projectFilter));
|
|
1581
|
-
sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
|
|
1579
|
+
sessions = sessions.filter(s => matches(s.project, s.worktree?.repo) || includeIds.has(s.id));
|
|
1582
1580
|
}
|
|
1583
1581
|
// The sidebar's 24h filter: projects with any transcript written in the window, so an older
|
|
1584
1582
|
// session of such a project stays in.
|
|
@@ -1650,14 +1648,30 @@ function projectActivity() {
|
|
|
1650
1648
|
return activity;
|
|
1651
1649
|
}
|
|
1652
1650
|
|
|
1653
|
-
// API: Get distinct project paths with last-modified timestamps
|
|
1651
|
+
// API: Get distinct project paths with last-modified timestamps. A linked worktree is folded
|
|
1652
|
+
// into its repo, which lists it under `worktrees`; the repo row exists even when only its
|
|
1653
|
+
// worktrees have transcripts.
|
|
1654
1654
|
app.get('/api/projects', (_req, res) => {
|
|
1655
1655
|
res.setHeader('Cache-Control', 'no-store');
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1656
|
+
const byRepo = new Map();
|
|
1657
|
+
for (const [project, mtime] of projectActivity()) {
|
|
1658
|
+
const repo = worktrees.resolve(project)?.repo || project;
|
|
1659
|
+
const key = normalizeProjectPath(repo);
|
|
1660
|
+
let row = byRepo.get(key);
|
|
1661
|
+
if (!row) {
|
|
1662
|
+
row = { path: repo, mtime: 0, worktrees: [] };
|
|
1663
|
+
byRepo.set(key, row);
|
|
1664
|
+
}
|
|
1665
|
+
if (repo === project) row.path = project;
|
|
1666
|
+
else row.worktrees.push(project);
|
|
1667
|
+
row.mtime = Math.max(row.mtime, mtime || 0);
|
|
1668
|
+
}
|
|
1669
|
+
const projects = [...byRepo.values()]
|
|
1670
|
+
.map((r) => ({
|
|
1671
|
+
path: r.path,
|
|
1672
|
+
modifiedAt: r.mtime ? new Date(r.mtime).toISOString() : null,
|
|
1673
|
+
...(isTempPath(r.path) && { temp: true }),
|
|
1674
|
+
...(r.worktrees.length && { worktrees: r.worktrees.sort() }),
|
|
1661
1675
|
}))
|
|
1662
1676
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
1663
1677
|
res.json(projects);
|
|
@@ -1782,15 +1796,20 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
|
1782
1796
|
|
|
1783
1797
|
// Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
|
|
1784
1798
|
// it already pays to notice the file grew. `load` may return a promise: the promise is
|
|
1785
|
-
// 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.
|
|
1786
1801
|
function cachedByFileStat(cache, filePath, load) {
|
|
1787
1802
|
let stat;
|
|
1788
1803
|
try { stat = statSync(filePath); } catch (_) { return null; }
|
|
1789
1804
|
const hit = cache.get(filePath);
|
|
1790
|
-
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
|
|
1791
|
-
const
|
|
1792
|
-
|
|
1793
|
-
|
|
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;
|
|
1794
1813
|
}
|
|
1795
1814
|
|
|
1796
1815
|
// Cold path — a full transcript scan, so the result is held until the file grows.
|
|
@@ -1801,12 +1820,12 @@ function getArtifactLinks(jsonlPath) {
|
|
|
1801
1820
|
return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
|
|
1802
1821
|
}
|
|
1803
1822
|
|
|
1804
|
-
app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
|
|
1823
|
+
app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
|
|
1805
1824
|
try {
|
|
1806
1825
|
const metadata = loadSessionMetadata();
|
|
1807
1826
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1808
1827
|
if (!meta?.jsonlPath) return res.json({ artifacts: [] });
|
|
1809
|
-
res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
|
|
1828
|
+
res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
|
|
1810
1829
|
} catch (error) {
|
|
1811
1830
|
console.error('Error reading artifacts:', error);
|
|
1812
1831
|
res.status(500).json({ error: 'Failed to read artifacts' });
|
|
@@ -1851,7 +1870,7 @@ async function findPads(root, depth = 0) {
|
|
|
1851
1870
|
// stored, so re-reading a transcript cannot drift; the client links each pad once
|
|
1852
1871
|
// and records that it did, which is what stops a re-read undoing an unlink.
|
|
1853
1872
|
async function readCreatedPads(meta) {
|
|
1854
|
-
const creations = readScratchpadCreations(meta.jsonlPath);
|
|
1873
|
+
const creations = await readScratchpadCreations(meta.jsonlPath);
|
|
1855
1874
|
// No `scratch new` in the transcript means no disk work at all, so sessions that
|
|
1856
1875
|
// never touch the CLI pay a substring pass over the transcript and nothing more.
|
|
1857
1876
|
if (!creations.length) return [];
|
|
@@ -2129,7 +2148,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
2129
2148
|
try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
|
|
2130
2149
|
for (const f of files) {
|
|
2131
2150
|
const agentId = f.slice('agent-'.length, -'.jsonl'.length);
|
|
2132
|
-
const stats = extractTranscriptStats(path.join(runDir, f)) || {};
|
|
2151
|
+
const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
|
|
2133
2152
|
let type = null;
|
|
2134
2153
|
try {
|
|
2135
2154
|
type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
|
|
@@ -2364,9 +2383,9 @@ app.get('/api/teams/:name', (req, res) => {
|
|
|
2364
2383
|
// API: Get agents for a session
|
|
2365
2384
|
// The resultUnavailable latch is persisted on the agent record. Bump this when
|
|
2366
2385
|
// extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
|
|
2367
|
-
const RESULT_SCAN =
|
|
2386
|
+
const RESULT_SCAN = 3;
|
|
2368
2387
|
|
|
2369
|
-
app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
2388
|
+
app.get('/api/sessions/:sessionId/agents', async (req, res) => {
|
|
2370
2389
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2371
2390
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
2372
2391
|
if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
|
|
@@ -2410,10 +2429,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2410
2429
|
agents.push(agent);
|
|
2411
2430
|
} catch { /* skip invalid */ }
|
|
2412
2431
|
}
|
|
2432
|
+
const digest = meta.jsonlPath ? await getSessionDigest(meta.jsonlPath) : null;
|
|
2413
2433
|
const liveAgents = agents.filter(isAgentLive);
|
|
2414
2434
|
if (liveAgents.length && meta.jsonlPath) {
|
|
2415
2435
|
try {
|
|
2416
|
-
const terminated =
|
|
2436
|
+
const { terminated } = digest;
|
|
2417
2437
|
if (terminated.size) {
|
|
2418
2438
|
for (const agent of liveAgents) {
|
|
2419
2439
|
const agentName = agentDisplayName(agent);
|
|
@@ -2430,10 +2450,9 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2430
2450
|
// Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
|
|
2431
2451
|
// the parent will never read their output, so they're orphans. Match by agentId
|
|
2432
2452
|
// when the digest already correlated tool_use→agent, else fall back to prompt text
|
|
2433
|
-
// (the
|
|
2453
|
+
// (the plugin's mod doesn't record the spawning tool_use_id).
|
|
2434
2454
|
try {
|
|
2435
|
-
const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
|
|
2436
|
-
getSessionDigest(meta.jsonlPath);
|
|
2455
|
+
const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } = digest;
|
|
2437
2456
|
if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
|
|
2438
2457
|
for (const agent of liveAgents) {
|
|
2439
2458
|
if (!isAgentLive(agent)) continue;
|
|
@@ -2464,8 +2483,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2464
2483
|
const byAgentId = {};
|
|
2465
2484
|
if (meta.jsonlPath) {
|
|
2466
2485
|
try {
|
|
2467
|
-
const
|
|
2468
|
-
for (const entry of Object.values(progressMap)) {
|
|
2486
|
+
for (const entry of Object.values(digest.progressMap)) {
|
|
2469
2487
|
byAgentId[entry.agentId] ||= {};
|
|
2470
2488
|
const e = byAgentId[entry.agentId];
|
|
2471
2489
|
for (const f of ['prompt', 'name', 'description', 'usage']) {
|
|
@@ -2622,10 +2640,10 @@ app.post('/api/sessions/:sessionId/read', (req, res) => {
|
|
|
2622
2640
|
}
|
|
2623
2641
|
});
|
|
2624
2642
|
|
|
2625
|
-
// UI-driven approvals: answers the ask
|
|
2626
|
-
//
|
|
2627
|
-
//
|
|
2628
|
-
//
|
|
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.
|
|
2629
2647
|
app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
2630
2648
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2631
2649
|
const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
@@ -2942,7 +2960,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
|
|
|
2942
2960
|
res.on('error', cleanup);
|
|
2943
2961
|
});
|
|
2944
2962
|
|
|
2945
|
-
|
|
2963
|
+
async function sendSessionMessages(req, res) {
|
|
2946
2964
|
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
|
|
2947
2965
|
const before = req.query.before || null;
|
|
2948
2966
|
const metadata = loadSessionMetadata();
|
|
@@ -2959,9 +2977,10 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
2959
2977
|
hasMore = messages.length > limit;
|
|
2960
2978
|
if (hasMore) messages = messages.slice(-limit);
|
|
2961
2979
|
}
|
|
2980
|
+
const compactPromise = cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), []);
|
|
2962
2981
|
const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
|
|
2963
2982
|
if (agentMessages.length) {
|
|
2964
|
-
const progressMap = getProgressMap(jsonlPath);
|
|
2983
|
+
const progressMap = await getProgressMap(jsonlPath);
|
|
2965
2984
|
const resolvedSid = resolveSessionId(req.params.sessionId);
|
|
2966
2985
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedSid);
|
|
2967
2986
|
for (const msg of agentMessages) {
|
|
@@ -3012,15 +3031,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
3012
3031
|
}
|
|
3013
3032
|
}
|
|
3014
3033
|
}
|
|
3015
|
-
const
|
|
3016
|
-
let compactSummaries;
|
|
3017
|
-
if (cachedCompact && Date.now() - cachedCompact.ts < MESSAGE_CACHE_TTL) {
|
|
3018
|
-
compactSummaries = cachedCompact.data;
|
|
3019
|
-
} else {
|
|
3020
|
-
compactSummaries = readCompactSummaries(jsonlPath);
|
|
3021
|
-
compactSummaryCache.set(jsonlPath, { data: compactSummaries, ts: Date.now() });
|
|
3022
|
-
evictStaleCache(compactSummaryCache);
|
|
3023
|
-
}
|
|
3034
|
+
const compactSummaries = await compactPromise;
|
|
3024
3035
|
// Match compaction messages to summaries by chronological order
|
|
3025
3036
|
const compactedMsgs = messages
|
|
3026
3037
|
.filter(m => m.systemLabel === 'Compacted')
|
|
@@ -3038,29 +3049,30 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
3038
3049
|
delete msg.promptId;
|
|
3039
3050
|
}
|
|
3040
3051
|
res.json({ messages, hasMore, sessionId: req.params.sessionId });
|
|
3041
|
-
}
|
|
3052
|
+
}
|
|
3042
3053
|
|
|
3043
|
-
app.get('/api/sessions/:sessionId/
|
|
3054
|
+
app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
|
|
3055
|
+
|
|
3056
|
+
app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
|
|
3044
3057
|
const metadata = loadSessionMetadata();
|
|
3045
3058
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3046
3059
|
const jsonlPath = meta?.jsonlPath;
|
|
3047
3060
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3048
|
-
const content = readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
3061
|
+
const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
3049
3062
|
if (content == null) return res.status(404).json({ error: 'tool result not found' });
|
|
3050
3063
|
res.json({ toolUseId: req.params.toolUseId, content });
|
|
3051
|
-
});
|
|
3064
|
+
}));
|
|
3052
3065
|
|
|
3053
3066
|
const toolStatsCache = new Map();
|
|
3054
3067
|
|
|
3055
|
-
function buildToolStats(jsonlPath) {
|
|
3068
|
+
async function buildToolStats(jsonlPath) {
|
|
3056
3069
|
const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
|
|
3057
3070
|
const seenResults = new Set();
|
|
3058
3071
|
const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
|
|
3059
3072
|
const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
|
|
3060
3073
|
const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
|
|
3061
3074
|
|
|
3062
|
-
const
|
|
3063
|
-
for (const line of content.split('\n')) {
|
|
3075
|
+
for await (const line of readLines(jsonlPath)) {
|
|
3064
3076
|
if (!line) continue;
|
|
3065
3077
|
let obj;
|
|
3066
3078
|
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
@@ -3147,13 +3159,13 @@ function buildToolStats(jsonlPath) {
|
|
|
3147
3159
|
return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
|
|
3148
3160
|
}
|
|
3149
3161
|
|
|
3150
|
-
app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
|
|
3162
|
+
app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
|
|
3151
3163
|
const metadata = loadSessionMetadata();
|
|
3152
3164
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3153
3165
|
const jsonlPath = meta?.jsonlPath;
|
|
3154
3166
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3155
3167
|
try {
|
|
3156
|
-
const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
|
|
3168
|
+
const data = await cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
|
|
3157
3169
|
if (!data) return res.status(404).json({ error: 'could not parse session' });
|
|
3158
3170
|
res.json({ sessionId: req.params.sessionId, ...data });
|
|
3159
3171
|
} catch (e) {
|
|
@@ -3161,31 +3173,24 @@ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
|
|
|
3161
3173
|
}
|
|
3162
3174
|
});
|
|
3163
3175
|
|
|
3164
|
-
|
|
3176
|
+
async function sendTranscriptImage(req, res, read) {
|
|
3165
3177
|
const metadata = loadSessionMetadata();
|
|
3166
3178
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3167
3179
|
const jsonlPath = meta?.jsonlPath;
|
|
3168
3180
|
if (!jsonlPath) return res.status(404).end();
|
|
3169
|
-
const img =
|
|
3181
|
+
const img = await read(jsonlPath);
|
|
3170
3182
|
if (!img) return res.status(404).end();
|
|
3171
3183
|
const buf = Buffer.from(img.data, 'base64');
|
|
3172
3184
|
res.setHeader('Content-Type', img.mediaType);
|
|
3173
3185
|
res.setHeader('Cache-Control', 'no-store');
|
|
3174
3186
|
res.end(buf);
|
|
3175
|
-
}
|
|
3187
|
+
}
|
|
3176
3188
|
|
|
3177
|
-
app.get('/api/sessions/:sessionId/
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
const img = readToolResultImage(jsonlPath, req.params.toolUseId, req.params.n);
|
|
3183
|
-
if (!img) return res.status(404).end();
|
|
3184
|
-
const buf = Buffer.from(img.data, 'base64');
|
|
3185
|
-
res.setHeader('Content-Type', img.mediaType);
|
|
3186
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
3187
|
-
res.end(buf);
|
|
3188
|
-
});
|
|
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))));
|
|
3189
3194
|
|
|
3190
3195
|
app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
|
|
3191
3196
|
const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
|
|
@@ -3298,6 +3303,16 @@ app.get('/api/terminals', (_req, res) => {
|
|
|
3298
3303
|
res.json({ sessions: terminal.list() });
|
|
3299
3304
|
});
|
|
3300
3305
|
|
|
3306
|
+
const terminalProcStats = createProcStats();
|
|
3307
|
+
app.get('/api/terminals/stats', async (_req, res) => {
|
|
3308
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3309
|
+
const pids = terminal.claudePids();
|
|
3310
|
+
const byPid = await terminalProcStats([process.pid, ...Object.values(pids)]);
|
|
3311
|
+
const terminals = {};
|
|
3312
|
+
for (const [id, pid] of Object.entries(pids)) if (byPid[pid]) terminals[id] = byPid[pid];
|
|
3313
|
+
res.json({ cck: byPid[process.pid] || null, terminals });
|
|
3314
|
+
});
|
|
3315
|
+
|
|
3301
3316
|
app.delete('/api/terminals/:id', (req, res) => {
|
|
3302
3317
|
const err = terminal.end(req.params.id, req.get('x-terminal-token'));
|
|
3303
3318
|
if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
|
|
@@ -3978,7 +3993,7 @@ function formatReviewMarkdown(src, items) {
|
|
|
3978
3993
|
const parts = [
|
|
3979
3994
|
`# Review of ${src.path || src.label}`,
|
|
3980
3995
|
'',
|
|
3981
|
-
|
|
3996
|
+
`Each comment is the user's instruction about the quoted text.${src.path ? ' A quote missing from the source means the source changed after the review: say so.' : ''}`,
|
|
3982
3997
|
];
|
|
3983
3998
|
items.forEach((c, i) => {
|
|
3984
3999
|
const where = [c.line && `line ${c.line}`, c.heading && `under "${c.heading}"`].filter(Boolean).join(', ');
|
|
@@ -4245,7 +4260,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
|
|
|
4245
4260
|
}
|
|
4246
4261
|
});
|
|
4247
4262
|
|
|
4248
|
-
// Watch context-status directory for
|
|
4263
|
+
// Watch context-status directory for the plugin's context and cost updates
|
|
4249
4264
|
const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
|
|
4250
4265
|
persistent: true,
|
|
4251
4266
|
ignoreInitial: false,
|
|
@@ -4305,9 +4320,8 @@ async function cleanupAgentActivity() {
|
|
|
4305
4320
|
await fs.rm(dirPath, { recursive: true, force: true });
|
|
4306
4321
|
continue;
|
|
4307
4322
|
}
|
|
4308
|
-
//
|
|
4309
|
-
//
|
|
4310
|
-
// 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.
|
|
4311
4325
|
for (const f of contents) {
|
|
4312
4326
|
if (!isDecisionFile(f)) continue;
|
|
4313
4327
|
const fp = path.join(dirPath, f);
|
|
@@ -4321,13 +4335,17 @@ async function cleanupAgentActivity() {
|
|
|
4321
4335
|
} catch { /* agent-activity dir may not exist */ }
|
|
4322
4336
|
}
|
|
4323
4337
|
|
|
4324
|
-
// Dispatch markers and
|
|
4338
|
+
// Dispatch markers, reviews and worktrees: see docs/retention.md.
|
|
4325
4339
|
async function runRetention() {
|
|
4326
4340
|
try {
|
|
4327
|
-
const
|
|
4341
|
+
const scan = await scanTranscripts(PROJECTS_DIR);
|
|
4342
|
+
const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
|
|
4328
4343
|
const markers = dispatched.prune(opts);
|
|
4329
4344
|
const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
|
|
4330
|
-
|
|
4345
|
+
const wts = worktrees.prune(scan?.dirs);
|
|
4346
|
+
if (markers || reviews || wts) {
|
|
4347
|
+
console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${wts} worktrees`);
|
|
4348
|
+
}
|
|
4331
4349
|
} catch (e) {
|
|
4332
4350
|
console.warn('[retention] failed:', e.message);
|
|
4333
4351
|
}
|