claude-code-kanban 4.0.0 → 4.2.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/lib/parsers.js +319 -86
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +10 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +4 -4
- package/public/app.js +381 -47
- package/public/index.html +25 -0
- package/public/style.css +308 -17
- package/server.js +323 -39
package/server.js
CHANGED
|
@@ -8,7 +8,7 @@ const readline = require('readline');
|
|
|
8
8
|
const chokidar = require('chokidar');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const { spawn } = require('child_process');
|
|
11
|
+
const { spawn, spawnSync } = require('child_process');
|
|
12
12
|
|
|
13
13
|
const {
|
|
14
14
|
readRecentMessages: _readRecentMessagesUncached,
|
|
@@ -20,7 +20,10 @@ const {
|
|
|
20
20
|
findTerminatedTeammates,
|
|
21
21
|
extractPromptFromTranscript,
|
|
22
22
|
extractModelFromTranscript,
|
|
23
|
-
readFullToolResult
|
|
23
|
+
readFullToolResult,
|
|
24
|
+
readUserImage,
|
|
25
|
+
updateLoopInfo,
|
|
26
|
+
buildLoopInfoFromState
|
|
24
27
|
} = require('./lib/parsers');
|
|
25
28
|
|
|
26
29
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
@@ -95,12 +98,16 @@ function writePins(pins) {
|
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const WAITING_RESOLVE_GRACE_MS =
|
|
101
|
+
// #region TIMINGS
|
|
102
|
+
const PERMISSION_TTL_MS = 30 * 60 * 1000;
|
|
103
|
+
const AGENT_TTL_MS = 60 * 60 * 1000;
|
|
104
|
+
const AGENT_STALE_MS = 30 * 60 * 1000; // safety net for crashed sessions
|
|
105
|
+
const SESSION_STALE_MS = 5 * 60 * 1000;
|
|
106
|
+
const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
|
|
107
|
+
const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
108
|
+
const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
109
|
+
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
|
110
|
+
// #endregion
|
|
104
111
|
|
|
105
112
|
function readAgentJsonl(filePath) {
|
|
106
113
|
const raw = readFileSync(filePath, 'utf8');
|
|
@@ -152,6 +159,45 @@ function isAgentFresh(agent) {
|
|
|
152
159
|
return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
|
|
153
160
|
}
|
|
154
161
|
|
|
162
|
+
// Claude Code records gitBranch from the launch-time repo and never updates it
|
|
163
|
+
// when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
|
|
164
|
+
// the live cwd instead. Cached per-cwd with a short TTL so a list refresh
|
|
165
|
+
// across N sessions sharing one cwd spawns git at most once per TTL window.
|
|
166
|
+
const gitBranchCache = new Map();
|
|
167
|
+
const GIT_BRANCH_TTL_MS = 30000;
|
|
168
|
+
const GIT_BRANCH_CACHE_MAX = 500;
|
|
169
|
+
function getGitBranch(cwd) {
|
|
170
|
+
if (!cwd) return null;
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const cached = gitBranchCache.get(cwd);
|
|
173
|
+
if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
|
|
174
|
+
let branch = null;
|
|
175
|
+
try {
|
|
176
|
+
const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
177
|
+
encoding: 'utf8', timeout: 500, windowsHide: true
|
|
178
|
+
});
|
|
179
|
+
if (r.status === 0) {
|
|
180
|
+
const out = (r.stdout || '').trim();
|
|
181
|
+
if (out && out !== 'HEAD') branch = out;
|
|
182
|
+
}
|
|
183
|
+
} catch (_) {}
|
|
184
|
+
gitBranchCache.set(cwd, { branch, ts: now });
|
|
185
|
+
if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
|
|
186
|
+
const firstKey = gitBranchCache.keys().next().value;
|
|
187
|
+
gitBranchCache.delete(firstKey);
|
|
188
|
+
}
|
|
189
|
+
return branch;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Only spawn git when cwd has diverged from the launch project — that's the
|
|
193
|
+
// only case the JSONL value is wrong. Saves N spawns on a typical list build.
|
|
194
|
+
function resolveSessionGitBranch(meta) {
|
|
195
|
+
if (meta.cwd && meta.project && meta.cwd !== meta.project) {
|
|
196
|
+
return getGitBranch(meta.cwd) || meta.gitBranch || null;
|
|
197
|
+
}
|
|
198
|
+
return meta.gitBranch || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
155
201
|
function getSessionLogStat(meta) {
|
|
156
202
|
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
|
|
157
203
|
try {
|
|
@@ -216,6 +262,11 @@ const clients = new Set();
|
|
|
216
262
|
let sessionMetadataCache = {};
|
|
217
263
|
let lastMetadataRefresh = 0;
|
|
218
264
|
const METADATA_CACHE_TTL = 10000; // 10 seconds
|
|
265
|
+
// Watcher-driven invalidation. `change` events (append to existing jsonl) only
|
|
266
|
+
// dirty the one path so we can do a targeted refresh; `add` / `unlink` events
|
|
267
|
+
// are structural and force a full rescan.
|
|
268
|
+
const dirtyMetadataPaths = new Set();
|
|
269
|
+
let metadataNeedsFullScan = true;
|
|
219
270
|
|
|
220
271
|
const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
221
272
|
function isSafeId(id) {
|
|
@@ -381,10 +432,50 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
381
432
|
/**
|
|
382
433
|
* Scan all project directories to find session JSONL files and extract slugs
|
|
383
434
|
*/
|
|
435
|
+
// Returns false when sessionId is unknown — caller must promote to full scan.
|
|
436
|
+
function refreshSessionMetadataPath(jsonlPath) {
|
|
437
|
+
const sessionId = path.basename(jsonlPath, '.jsonl');
|
|
438
|
+
if (!isSafeId(sessionId)) return false;
|
|
439
|
+
const existing = sessionMetadataCache[sessionId];
|
|
440
|
+
if (!existing) return false;
|
|
441
|
+
let info;
|
|
442
|
+
try {
|
|
443
|
+
info = readSessionInfoFromJsonl(jsonlPath);
|
|
444
|
+
} catch (_) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
// Shadow JSONLs (continued from a worktree) hold only custom-title / agent-
|
|
448
|
+
// name records — no projectPath. Don't let a shadow clobber the real entry.
|
|
449
|
+
const shadow = existing.project && !info.projectPath;
|
|
450
|
+
if (shadow) {
|
|
451
|
+
if (!existing.slug && info.slug) existing.slug = info.slug;
|
|
452
|
+
if (!existing.customTitle && info.customTitle) existing.customTitle = info.customTitle;
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
if (info.slug) existing.slug = info.slug;
|
|
456
|
+
if (info.cwd) existing.cwd = info.cwd;
|
|
457
|
+
if (info.gitBranch) existing.gitBranch = info.gitBranch;
|
|
458
|
+
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
384
462
|
function loadSessionMetadata() {
|
|
385
463
|
const now = Date.now();
|
|
386
|
-
|
|
387
|
-
|
|
464
|
+
|
|
465
|
+
if (!metadataNeedsFullScan && now - lastMetadataRefresh < METADATA_CACHE_TTL) {
|
|
466
|
+
if (dirtyMetadataPaths.size > 0) {
|
|
467
|
+
for (const p of dirtyMetadataPaths) {
|
|
468
|
+
if (!refreshSessionMetadataPath(p)) {
|
|
469
|
+
// Unknown sessionId — structural change snuck in. Promote to full.
|
|
470
|
+
metadataNeedsFullScan = true;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
dirtyMetadataPaths.clear();
|
|
475
|
+
if (!metadataNeedsFullScan) return sessionMetadataCache;
|
|
476
|
+
} else {
|
|
477
|
+
return sessionMetadataCache;
|
|
478
|
+
}
|
|
388
479
|
}
|
|
389
480
|
|
|
390
481
|
const metadata = {};
|
|
@@ -515,6 +606,8 @@ function loadSessionMetadata() {
|
|
|
515
606
|
|
|
516
607
|
sessionMetadataCache = metadata;
|
|
517
608
|
lastMetadataRefresh = now;
|
|
609
|
+
metadataNeedsFullScan = false;
|
|
610
|
+
dirtyMetadataPaths.clear();
|
|
518
611
|
return metadata;
|
|
519
612
|
}
|
|
520
613
|
|
|
@@ -531,6 +624,51 @@ function getPlanInfo(slug) {
|
|
|
531
624
|
}
|
|
532
625
|
}
|
|
533
626
|
|
|
627
|
+
// Hide wakeups whose fire time is more than this far in the past — long /loop
|
|
628
|
+
// sessions otherwise produce dozens of stale entries that drown the badge.
|
|
629
|
+
const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
|
|
630
|
+
|
|
631
|
+
function isWakeupActive(w, now = Date.now()) {
|
|
632
|
+
if (!w || !w.timestamp || w.delaySeconds == null) return true;
|
|
633
|
+
const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
|
|
634
|
+
return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function filterActiveLoopInfo(info) {
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
return {
|
|
640
|
+
wakeups: info.wakeups.filter(w => isWakeupActive(w, now)),
|
|
641
|
+
crons: info.crons
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Per-path incremental scan state. Populated lazily on first access and
|
|
646
|
+
// updated in place; the projectsWatcher event handler keeps entries warm so
|
|
647
|
+
// the request path does O(1) work in steady state.
|
|
648
|
+
const loopInfoStateByPath = new Map();
|
|
649
|
+
|
|
650
|
+
function refreshLoopInfoState(jsonlPath) {
|
|
651
|
+
if (!jsonlPath) return null;
|
|
652
|
+
const prev = loopInfoStateByPath.get(jsonlPath);
|
|
653
|
+
const next = updateLoopInfo(jsonlPath, prev);
|
|
654
|
+
if (next) loopInfoStateByPath.set(jsonlPath, next);
|
|
655
|
+
return next;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function getLoopInfoSummary(meta) {
|
|
659
|
+
const empty = { wakeupCount: 0, cronCount: 0, latest: null };
|
|
660
|
+
if (!meta?.jsonlPath) return empty;
|
|
661
|
+
try {
|
|
662
|
+
const state = refreshLoopInfoState(meta.jsonlPath);
|
|
663
|
+
const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
|
|
664
|
+
return {
|
|
665
|
+
wakeupCount: filtered.wakeups.length,
|
|
666
|
+
cronCount: filtered.crons.length,
|
|
667
|
+
latest: filtered.wakeups[filtered.wakeups.length - 1] || filtered.crons[filtered.crons.length - 1] || null
|
|
668
|
+
};
|
|
669
|
+
} catch (_) { return empty; }
|
|
670
|
+
}
|
|
671
|
+
|
|
534
672
|
function getSessionDisplayName(sessionId, meta) {
|
|
535
673
|
if (meta?.customTitle) return meta.customTitle;
|
|
536
674
|
if (meta?.slug) return meta.slug;
|
|
@@ -548,7 +686,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
548
686
|
project: meta.project || null,
|
|
549
687
|
cwd: meta.cwd || null,
|
|
550
688
|
description: meta.description || null,
|
|
551
|
-
gitBranch: meta
|
|
689
|
+
gitBranch: resolveSessionGitBranch(meta),
|
|
552
690
|
customTitle: meta.customTitle || null,
|
|
553
691
|
taskCount: 0,
|
|
554
692
|
completed: 0,
|
|
@@ -568,6 +706,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
568
706
|
projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
|
|
569
707
|
contextStatus: getContextStatus(id, meta),
|
|
570
708
|
...getPlanInfo(meta.slug),
|
|
709
|
+
loopInfo: getLoopInfoSummary(meta),
|
|
571
710
|
...overrides,
|
|
572
711
|
// Remove internal-only field
|
|
573
712
|
_logStat: undefined,
|
|
@@ -969,6 +1108,23 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
|
969
1108
|
}
|
|
970
1109
|
});
|
|
971
1110
|
|
|
1111
|
+
app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
1112
|
+
try {
|
|
1113
|
+
const metadata = loadSessionMetadata();
|
|
1114
|
+
const meta = metadata[req.params.sessionId];
|
|
1115
|
+
if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
|
|
1116
|
+
const state = refreshLoopInfoState(meta.jsonlPath);
|
|
1117
|
+
const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
|
|
1118
|
+
res.json({
|
|
1119
|
+
wakeups: [...filtered.wakeups].reverse(),
|
|
1120
|
+
crons: [...filtered.crons].reverse()
|
|
1121
|
+
});
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
console.error('Error reading loop info:', error);
|
|
1124
|
+
res.status(500).json({ error: 'Failed to read loop info' });
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
972
1128
|
function openInEditor(...targets) {
|
|
973
1129
|
const editor = process.env.EDITOR || 'code';
|
|
974
1130
|
spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
|
|
@@ -1218,6 +1374,20 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1218
1374
|
}
|
|
1219
1375
|
});
|
|
1220
1376
|
|
|
1377
|
+
function clearWaitingFile(sessionId) {
|
|
1378
|
+
try { unlinkSync(path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json')); }
|
|
1379
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
|
|
1383
|
+
try {
|
|
1384
|
+
clearWaitingFile(resolveSessionId(req.params.sessionId));
|
|
1385
|
+
res.json({ ok: true });
|
|
1386
|
+
} catch (e) {
|
|
1387
|
+
res.status(500).json({ error: 'Failed to discard waiting' });
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1221
1391
|
app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
|
|
1222
1392
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
1223
1393
|
const agentId = sanitizeAgentId(req.params.agentId);
|
|
@@ -1229,9 +1399,7 @@ app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
|
|
|
1229
1399
|
agent.stoppedAt = new Date().toISOString();
|
|
1230
1400
|
const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
|
|
1231
1401
|
writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt) + '\n', 'utf8'); // sync — response depends on write
|
|
1232
|
-
|
|
1233
|
-
const waitingFile = path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json');
|
|
1234
|
-
if (existsSync(waitingFile)) unlinkSync(waitingFile);
|
|
1402
|
+
clearWaitingFile(sessionId);
|
|
1235
1403
|
res.json({ ok: true });
|
|
1236
1404
|
} catch (e) {
|
|
1237
1405
|
res.status(500).json({ error: 'Failed to stop agent' });
|
|
@@ -1252,30 +1420,122 @@ function subagentJsonlPath(meta, agentId) {
|
|
|
1252
1420
|
}
|
|
1253
1421
|
|
|
1254
1422
|
// Claude Code can scatter a session's records across multiple project dirs
|
|
1255
|
-
// (e.g. main repo + worktree)
|
|
1256
|
-
//
|
|
1257
|
-
// derived path is missing.
|
|
1423
|
+
// (e.g. main repo + worktree) and across sibling sessionId dirs when a
|
|
1424
|
+
// session is forked/resumed — the subagent JSONL stays under the original
|
|
1425
|
+
// parent sessionId. Fall back to scanning when the derived path is missing.
|
|
1258
1426
|
const subagentPathCache = new Map();
|
|
1427
|
+
function findSubagentJsonlInProject(projPath, sessionId, agentId) {
|
|
1428
|
+
const sameSid = path.join(projPath, sessionId, 'subagents', 'agent-' + agentId + '.jsonl');
|
|
1429
|
+
if (existsSync(sameSid)) return sameSid;
|
|
1430
|
+
let sessions;
|
|
1431
|
+
try { sessions = readdirSync(projPath, { withFileTypes: true }); } catch { return null; }
|
|
1432
|
+
for (const sess of sessions) {
|
|
1433
|
+
if (!sess.isDirectory() || sess.name === sessionId) continue;
|
|
1434
|
+
const candidate = path.join(projPath, sess.name, 'subagents', 'agent-' + agentId + '.jsonl');
|
|
1435
|
+
if (existsSync(candidate)) return candidate;
|
|
1436
|
+
}
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1259
1439
|
function resolveSubagentJsonl(meta, sessionId, agentId) {
|
|
1260
1440
|
const primary = subagentJsonlPath(meta, agentId);
|
|
1261
1441
|
if (existsSync(primary)) return primary;
|
|
1262
1442
|
const key = sessionId + '/' + agentId;
|
|
1263
|
-
|
|
1443
|
+
const cached = subagentPathCache.get(key);
|
|
1444
|
+
if (cached) return cached;
|
|
1264
1445
|
let found = null;
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1446
|
+
const parent = lookupParentSession(sessionId);
|
|
1447
|
+
if (parent.parentSessionId && parent.parentJsonlPath) {
|
|
1448
|
+
const projDir = path.dirname(parent.parentJsonlPath);
|
|
1449
|
+
const candidate = path.join(projDir, parent.parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl');
|
|
1450
|
+
if (existsSync(candidate)) found = candidate;
|
|
1451
|
+
}
|
|
1452
|
+
if (!found) {
|
|
1453
|
+
try {
|
|
1454
|
+
for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
|
|
1455
|
+
if (!proj.isDirectory()) continue;
|
|
1456
|
+
found = findSubagentJsonlInProject(path.join(PROJECTS_DIR, proj.name), sessionId, agentId);
|
|
1457
|
+
if (found) break;
|
|
1458
|
+
}
|
|
1459
|
+
} catch (_) { /* projects dir missing */ }
|
|
1460
|
+
}
|
|
1461
|
+
if (found) subagentPathCache.set(key, found);
|
|
1276
1462
|
return found || primary;
|
|
1277
1463
|
}
|
|
1278
1464
|
|
|
1465
|
+
// Claude Code marks fork lineage in two ways:
|
|
1466
|
+
// 1. `logicalParentUuid` on a system record (when present) points to a uuid
|
|
1467
|
+
// in the parent session's JSONL.
|
|
1468
|
+
// 2. When absent, the fork copies the parent's early records verbatim, so
|
|
1469
|
+
// the earliest `uuid` in this session also exists (same uuid+timestamp)
|
|
1470
|
+
// in the parent's JSONL.
|
|
1471
|
+
// We try (1) first, then fall back to (2).
|
|
1472
|
+
const parentSessionCache = new Map();
|
|
1473
|
+
// Both anchor signals live in the first few records (system marker on top,
|
|
1474
|
+
// fork-copy starts at line 0), so cap the scan instead of reading the whole file.
|
|
1475
|
+
const FORK_ANCHOR_SCAN_LINES = 10;
|
|
1476
|
+
function findForkAnchorUuid(jsonlPath) {
|
|
1477
|
+
let text;
|
|
1478
|
+
try { text = readFileSync(jsonlPath, 'utf8'); } catch { return null; }
|
|
1479
|
+
let firstUuid = null;
|
|
1480
|
+
let scanned = 0;
|
|
1481
|
+
for (const l of text.split('\n')) {
|
|
1482
|
+
if (!l) continue;
|
|
1483
|
+
if (scanned++ >= FORK_ANCHOR_SCAN_LINES) break;
|
|
1484
|
+
try {
|
|
1485
|
+
const d = JSON.parse(l);
|
|
1486
|
+
if (d.logicalParentUuid) return d.logicalParentUuid;
|
|
1487
|
+
if (!firstUuid && d.uuid) firstUuid = d.uuid;
|
|
1488
|
+
} catch { /* skip malformed */ }
|
|
1489
|
+
}
|
|
1490
|
+
return firstUuid;
|
|
1491
|
+
}
|
|
1492
|
+
function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
|
|
1493
|
+
let files;
|
|
1494
|
+
try { files = readdirSync(projectDir); } catch { return null; }
|
|
1495
|
+
const candidates = [];
|
|
1496
|
+
for (const f of files) {
|
|
1497
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
1498
|
+
const fp = path.join(projectDir, f);
|
|
1499
|
+
if (fp === excludeJsonlPath) continue;
|
|
1500
|
+
let text;
|
|
1501
|
+
try { text = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
1502
|
+
if (!text.includes(targetUuid)) continue;
|
|
1503
|
+
for (const l of text.split('\n')) {
|
|
1504
|
+
if (!l || !l.includes(targetUuid)) continue;
|
|
1505
|
+
try {
|
|
1506
|
+
const d = JSON.parse(l);
|
|
1507
|
+
if (d.uuid === targetUuid && d.sessionId) {
|
|
1508
|
+
let mtime = 0;
|
|
1509
|
+
try { mtime = statSync(fp).mtimeMs; } catch { /* ignore */ }
|
|
1510
|
+
candidates.push({ parentSessionId: d.sessionId, parentJsonlPath: fp, mtime });
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
} catch { /* skip */ }
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
if (!candidates.length) return null;
|
|
1517
|
+
candidates.sort((a, b) => a.mtime - b.mtime);
|
|
1518
|
+
const { parentSessionId, parentJsonlPath } = candidates[0];
|
|
1519
|
+
return { parentSessionId, parentJsonlPath };
|
|
1520
|
+
}
|
|
1521
|
+
function lookupParentSession(sessionId) {
|
|
1522
|
+
if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
|
|
1523
|
+
const meta = loadSessionMetadata()[sessionId];
|
|
1524
|
+
const result = { parentSessionId: null, parentJsonlPath: null };
|
|
1525
|
+
if (meta?.jsonlPath) {
|
|
1526
|
+
const anchorUuid = findForkAnchorUuid(meta.jsonlPath);
|
|
1527
|
+
if (anchorUuid) {
|
|
1528
|
+
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath);
|
|
1529
|
+
if (hit) Object.assign(result, hit);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
if (result.parentSessionId) parentSessionCache.set(sessionId, result);
|
|
1533
|
+
return result;
|
|
1534
|
+
}
|
|
1535
|
+
app.get('/api/sessions/:sessionId/parent', (req, res) => {
|
|
1536
|
+
res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1279
1539
|
app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
|
|
1280
1540
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
1281
1541
|
const agentId = sanitizeAgentId(req.params.agentId);
|
|
@@ -1418,6 +1678,19 @@ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
|
|
|
1418
1678
|
res.json({ toolUseId: req.params.toolUseId, content });
|
|
1419
1679
|
});
|
|
1420
1680
|
|
|
1681
|
+
app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
|
|
1682
|
+
const metadata = loadSessionMetadata();
|
|
1683
|
+
const meta = metadata[req.params.sessionId];
|
|
1684
|
+
const jsonlPath = meta?.jsonlPath;
|
|
1685
|
+
if (!jsonlPath) return res.status(404).end();
|
|
1686
|
+
const img = readUserImage(jsonlPath, req.params.msgUuid, req.params.blockIndex);
|
|
1687
|
+
if (!img) return res.status(404).end();
|
|
1688
|
+
const buf = Buffer.from(img.data, 'base64');
|
|
1689
|
+
res.setHeader('Content-Type', img.mediaType);
|
|
1690
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1691
|
+
res.end(buf);
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1421
1694
|
app.get('/api/version', (req, res) => {
|
|
1422
1695
|
const pkg = require('./package.json');
|
|
1423
1696
|
res.json({ version: pkg.version });
|
|
@@ -1818,13 +2091,27 @@ console.log(`Watching for team changes in: ${TEAMS_DIR}`);
|
|
|
1818
2091
|
const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
|
|
1819
2092
|
persistent: true,
|
|
1820
2093
|
ignoreInitial: true,
|
|
1821
|
-
depth: 2
|
|
2094
|
+
depth: 2,
|
|
2095
|
+
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
|
|
1822
2096
|
});
|
|
1823
2097
|
|
|
1824
2098
|
projectsWatcher.on('all', (event, filePath) => {
|
|
1825
|
-
if (
|
|
1826
|
-
|
|
1827
|
-
|
|
2099
|
+
if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
|
|
2100
|
+
if (filePath.endsWith('.jsonl')) {
|
|
2101
|
+
if (event === 'unlink') {
|
|
2102
|
+
loopInfoStateByPath.delete(filePath);
|
|
2103
|
+
} else {
|
|
2104
|
+
// Warm the incremental scan state so the next request does no IO.
|
|
2105
|
+
try { refreshLoopInfoState(filePath); } catch (_) {}
|
|
2106
|
+
}
|
|
2107
|
+
// add/unlink reshape the session set — promote to full rescan.
|
|
2108
|
+
if (event === 'change') dirtyMetadataPaths.add(filePath);
|
|
2109
|
+
else metadataNeedsFullScan = true;
|
|
2110
|
+
broadcast({ type: 'metadata-update' });
|
|
2111
|
+
} else if (path.basename(filePath) === 'sessions-index.json') {
|
|
2112
|
+
// Index holds description / created / customTitle that the targeted
|
|
2113
|
+
// refresh doesn't touch — promote to full rescan.
|
|
2114
|
+
metadataNeedsFullScan = true;
|
|
1828
2115
|
broadcast({ type: 'metadata-update' });
|
|
1829
2116
|
}
|
|
1830
2117
|
});
|
|
@@ -1837,7 +2124,9 @@ const plansWatcher = chokidar.watch(PLANS_DIR, {
|
|
|
1837
2124
|
|
|
1838
2125
|
plansWatcher.on('all', (event, filePath) => {
|
|
1839
2126
|
if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.md')) {
|
|
1840
|
-
|
|
2127
|
+
// Plan files don't affect cached session metadata — getPlanInfo is called
|
|
2128
|
+
// fresh from buildSessionObject on every list build. The broadcast alone
|
|
2129
|
+
// is enough to trigger a client refetch.
|
|
1841
2130
|
broadcast({ type: 'metadata-update' });
|
|
1842
2131
|
if (event === 'change') {
|
|
1843
2132
|
const slug = path.basename(filePath, '.md');
|
|
@@ -1920,7 +2209,6 @@ contextStatusWatcher.on('all', (event, filePath) => {
|
|
|
1920
2209
|
}
|
|
1921
2210
|
});
|
|
1922
2211
|
|
|
1923
|
-
const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
1924
2212
|
async function cleanupContextStatus() {
|
|
1925
2213
|
try {
|
|
1926
2214
|
const entries = await fs.readdir(CONTEXT_STATUS_DIR);
|
|
@@ -1939,10 +2227,6 @@ async function cleanupContextStatus() {
|
|
|
1939
2227
|
} catch (e) { /* dir may not exist */ }
|
|
1940
2228
|
}
|
|
1941
2229
|
|
|
1942
|
-
// Cleanup agent-activity folders older than 2 days
|
|
1943
|
-
const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
1944
|
-
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
|
1945
|
-
|
|
1946
2230
|
async function cleanupAgentActivity() {
|
|
1947
2231
|
try {
|
|
1948
2232
|
const entries = await fs.readdir(AGENT_ACTIVITY_DIR, { withFileTypes: true });
|