claude-code-kanban 4.21.0 → 4.23.1
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/file-url.js +38 -0
- package/package.json +1 -1
- package/public/app.js +991 -137
- package/public/style.css +470 -94
- package/server.js +202 -85
package/server.js
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// #region SETUP
|
|
3
4
|
const express = require('express');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const fs = require('fs').promises;
|
|
6
|
-
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync,
|
|
7
|
-
const
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const fs = require('node:fs').promises;
|
|
7
|
+
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync } = require('node:fs');
|
|
8
|
+
const _readline = require('node:readline');
|
|
8
9
|
const chokidar = require('chokidar');
|
|
9
|
-
const os = require('os');
|
|
10
|
-
const crypto = require('crypto');
|
|
11
|
-
const { spawnSync } = require('child_process');
|
|
12
|
-
const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const { spawnSync, spawn } = require('node:child_process');
|
|
13
|
+
const { assertOpenTarget, openInEditor, whichSync, exeBehindShim } = require('./lib/open-editor');
|
|
13
14
|
const { createNetGuard } = require('./lib/net-guard');
|
|
14
15
|
const { isContained } = require('./lib/contain');
|
|
16
|
+
const { fileUrlToPath } = require('./lib/file-url');
|
|
15
17
|
|
|
16
18
|
const {
|
|
17
19
|
readRecentMessages: _readRecentMessagesUncached,
|
|
18
20
|
readMessagesPage: _readMessagesPageUncached,
|
|
19
21
|
readSessionInfoFromJsonl,
|
|
20
|
-
buildAgentProgressMap,
|
|
21
22
|
buildSessionDigest,
|
|
22
23
|
readCompactSummaries,
|
|
23
|
-
findTerminatedTeammates,
|
|
24
24
|
extractPromptFromTranscript,
|
|
25
25
|
extractModelFromTranscript,
|
|
26
26
|
extractStructuredResultFromTranscript,
|
|
@@ -99,6 +99,9 @@ const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
|
99
99
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
100
100
|
const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
|
|
101
101
|
|
|
102
|
+
// #endregion
|
|
103
|
+
|
|
104
|
+
// #region SERVER_STATE
|
|
102
105
|
// Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
|
|
103
106
|
function readPins() {
|
|
104
107
|
try {
|
|
@@ -129,6 +132,8 @@ function writeServerInfo(port) {
|
|
|
129
132
|
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
// #endregion
|
|
136
|
+
|
|
132
137
|
// #region TIMINGS
|
|
133
138
|
const PERMISSION_TTL_MS = 30 * 60 * 1000;
|
|
134
139
|
const AGENT_TTL_MS = 60 * 60 * 1000;
|
|
@@ -144,6 +149,8 @@ const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
|
144
149
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
|
145
150
|
// #endregion
|
|
146
151
|
|
|
152
|
+
// #region AGENT_ACTIVITY
|
|
153
|
+
|
|
147
154
|
function readAgentJsonl(filePath) {
|
|
148
155
|
const raw = readFileSync(filePath, 'utf8');
|
|
149
156
|
const merged = {};
|
|
@@ -165,8 +172,8 @@ function listAgentFiles(agentDir) {
|
|
|
165
172
|
}
|
|
166
173
|
|
|
167
174
|
function persistAgent(dir, agent) {
|
|
168
|
-
const file = path.join(dir, agent.agentId
|
|
169
|
-
fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' })
|
|
175
|
+
const file = path.join(dir, `${agent.agentId}.jsonl`);
|
|
176
|
+
fs.appendFile(file, `${JSON.stringify({ ...agent, event: 'server-update' })}\n`, 'utf8').catch(() => {});
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
// Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
|
|
@@ -179,7 +186,7 @@ function approvalsWaitMs() {
|
|
|
179
186
|
let cfg = null;
|
|
180
187
|
try {
|
|
181
188
|
cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
|
|
182
|
-
} catch
|
|
189
|
+
} catch { /* missing config — gate default */ }
|
|
183
190
|
approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
|
|
184
191
|
return approvalsWaitCache.ms;
|
|
185
192
|
}
|
|
@@ -200,7 +207,7 @@ function checkWaitingForUser(agentDir, logMtime) {
|
|
|
200
207
|
if (isWaitingLapsed(data)) return { ...data, lapsed: true };
|
|
201
208
|
return data;
|
|
202
209
|
}
|
|
203
|
-
} catch
|
|
210
|
+
} catch { /* skip — missing or invalid */ }
|
|
204
211
|
return null;
|
|
205
212
|
}
|
|
206
213
|
|
|
@@ -274,7 +281,7 @@ function getSessionLogStat(meta) {
|
|
|
274
281
|
try {
|
|
275
282
|
const st = statSync(meta.jsonlPath);
|
|
276
283
|
return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
|
|
277
|
-
} catch
|
|
284
|
+
} catch { return { mtime: null, hasMessages: false }; }
|
|
278
285
|
}
|
|
279
286
|
|
|
280
287
|
function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
@@ -294,12 +301,15 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
|
294
301
|
result.hasRunning = true;
|
|
295
302
|
}
|
|
296
303
|
if (result.hasRunning) break;
|
|
297
|
-
} catch
|
|
304
|
+
} catch { /* skip invalid */ }
|
|
298
305
|
}
|
|
299
|
-
} catch
|
|
306
|
+
} catch { /* ignore */ }
|
|
300
307
|
return result;
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
// #endregion
|
|
311
|
+
|
|
312
|
+
// #region TEAMS
|
|
303
313
|
// isContained canonicalises both sides with realpath, which costs ~100x a plain
|
|
304
314
|
// path.join. This is called once per session on the /api/sessions hot path, and
|
|
305
315
|
// the verdict for a given name cannot change while TEAMS_DIR is fixed — so the
|
|
@@ -336,14 +346,14 @@ function loadTeamConfig(teamName) {
|
|
|
336
346
|
const data = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
337
347
|
teamConfigCache.set(teamName, { data, ts: Date.now() });
|
|
338
348
|
return data;
|
|
339
|
-
} catch
|
|
349
|
+
} catch {
|
|
340
350
|
return null;
|
|
341
351
|
}
|
|
342
352
|
}
|
|
343
353
|
|
|
344
354
|
function resolveSessionId(sessionId) {
|
|
345
355
|
const teamConfig = loadTeamConfig(sessionId);
|
|
346
|
-
return (teamConfig
|
|
356
|
+
return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
|
|
347
357
|
}
|
|
348
358
|
|
|
349
359
|
// Recent Claude Code releases auto-create a single-member "self-team" for every
|
|
@@ -457,6 +467,9 @@ function attachTeamTasks(card, teamTaskDir, teamName, counts) {
|
|
|
457
467
|
}
|
|
458
468
|
}
|
|
459
469
|
|
|
470
|
+
// #endregion
|
|
471
|
+
|
|
472
|
+
// #region SHARED_STATE
|
|
460
473
|
// SSE clients for live updates
|
|
461
474
|
const clients = new Set();
|
|
462
475
|
|
|
@@ -470,39 +483,48 @@ const METADATA_CACHE_TTL = 10000; // 10 seconds
|
|
|
470
483
|
const dirtyMetadataPaths = new Set();
|
|
471
484
|
let metadataNeedsFullScan = true;
|
|
472
485
|
|
|
486
|
+
// #endregion
|
|
487
|
+
|
|
488
|
+
// #region REQUEST_GUARDS
|
|
473
489
|
const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
474
490
|
function isSafeId(id) {
|
|
475
491
|
return typeof id === 'string' && id.length > 0 && id.length <= 128 && SAFE_ID_RE.test(id);
|
|
476
492
|
}
|
|
477
493
|
|
|
478
|
-
app.param('sessionId', (
|
|
494
|
+
app.param('sessionId', (_req, res, next, val) => {
|
|
479
495
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid session ID' });
|
|
480
496
|
next();
|
|
481
497
|
});
|
|
482
|
-
app.param('taskId', (
|
|
498
|
+
app.param('taskId', (_req, res, next, val) => {
|
|
483
499
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid task ID' });
|
|
484
500
|
next();
|
|
485
501
|
});
|
|
486
502
|
// Team names are directory names under TEAMS_DIR, so /api/teams/:name was a
|
|
487
503
|
// straight traversal before this.
|
|
488
|
-
app.param('name', (
|
|
504
|
+
app.param('name', (_req, res, next, val) => {
|
|
489
505
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid team name' });
|
|
490
506
|
next();
|
|
491
507
|
});
|
|
492
508
|
|
|
493
509
|
// Parse JSON bodies
|
|
494
510
|
app.use(express.json());
|
|
511
|
+
// #endregion
|
|
512
|
+
|
|
513
|
+
// #region STATIC
|
|
495
514
|
|
|
496
515
|
app.get('/hub-config', (_req, res) => {
|
|
497
516
|
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
|
|
498
517
|
});
|
|
499
518
|
|
|
500
519
|
// Serve static files
|
|
501
|
-
app.get('/sw.js', (
|
|
520
|
+
app.get('/sw.js', (_req, res) => {
|
|
502
521
|
res.setHeader('Cache-Control', 'no-cache');
|
|
503
522
|
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
|
|
504
523
|
});
|
|
505
524
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
525
|
+
// #endregion
|
|
526
|
+
|
|
527
|
+
// #region SESSION_LIST
|
|
506
528
|
|
|
507
529
|
const messageCache = new Map();
|
|
508
530
|
const MESSAGE_CACHE_TTL = 5000;
|
|
@@ -545,9 +567,9 @@ function loadAllTaskMaps() {
|
|
|
545
567
|
for (const sessionId of Object.keys(map)) {
|
|
546
568
|
sessionToList[sessionId] = taskListName;
|
|
547
569
|
}
|
|
548
|
-
} catch
|
|
570
|
+
} catch { /* skip invalid */ }
|
|
549
571
|
}
|
|
550
|
-
} catch
|
|
572
|
+
} catch { /* ignore */ }
|
|
551
573
|
sessionToTaskListCache = { sessionToList, listToSessions };
|
|
552
574
|
lastTaskMapScan = now;
|
|
553
575
|
return sessionToTaskListCache;
|
|
@@ -617,7 +639,7 @@ function getTaskCounts(sessionPath) {
|
|
|
617
639
|
try {
|
|
618
640
|
const taskPath = path.join(sessionPath, file);
|
|
619
641
|
const task = JSON.parse(readFileSync(taskPath, 'utf8'));
|
|
620
|
-
if (task.metadata
|
|
642
|
+
if (task.metadata?._internal) continue;
|
|
621
643
|
if (task.status === 'completed') completed++;
|
|
622
644
|
else if (task.status === 'in_progress') inProgress++;
|
|
623
645
|
else pending++;
|
|
@@ -625,7 +647,7 @@ function getTaskCounts(sessionPath) {
|
|
|
625
647
|
if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
|
|
626
648
|
newestTaskMtime = taskStat.mtime;
|
|
627
649
|
}
|
|
628
|
-
} catch
|
|
650
|
+
} catch { /* skip invalid */ }
|
|
629
651
|
}
|
|
630
652
|
|
|
631
653
|
const taskCount = completed + inProgress + pending;
|
|
@@ -764,7 +786,7 @@ function loadSessionMetadata() {
|
|
|
764
786
|
for (const entry of indexEntries) {
|
|
765
787
|
if (entry.projectPath) { indexProjectPath = entry.projectPath; break; }
|
|
766
788
|
}
|
|
767
|
-
} catch
|
|
789
|
+
} catch {}
|
|
768
790
|
}
|
|
769
791
|
|
|
770
792
|
// First pass: read all JSONL files
|
|
@@ -784,7 +806,7 @@ function loadSessionMetadata() {
|
|
|
784
806
|
// JSONLs that only hold custom-title/agent-name records when a session
|
|
785
807
|
// is continued from a worktree). Don't let a weaker entry (no cwd, no
|
|
786
808
|
// project) overwrite a previously resolved one — just merge scalars.
|
|
787
|
-
if (existing
|
|
809
|
+
if (existing?.project && !candidateProject) {
|
|
788
810
|
if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
|
|
789
811
|
if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
|
|
790
812
|
if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
|
|
@@ -926,7 +948,7 @@ function getWorkflowIndex() {
|
|
|
926
948
|
function getWorkflowScripts(sessionId) {
|
|
927
949
|
const idx = getWorkflowIndex();
|
|
928
950
|
let list = idx.get(sessionId);
|
|
929
|
-
if (!list
|
|
951
|
+
if (!list?.length) {
|
|
930
952
|
const alt = resolveSessionId(sessionId);
|
|
931
953
|
if (alt && alt !== sessionId) list = idx.get(alt);
|
|
932
954
|
}
|
|
@@ -935,7 +957,7 @@ function getWorkflowScripts(sessionId) {
|
|
|
935
957
|
|
|
936
958
|
function getWorkflowInfoSummary(sessionId) {
|
|
937
959
|
const list = getWorkflowIndex().get(sessionId);
|
|
938
|
-
return { hasWorkflow: !!(list
|
|
960
|
+
return { hasWorkflow: !!(list?.length), workflowCount: list ? list.length : 0 };
|
|
939
961
|
}
|
|
940
962
|
|
|
941
963
|
function getPlanInfo(slug) {
|
|
@@ -946,7 +968,7 @@ function getPlanInfo(slug) {
|
|
|
946
968
|
const head = readFileSync(planPath, 'utf8').slice(0, 512);
|
|
947
969
|
const match = head.match(/^#\s+(.+)$/m);
|
|
948
970
|
return { hasPlan: true, planTitle: match ? match[1].trim() : null, planPath };
|
|
949
|
-
} catch
|
|
971
|
+
} catch {
|
|
950
972
|
return { hasPlan: true, planTitle: null, planPath };
|
|
951
973
|
}
|
|
952
974
|
}
|
|
@@ -956,7 +978,7 @@ function getPlanInfo(slug) {
|
|
|
956
978
|
const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
|
|
957
979
|
|
|
958
980
|
function isWakeupActive(w, now = Date.now()) {
|
|
959
|
-
if (!w
|
|
981
|
+
if (!w?.timestamp || w.delaySeconds == null) return true;
|
|
960
982
|
const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
|
|
961
983
|
return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
|
|
962
984
|
}
|
|
@@ -996,7 +1018,7 @@ function getLoopInfoSummary(meta) {
|
|
|
996
1018
|
} catch (_) { return empty; }
|
|
997
1019
|
}
|
|
998
1020
|
|
|
999
|
-
function getSessionDisplayName(
|
|
1021
|
+
function getSessionDisplayName(_sessionId, meta) {
|
|
1000
1022
|
if (meta?.customTitle) return meta.customTitle;
|
|
1001
1023
|
if (meta?.slug) return meta.slug;
|
|
1002
1024
|
return null;
|
|
@@ -1052,6 +1074,9 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1052
1074
|
};
|
|
1053
1075
|
}
|
|
1054
1076
|
|
|
1077
|
+
// #endregion
|
|
1078
|
+
|
|
1079
|
+
// #region SESSION_ROUTES
|
|
1055
1080
|
// API: List all sessions
|
|
1056
1081
|
app.get('/api/sessions', async (req, res) => {
|
|
1057
1082
|
// Prevent browser caching
|
|
@@ -1235,7 +1260,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1235
1260
|
hasWaitingForUser: true,
|
|
1236
1261
|
}));
|
|
1237
1262
|
}
|
|
1238
|
-
} catch
|
|
1263
|
+
} catch { /* ignore */ }
|
|
1239
1264
|
}
|
|
1240
1265
|
|
|
1241
1266
|
// Enrich leader sessions with team info and remove team-named duplicates
|
|
@@ -1320,13 +1345,13 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1320
1345
|
|
|
1321
1346
|
// Correlate plan sessions with their implementation sessions (same slug)
|
|
1322
1347
|
const slugGroups = new Map();
|
|
1323
|
-
for (const [
|
|
1348
|
+
for (const [_sid, session] of sessionsMap) {
|
|
1324
1349
|
if (session.slug) {
|
|
1325
1350
|
if (!slugGroups.has(session.slug)) slugGroups.set(session.slug, []);
|
|
1326
1351
|
slugGroups.get(session.slug).push(session);
|
|
1327
1352
|
}
|
|
1328
1353
|
}
|
|
1329
|
-
for (const [
|
|
1354
|
+
for (const [_slug, group] of slugGroups) {
|
|
1330
1355
|
if (group.length < 2) continue;
|
|
1331
1356
|
group.sort((a, b) => new Date(a.modifiedAt) - new Date(b.modifiedAt));
|
|
1332
1357
|
const planSession = group.find(s => s.hasPlan);
|
|
@@ -1428,7 +1453,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1428
1453
|
});
|
|
1429
1454
|
|
|
1430
1455
|
// API: Get distinct project paths with last-modified timestamps
|
|
1431
|
-
app.get('/api/projects', (
|
|
1456
|
+
app.get('/api/projects', (_req, res) => {
|
|
1432
1457
|
res.setHeader('Cache-Control', 'no-store');
|
|
1433
1458
|
const metadata = loadSessionMetadata();
|
|
1434
1459
|
const projectMap = {};
|
|
@@ -1467,7 +1492,7 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
|
1467
1492
|
}
|
|
1468
1493
|
|
|
1469
1494
|
// Sort by ID (numeric)
|
|
1470
|
-
tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
1495
|
+
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
1471
1496
|
|
|
1472
1497
|
res.json(tasks);
|
|
1473
1498
|
} catch (error) {
|
|
@@ -1514,7 +1539,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
|
|
|
1514
1539
|
} catch (_) {}
|
|
1515
1540
|
}
|
|
1516
1541
|
}
|
|
1517
|
-
tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
1542
|
+
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
1518
1543
|
res.json(tasks);
|
|
1519
1544
|
} catch (error) {
|
|
1520
1545
|
console.error('Error getting project tasks:', error);
|
|
@@ -1596,6 +1621,9 @@ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
|
|
|
1596
1621
|
}
|
|
1597
1622
|
});
|
|
1598
1623
|
|
|
1624
|
+
// #endregion
|
|
1625
|
+
|
|
1626
|
+
// #region WORKFLOWS
|
|
1599
1627
|
// meta.phases and meta.name/description are pure literals per the Workflow tool
|
|
1600
1628
|
// contract, so pull them by regex rather than executing the untrusted script.
|
|
1601
1629
|
// name/description take the first match (the meta block is at the top of the file).
|
|
@@ -1609,9 +1637,7 @@ function parseWorkflowMeta(source) {
|
|
|
1609
1637
|
const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
|
|
1610
1638
|
const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
|
|
1611
1639
|
if (phasesM) {
|
|
1612
|
-
const
|
|
1613
|
-
let m;
|
|
1614
|
-
while ((m = re.exec(phasesM[1]))) {
|
|
1640
|
+
for (const m of phasesM[1].matchAll(/\{[\s\S]*?\}/g)) {
|
|
1615
1641
|
const title = matchStr(m[0], 'title');
|
|
1616
1642
|
if (title) meta.phases.push({ title, detail: matchStr(m[0], 'detail') });
|
|
1617
1643
|
}
|
|
@@ -1781,6 +1807,9 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
|
1781
1807
|
}
|
|
1782
1808
|
});
|
|
1783
1809
|
|
|
1810
|
+
// #endregion
|
|
1811
|
+
|
|
1812
|
+
// #region OPEN_TARGETS
|
|
1784
1813
|
// API: Open folder (and optionally a file within it) in editor
|
|
1785
1814
|
app.post('/api/open-folder', (req, res) => {
|
|
1786
1815
|
try {
|
|
@@ -1810,7 +1839,7 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1810
1839
|
const safeName = (title || 'message').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
|
|
1811
1840
|
const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
|
|
1812
1841
|
const tmpFile = path.join(os.tmpdir(), `claude-kanban-${safeName}-${hash}.md`);
|
|
1813
|
-
require('fs').writeFileSync(tmpFile, content, 'utf8');
|
|
1842
|
+
require('node:fs').writeFileSync(tmpFile, content, 'utf8');
|
|
1814
1843
|
|
|
1815
1844
|
openInEditor([tmpFile]);
|
|
1816
1845
|
res.json({ success: true, path: tmpFile });
|
|
@@ -1821,6 +1850,60 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1821
1850
|
}
|
|
1822
1851
|
});
|
|
1823
1852
|
|
|
1853
|
+
// A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
|
|
1854
|
+
// external `scratch` CLI. cck only recognizes the shape and launches the viewer —
|
|
1855
|
+
// it never parses the manifest, so the two stay independently versioned.
|
|
1856
|
+
const SCRATCHPAD_MANIFEST = 'scratchpad.json';
|
|
1857
|
+
|
|
1858
|
+
// Resolve a linked path to the pad the viewer wants: `<parent>/<name>` where
|
|
1859
|
+
// `<name>/scratchpad.json` exists. Accepts either the manifest or its directory.
|
|
1860
|
+
function resolveScratchpad(value) {
|
|
1861
|
+
const target = assertOpenTarget(value, 'path');
|
|
1862
|
+
const dir = path.basename(target).toLowerCase() === SCRATCHPAD_MANIFEST ? path.dirname(target) : target;
|
|
1863
|
+
if (!existsSync(path.join(dir, SCRATCHPAD_MANIFEST))) throw previewError(400, 'Not a scratchpad');
|
|
1864
|
+
return { name: path.basename(dir), parent: path.dirname(dir) };
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// API: Open a scratchpad in the external `scratch` viewer — the window outlives
|
|
1868
|
+
// this request and cck never waits on it.
|
|
1869
|
+
app.post('/api/scratchpad/open', (req, res) => {
|
|
1870
|
+
try {
|
|
1871
|
+
const bin = whichSync('scratch');
|
|
1872
|
+
if (!bin) return res.status(501).json({ error: 'scratch CLI not found on PATH' });
|
|
1873
|
+
const { name, parent } = resolveScratchpad(req.body?.path);
|
|
1874
|
+
// .cmd/.bat cannot be spawned without a shell since the CVE-2024-27980 fix;
|
|
1875
|
+
// scratch ships a real .exe, so resolve it rather than reintroducing one.
|
|
1876
|
+
const ext = path.extname(bin).toLowerCase();
|
|
1877
|
+
const file = ext === '.cmd' || ext === '.bat' ? exeBehindShim(bin) : bin;
|
|
1878
|
+
if (!file) return res.status(501).json({ error: 'scratch CLI cannot be launched without a shell' });
|
|
1879
|
+
// `scratch ui` is a foreground supervisor: it reloads the window on edits and
|
|
1880
|
+
// quits on stdin EOF. So stdin must be an open pipe we never write to —
|
|
1881
|
+
// 'ignore' is /dev/null, which reads as EOF and closes the viewer at once.
|
|
1882
|
+
//
|
|
1883
|
+
// And no `detached`: on Windows that means DETACHED_PROCESS, which overrides
|
|
1884
|
+
// the CREATE_NO_WINDOW `windowsHide` asks for, so the bun shim allocates its
|
|
1885
|
+
// own console and a blank terminal window pops up next to the viewer.
|
|
1886
|
+
const child = spawn(file, ['ui', name, '--dir', parent], {
|
|
1887
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
1888
|
+
windowsHide: true,
|
|
1889
|
+
});
|
|
1890
|
+
child.on('error', (e) => console.error('scratch ui failed to start:', e.message));
|
|
1891
|
+
// Nothing is ever written to it, but an unhandled EPIPE when the user closes
|
|
1892
|
+
// the viewer would take the server down with it.
|
|
1893
|
+
child.stdin.on('error', () => {});
|
|
1894
|
+
child.stdin.unref();
|
|
1895
|
+
child.unref();
|
|
1896
|
+
res.json({ success: true, pad: name });
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
if (error.status) return res.status(error.status).json({ error: error.message });
|
|
1899
|
+
console.error('Error opening scratchpad:', error);
|
|
1900
|
+
res.status(500).json({ error: 'Failed to open scratchpad' });
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
// #endregion
|
|
1905
|
+
|
|
1906
|
+
// #region AGENT_ROUTES
|
|
1824
1907
|
// API: Get team config
|
|
1825
1908
|
app.get('/api/teams/:name', (req, res) => {
|
|
1826
1909
|
const config = loadTeamConfig(req.params.name);
|
|
@@ -1872,7 +1955,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1872
1955
|
}
|
|
1873
1956
|
}
|
|
1874
1957
|
agents.push(agent);
|
|
1875
|
-
} catch
|
|
1958
|
+
} catch { /* skip invalid */ }
|
|
1876
1959
|
}
|
|
1877
1960
|
const liveAgents = agents.filter(isAgentLive);
|
|
1878
1961
|
if (liveAgents.length && meta.jsonlPath) {
|
|
@@ -2029,7 +2112,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2029
2112
|
// Subagents (Explore, general-purpose, etc.) are not in teamMemberNames and bypass
|
|
2030
2113
|
// dedup entirely, so parallel siblings of the same subagent type remain visible.
|
|
2031
2114
|
let visibleAgents = agents;
|
|
2032
|
-
if (teamMemberNames
|
|
2115
|
+
if (teamMemberNames?.size) {
|
|
2033
2116
|
const groups = new Map();
|
|
2034
2117
|
for (const a of agents) {
|
|
2035
2118
|
const t = agentDisplayName(a);
|
|
@@ -2051,7 +2134,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2051
2134
|
|
|
2052
2135
|
const waitingForUser = checkWaitingForUser(agentDir, logMtime);
|
|
2053
2136
|
res.json({ agents: visibleAgents, waitingForUser, teamColors });
|
|
2054
|
-
} catch
|
|
2137
|
+
} catch {
|
|
2055
2138
|
res.json({ agents: [], waitingForUser: null });
|
|
2056
2139
|
}
|
|
2057
2140
|
});
|
|
@@ -2065,7 +2148,7 @@ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
|
|
|
2065
2148
|
try {
|
|
2066
2149
|
clearWaitingFile(resolveSessionId(req.params.sessionId));
|
|
2067
2150
|
res.json({ ok: true });
|
|
2068
|
-
} catch
|
|
2151
|
+
} catch {
|
|
2069
2152
|
res.status(500).json({ error: 'Failed to discard waiting' });
|
|
2070
2153
|
}
|
|
2071
2154
|
});
|
|
@@ -2079,7 +2162,7 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
|
2079
2162
|
const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
2080
2163
|
let marker = null;
|
|
2081
2164
|
try { marker = JSON.parse(readFileSync(path.join(dir, '_waiting.json'), 'utf8')); }
|
|
2082
|
-
catch
|
|
2165
|
+
catch { /* missing or invalid → buildDecision reports 410 */ }
|
|
2083
2166
|
// The gate stopped polling after waitSeconds — an orphaned decision file would
|
|
2084
2167
|
// sit unconsumed while the card pretends the click worked.
|
|
2085
2168
|
if (marker && isWaitingLapsed(marker)) {
|
|
@@ -2090,7 +2173,7 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
|
2090
2173
|
try {
|
|
2091
2174
|
writeJsonAtomic(path.join(dir, decisionFileName(marker.id)), result.decision);
|
|
2092
2175
|
res.json({ ok: true });
|
|
2093
|
-
} catch
|
|
2176
|
+
} catch {
|
|
2094
2177
|
res.status(500).json({ error: 'Failed to write decision' });
|
|
2095
2178
|
}
|
|
2096
2179
|
});
|
|
@@ -2098,21 +2181,24 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
|
2098
2181
|
app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
|
|
2099
2182
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2100
2183
|
const agentId = sanitizeAgentId(req.params.agentId);
|
|
2101
|
-
const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId
|
|
2184
|
+
const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, `${agentId}.jsonl`);
|
|
2102
2185
|
if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
|
|
2103
2186
|
try {
|
|
2104
2187
|
const agent = readAgentJsonl(agentFile);
|
|
2105
2188
|
agent.status = 'stopped';
|
|
2106
2189
|
agent.stoppedAt = new Date().toISOString();
|
|
2107
2190
|
const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
|
|
2108
|
-
writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt)
|
|
2191
|
+
writeFileSync(agentFile, `${readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt)}\n`, 'utf8'); // sync — response depends on write
|
|
2109
2192
|
clearWaitingFile(sessionId);
|
|
2110
2193
|
res.json({ ok: true });
|
|
2111
|
-
} catch
|
|
2194
|
+
} catch {
|
|
2112
2195
|
res.status(500).json({ error: 'Failed to stop agent' });
|
|
2113
2196
|
}
|
|
2114
2197
|
});
|
|
2115
2198
|
|
|
2199
|
+
// #endregion
|
|
2200
|
+
|
|
2201
|
+
// #region SUBAGENT_RESOLUTION
|
|
2116
2202
|
function sanitizeAgentId(raw) {
|
|
2117
2203
|
return path.basename(raw).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
2118
2204
|
}
|
|
@@ -2122,7 +2208,7 @@ function sessionDirFromMeta(meta) {
|
|
|
2122
2208
|
}
|
|
2123
2209
|
|
|
2124
2210
|
function subagentJsonlPath(meta, agentId) {
|
|
2125
|
-
return path.join(sessionDirFromMeta(meta), 'subagents',
|
|
2211
|
+
return path.join(sessionDirFromMeta(meta), 'subagents', `agent-${agentId}.jsonl`);
|
|
2126
2212
|
}
|
|
2127
2213
|
|
|
2128
2214
|
// A subagent transcript is normally at <sessionDir>/subagents/agent-<id>.jsonl.
|
|
@@ -2130,14 +2216,14 @@ function subagentJsonlPath(meta, agentId) {
|
|
|
2130
2216
|
// <sessionDir>/subagents/workflows/<wf_id>/agent-<id>.jsonl. Return whichever
|
|
2131
2217
|
// exists (flat first), else null.
|
|
2132
2218
|
function subagentJsonlInDir(sessionDir, agentId) {
|
|
2133
|
-
const flat = path.join(sessionDir, 'subagents',
|
|
2219
|
+
const flat = path.join(sessionDir, 'subagents', `agent-${agentId}.jsonl`);
|
|
2134
2220
|
if (existsSync(flat)) return flat;
|
|
2135
2221
|
const wfRoot = path.join(sessionDir, 'subagents', 'workflows');
|
|
2136
2222
|
let wfDirs;
|
|
2137
2223
|
try { wfDirs = readdirSync(wfRoot, { withFileTypes: true }); } catch { return null; }
|
|
2138
2224
|
for (const wf of wfDirs) {
|
|
2139
2225
|
if (!wf.isDirectory()) continue;
|
|
2140
|
-
const nested = path.join(wfRoot, wf.name,
|
|
2226
|
+
const nested = path.join(wfRoot, wf.name, `agent-${agentId}.jsonl`);
|
|
2141
2227
|
if (existsSync(nested)) return nested;
|
|
2142
2228
|
}
|
|
2143
2229
|
return null;
|
|
@@ -2172,7 +2258,7 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
|
|
|
2172
2258
|
// Same session dir: flat path or nested under subagents/workflows/ (both checked here).
|
|
2173
2259
|
const local = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
|
|
2174
2260
|
if (local) return local;
|
|
2175
|
-
const key = sessionId
|
|
2261
|
+
const key = `${sessionId}/${agentId}`;
|
|
2176
2262
|
const cached = subagentPathCache.get(key);
|
|
2177
2263
|
if (cached) return cached;
|
|
2178
2264
|
let found = null;
|
|
@@ -2262,7 +2348,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2262
2348
|
try { text = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
2263
2349
|
if (!text.includes(targetUuid)) continue;
|
|
2264
2350
|
for (const l of text.split('\n')) {
|
|
2265
|
-
if (!l
|
|
2351
|
+
if (!l?.includes(targetUuid)) continue;
|
|
2266
2352
|
try {
|
|
2267
2353
|
const d = JSON.parse(l);
|
|
2268
2354
|
if (d.uuid === targetUuid && d.sessionId) {
|
|
@@ -2314,6 +2400,9 @@ function lookupParentSession(sessionId) {
|
|
|
2314
2400
|
parentSessionCache.set(sessionId, result);
|
|
2315
2401
|
return result;
|
|
2316
2402
|
}
|
|
2403
|
+
// #endregion
|
|
2404
|
+
|
|
2405
|
+
// #region MESSAGE_ROUTES
|
|
2317
2406
|
app.get('/api/sessions/:sessionId/parent', (req, res) => {
|
|
2318
2407
|
res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
|
|
2319
2408
|
});
|
|
@@ -2413,7 +2502,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
2413
2502
|
if (entry.usage) msg.agentUsageStats = entry.usage;
|
|
2414
2503
|
if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
|
|
2415
2504
|
try {
|
|
2416
|
-
const agentFile = path.join(agentDir, entry.agentId
|
|
2505
|
+
const agentFile = path.join(agentDir, `${entry.agentId}.jsonl`);
|
|
2417
2506
|
const agent = readAgentJsonl(agentFile);
|
|
2418
2507
|
if (agent.lastMessage) msg.agentLastMessage = agent.lastMessage;
|
|
2419
2508
|
if (agent.prompt && !msg.agentPrompt) msg.agentPrompt = agent.prompt;
|
|
@@ -2436,7 +2525,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
2436
2525
|
if (unresolved.length && existsSync(agentDir)) {
|
|
2437
2526
|
const activity = listAgentFiles(agentDir)
|
|
2438
2527
|
.map((f) => { try { return readAgentJsonl(path.join(agentDir, f)); } catch (_) { return null; } })
|
|
2439
|
-
.filter((a) => a
|
|
2528
|
+
.filter((a) => a?.agentId && a.prompt);
|
|
2440
2529
|
const usedIds = new Set(agentMessages.map(m => m.agentId).filter(Boolean));
|
|
2441
2530
|
for (const msg of unresolved) {
|
|
2442
2531
|
const key = msg.agentPrompt.slice(0, 200);
|
|
@@ -2636,17 +2725,28 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
|
|
|
2636
2725
|
res.end(img.buffer);
|
|
2637
2726
|
});
|
|
2638
2727
|
|
|
2639
|
-
|
|
2728
|
+
// #endregion
|
|
2729
|
+
|
|
2730
|
+
// #region META_ROUTES
|
|
2731
|
+
app.get('/api/version', (_req, res) => {
|
|
2640
2732
|
const pkg = require('./package.json');
|
|
2641
2733
|
res.json({ version: pkg.version });
|
|
2642
2734
|
});
|
|
2643
2735
|
|
|
2644
|
-
app.get('/api/config', (
|
|
2645
|
-
res.json({
|
|
2736
|
+
app.get('/api/config', (_req, res) => {
|
|
2737
|
+
res.json({
|
|
2738
|
+
marketplaceUrl: MARKETPLACE_URL,
|
|
2739
|
+
costUrl: COST_URL,
|
|
2740
|
+
memoryUrl: MEMORY_URL,
|
|
2741
|
+
scratchAvailable: !!whichSync('scratch'),
|
|
2742
|
+
});
|
|
2646
2743
|
});
|
|
2647
2744
|
|
|
2745
|
+
// #endregion
|
|
2746
|
+
|
|
2747
|
+
// #region TASK_ROUTES
|
|
2648
2748
|
// API: Get all tasks across all sessions
|
|
2649
|
-
app.get('/api/tasks/all', async (
|
|
2749
|
+
app.get('/api/tasks/all', async (_req, res) => {
|
|
2650
2750
|
try {
|
|
2651
2751
|
if (!existsSync(TASKS_DIR)) {
|
|
2652
2752
|
return res.json([]);
|
|
@@ -2686,7 +2786,7 @@ app.get('/api/tasks/all', async (req, res) => {
|
|
|
2686
2786
|
sessionName: getSessionDisplayName(sessionDir.name, meta),
|
|
2687
2787
|
project
|
|
2688
2788
|
});
|
|
2689
|
-
} catch
|
|
2789
|
+
} catch {
|
|
2690
2790
|
// Skip invalid files
|
|
2691
2791
|
}
|
|
2692
2792
|
}
|
|
@@ -2791,16 +2891,16 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2791
2891
|
return res.status(404).json({ error: 'Task not found' });
|
|
2792
2892
|
}
|
|
2793
2893
|
|
|
2794
|
-
//
|
|
2894
|
+
// A deleted task can't block anything, so drop the dangling reference instead of
|
|
2895
|
+
// refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
|
|
2795
2896
|
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
2796
2897
|
|
|
2797
2898
|
for (const file of taskFiles) {
|
|
2798
|
-
const
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
});
|
|
2899
|
+
const otherPath = path.join(sessionPath, file);
|
|
2900
|
+
const otherTask = JSON.parse(readFileSync(otherPath, 'utf8'));
|
|
2901
|
+
if (otherTask.blockedBy?.includes(taskId)) {
|
|
2902
|
+
otherTask.blockedBy = otherTask.blockedBy.filter(id => id !== taskId);
|
|
2903
|
+
await fs.writeFile(otherPath, JSON.stringify(otherTask, null, 2));
|
|
2804
2904
|
}
|
|
2805
2905
|
}
|
|
2806
2906
|
|
|
@@ -2814,6 +2914,9 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2814
2914
|
}
|
|
2815
2915
|
});
|
|
2816
2916
|
|
|
2917
|
+
// #endregion
|
|
2918
|
+
|
|
2919
|
+
// #region PREVIEW
|
|
2817
2920
|
// API: File preview — read file and broadcast to clients
|
|
2818
2921
|
const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
|
|
2819
2922
|
// Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
|
|
@@ -2884,8 +2987,11 @@ function dropUrlFragment(abs) {
|
|
|
2884
2987
|
return existsSync(trimmed) ? trimmed : abs;
|
|
2885
2988
|
}
|
|
2886
2989
|
|
|
2887
|
-
function resolvePreviewPath(
|
|
2888
|
-
if (!
|
|
2990
|
+
function resolvePreviewPath(rawPath, base) {
|
|
2991
|
+
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
2992
|
+
// Every path entering the preview/link surface passes through here, so accepting
|
|
2993
|
+
// `file://` once covers /api/preview, /api/document/link and /api/file/resolve.
|
|
2994
|
+
const filePath = fileUrlToPath(rawPath);
|
|
2889
2995
|
if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
|
|
2890
2996
|
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
2891
2997
|
let baseDir = base;
|
|
@@ -2990,7 +3096,7 @@ app.post('/api/session/pin', async (req, res) => {
|
|
|
2990
3096
|
}
|
|
2991
3097
|
});
|
|
2992
3098
|
|
|
2993
|
-
app.get('/api/session/pins', (
|
|
3099
|
+
app.get('/api/session/pins', (_req, res) => {
|
|
2994
3100
|
res.setHeader('Cache-Control', 'no-store');
|
|
2995
3101
|
try {
|
|
2996
3102
|
const pins = readPins();
|
|
@@ -3029,6 +3135,9 @@ app.get('/api/file/resolve', async (req, res) => {
|
|
|
3029
3135
|
}
|
|
3030
3136
|
});
|
|
3031
3137
|
|
|
3138
|
+
// #endregion
|
|
3139
|
+
|
|
3140
|
+
// #region SSE
|
|
3032
3141
|
// SSE endpoint for live updates
|
|
3033
3142
|
app.get('/api/events', (req, res) => {
|
|
3034
3143
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -3058,11 +3167,11 @@ function broadcast(data) {
|
|
|
3058
3167
|
}
|
|
3059
3168
|
}
|
|
3060
3169
|
|
|
3061
|
-
app.get('/api/context-status', (
|
|
3170
|
+
app.get('/api/context-status', (_req, res) => {
|
|
3062
3171
|
res.json(Object.fromEntries(contextStatusCache));
|
|
3063
3172
|
});
|
|
3064
3173
|
|
|
3065
|
-
app.use('/api', (
|
|
3174
|
+
app.use('/api', (_req, res) => {
|
|
3066
3175
|
res.status(404).json({ error: 'Not found' });
|
|
3067
3176
|
});
|
|
3068
3177
|
|
|
@@ -3088,6 +3197,9 @@ watcher.on('all', (event, filePath) => {
|
|
|
3088
3197
|
// shared list or a team dir is one directory several sessions map onto -- so anything
|
|
3089
3198
|
// addressing a session from a task path has to fan out the same way. One resolver for
|
|
3090
3199
|
// both readers of that mapping: the SSE broadcast and the doorbell.
|
|
3200
|
+
// #endregion
|
|
3201
|
+
|
|
3202
|
+
// #region DOORBELL
|
|
3091
3203
|
function resolveSessionsForTaskDir(name) {
|
|
3092
3204
|
if (isUUID(name)) return [name];
|
|
3093
3205
|
const { listToSessions } = loadAllTaskMaps();
|
|
@@ -3218,7 +3330,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
|
|
|
3218
3330
|
fs.unlink(file).catch(() => {});
|
|
3219
3331
|
}
|
|
3220
3332
|
}
|
|
3221
|
-
} catch
|
|
3333
|
+
} catch { /* ignore */ }
|
|
3222
3334
|
}
|
|
3223
3335
|
broadcast({ type: 'agent-update', sessionId });
|
|
3224
3336
|
// For team sessions, also broadcast with team name so frontend picks it up
|
|
@@ -3232,7 +3344,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
|
|
|
3232
3344
|
break;
|
|
3233
3345
|
}
|
|
3234
3346
|
}
|
|
3235
|
-
} catch
|
|
3347
|
+
} catch { /* ignore */ }
|
|
3236
3348
|
}
|
|
3237
3349
|
}
|
|
3238
3350
|
});
|
|
@@ -3253,7 +3365,7 @@ contextStatusWatcher.on('all', (event, filePath) => {
|
|
|
3253
3365
|
try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
|
|
3254
3366
|
contextStatusCache.set(sessionId, data);
|
|
3255
3367
|
evictStaleCache(contextStatusCache);
|
|
3256
|
-
} catch
|
|
3368
|
+
} catch { /* ignore malformed */ }
|
|
3257
3369
|
broadcast({ type: 'context-update', sessionId });
|
|
3258
3370
|
} else if (event === 'unlink') {
|
|
3259
3371
|
contextStatusCache.delete(sessionId);
|
|
@@ -3261,6 +3373,9 @@ contextStatusWatcher.on('all', (event, filePath) => {
|
|
|
3261
3373
|
}
|
|
3262
3374
|
});
|
|
3263
3375
|
|
|
3376
|
+
// #endregion
|
|
3377
|
+
|
|
3378
|
+
// #region CLEANUP
|
|
3264
3379
|
async function cleanupContextStatus() {
|
|
3265
3380
|
try {
|
|
3266
3381
|
const entries = await fs.readdir(CONTEXT_STATUS_DIR);
|
|
@@ -3274,9 +3389,9 @@ async function cleanupContextStatus() {
|
|
|
3274
3389
|
await fs.unlink(fp);
|
|
3275
3390
|
contextStatusCache.delete(path.basename(f, '.json'));
|
|
3276
3391
|
}
|
|
3277
|
-
} catch
|
|
3392
|
+
} catch { /* ignore */ }
|
|
3278
3393
|
}
|
|
3279
|
-
} catch
|
|
3394
|
+
} catch { /* dir may not exist */ }
|
|
3280
3395
|
}
|
|
3281
3396
|
|
|
3282
3397
|
async function cleanupAgentActivity() {
|
|
@@ -3303,11 +3418,11 @@ async function cleanupAgentActivity() {
|
|
|
3303
3418
|
try {
|
|
3304
3419
|
const fst = await fs.stat(fp);
|
|
3305
3420
|
if (now - fst.mtimeMs > PERMISSION_TTL_MS) await fs.rm(fp, { force: true });
|
|
3306
|
-
} catch
|
|
3421
|
+
} catch { /* ignore per-file errors */ }
|
|
3307
3422
|
}
|
|
3308
|
-
} catch
|
|
3423
|
+
} catch { /* ignore per-folder errors */ }
|
|
3309
3424
|
}
|
|
3310
|
-
} catch
|
|
3425
|
+
} catch { /* agent-activity dir may not exist */ }
|
|
3311
3426
|
}
|
|
3312
3427
|
|
|
3313
3428
|
cleanupAgentActivity();
|
|
@@ -3364,3 +3479,5 @@ async function prewarmCaches() {
|
|
|
3364
3479
|
}
|
|
3365
3480
|
});
|
|
3366
3481
|
|
|
3482
|
+
|
|
3483
|
+
// #endregion
|