claude-code-kanban 4.20.0 → 4.22.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 +1 -0
- package/lib/approvals.js +82 -0
- package/lib/file-url.js +36 -0
- package/package.json +2 -2
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +2 -24
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +17 -3
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +141 -0
- package/public/app.js +1335 -165
- package/public/index.html +1 -0
- package/public/style.css +648 -39
- package/server.js +256 -81
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,
|
|
@@ -33,6 +33,7 @@ const {
|
|
|
33
33
|
buildLoopInfoFromState
|
|
34
34
|
} = require('./lib/parsers');
|
|
35
35
|
const { inlineHtmlAssets } = require('./lib/inline-assets');
|
|
36
|
+
const { buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed } = require('./lib/approvals');
|
|
36
37
|
|
|
37
38
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
38
39
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -98,6 +99,9 @@ const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
|
98
99
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
99
100
|
const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
|
|
100
101
|
|
|
102
|
+
// #endregion
|
|
103
|
+
|
|
104
|
+
// #region SERVER_STATE
|
|
101
105
|
// Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
|
|
102
106
|
function readPins() {
|
|
103
107
|
try {
|
|
@@ -128,6 +132,8 @@ function writeServerInfo(port) {
|
|
|
128
132
|
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
// #endregion
|
|
136
|
+
|
|
131
137
|
// #region TIMINGS
|
|
132
138
|
const PERMISSION_TTL_MS = 30 * 60 * 1000;
|
|
133
139
|
const AGENT_TTL_MS = 60 * 60 * 1000;
|
|
@@ -143,6 +149,8 @@ const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
|
143
149
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
|
144
150
|
// #endregion
|
|
145
151
|
|
|
152
|
+
// #region AGENT_ACTIVITY
|
|
153
|
+
|
|
146
154
|
function readAgentJsonl(filePath) {
|
|
147
155
|
const raw = readFileSync(filePath, 'utf8');
|
|
148
156
|
const merged = {};
|
|
@@ -164,8 +172,27 @@ function listAgentFiles(agentDir) {
|
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
function persistAgent(dir, agent) {
|
|
167
|
-
const file = path.join(dir, agent.agentId
|
|
168
|
-
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(() => {});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
|
|
180
|
+
// this only reads the config, cached so the polled session list doesn't re-read
|
|
181
|
+
// it per hit (marker stays visible for the badge until TTL).
|
|
182
|
+
const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
|
|
183
|
+
let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
|
|
184
|
+
function approvalsWaitMs() {
|
|
185
|
+
if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
|
|
186
|
+
let cfg = null;
|
|
187
|
+
try {
|
|
188
|
+
cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
|
|
189
|
+
} catch { /* missing config — gate default */ }
|
|
190
|
+
approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
|
|
191
|
+
return approvalsWaitCache.ms;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isWaitingLapsed(data) {
|
|
195
|
+
return isLapsed(data.timestamp, approvalsWaitMs());
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
function checkWaitingForUser(agentDir, logMtime) {
|
|
@@ -177,9 +204,10 @@ function checkWaitingForUser(agentDir, logMtime) {
|
|
|
177
204
|
if (age >= PERMISSION_TTL_MS) return null;
|
|
178
205
|
// After grace period, check if session resumed activity (user already responded)
|
|
179
206
|
if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
|
|
207
|
+
if (isWaitingLapsed(data)) return { ...data, lapsed: true };
|
|
180
208
|
return data;
|
|
181
209
|
}
|
|
182
|
-
} catch
|
|
210
|
+
} catch { /* skip — missing or invalid */ }
|
|
183
211
|
return null;
|
|
184
212
|
}
|
|
185
213
|
|
|
@@ -253,7 +281,7 @@ function getSessionLogStat(meta) {
|
|
|
253
281
|
try {
|
|
254
282
|
const st = statSync(meta.jsonlPath);
|
|
255
283
|
return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
|
|
256
|
-
} catch
|
|
284
|
+
} catch { return { mtime: null, hasMessages: false }; }
|
|
257
285
|
}
|
|
258
286
|
|
|
259
287
|
function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
@@ -273,12 +301,15 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
|
273
301
|
result.hasRunning = true;
|
|
274
302
|
}
|
|
275
303
|
if (result.hasRunning) break;
|
|
276
|
-
} catch
|
|
304
|
+
} catch { /* skip invalid */ }
|
|
277
305
|
}
|
|
278
|
-
} catch
|
|
306
|
+
} catch { /* ignore */ }
|
|
279
307
|
return result;
|
|
280
308
|
}
|
|
281
309
|
|
|
310
|
+
// #endregion
|
|
311
|
+
|
|
312
|
+
// #region TEAMS
|
|
282
313
|
// isContained canonicalises both sides with realpath, which costs ~100x a plain
|
|
283
314
|
// path.join. This is called once per session on the /api/sessions hot path, and
|
|
284
315
|
// the verdict for a given name cannot change while TEAMS_DIR is fixed — so the
|
|
@@ -315,14 +346,14 @@ function loadTeamConfig(teamName) {
|
|
|
315
346
|
const data = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
316
347
|
teamConfigCache.set(teamName, { data, ts: Date.now() });
|
|
317
348
|
return data;
|
|
318
|
-
} catch
|
|
349
|
+
} catch {
|
|
319
350
|
return null;
|
|
320
351
|
}
|
|
321
352
|
}
|
|
322
353
|
|
|
323
354
|
function resolveSessionId(sessionId) {
|
|
324
355
|
const teamConfig = loadTeamConfig(sessionId);
|
|
325
|
-
return (teamConfig
|
|
356
|
+
return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
|
|
326
357
|
}
|
|
327
358
|
|
|
328
359
|
// Recent Claude Code releases auto-create a single-member "self-team" for every
|
|
@@ -436,6 +467,9 @@ function attachTeamTasks(card, teamTaskDir, teamName, counts) {
|
|
|
436
467
|
}
|
|
437
468
|
}
|
|
438
469
|
|
|
470
|
+
// #endregion
|
|
471
|
+
|
|
472
|
+
// #region SHARED_STATE
|
|
439
473
|
// SSE clients for live updates
|
|
440
474
|
const clients = new Set();
|
|
441
475
|
|
|
@@ -449,39 +483,48 @@ const METADATA_CACHE_TTL = 10000; // 10 seconds
|
|
|
449
483
|
const dirtyMetadataPaths = new Set();
|
|
450
484
|
let metadataNeedsFullScan = true;
|
|
451
485
|
|
|
486
|
+
// #endregion
|
|
487
|
+
|
|
488
|
+
// #region REQUEST_GUARDS
|
|
452
489
|
const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
453
490
|
function isSafeId(id) {
|
|
454
491
|
return typeof id === 'string' && id.length > 0 && id.length <= 128 && SAFE_ID_RE.test(id);
|
|
455
492
|
}
|
|
456
493
|
|
|
457
|
-
app.param('sessionId', (
|
|
494
|
+
app.param('sessionId', (_req, res, next, val) => {
|
|
458
495
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid session ID' });
|
|
459
496
|
next();
|
|
460
497
|
});
|
|
461
|
-
app.param('taskId', (
|
|
498
|
+
app.param('taskId', (_req, res, next, val) => {
|
|
462
499
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid task ID' });
|
|
463
500
|
next();
|
|
464
501
|
});
|
|
465
502
|
// Team names are directory names under TEAMS_DIR, so /api/teams/:name was a
|
|
466
503
|
// straight traversal before this.
|
|
467
|
-
app.param('name', (
|
|
504
|
+
app.param('name', (_req, res, next, val) => {
|
|
468
505
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid team name' });
|
|
469
506
|
next();
|
|
470
507
|
});
|
|
471
508
|
|
|
472
509
|
// Parse JSON bodies
|
|
473
510
|
app.use(express.json());
|
|
511
|
+
// #endregion
|
|
512
|
+
|
|
513
|
+
// #region STATIC
|
|
474
514
|
|
|
475
515
|
app.get('/hub-config', (_req, res) => {
|
|
476
516
|
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
|
|
477
517
|
});
|
|
478
518
|
|
|
479
519
|
// Serve static files
|
|
480
|
-
app.get('/sw.js', (
|
|
520
|
+
app.get('/sw.js', (_req, res) => {
|
|
481
521
|
res.setHeader('Cache-Control', 'no-cache');
|
|
482
522
|
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
|
|
483
523
|
});
|
|
484
524
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
525
|
+
// #endregion
|
|
526
|
+
|
|
527
|
+
// #region SESSION_LIST
|
|
485
528
|
|
|
486
529
|
const messageCache = new Map();
|
|
487
530
|
const MESSAGE_CACHE_TTL = 5000;
|
|
@@ -524,9 +567,9 @@ function loadAllTaskMaps() {
|
|
|
524
567
|
for (const sessionId of Object.keys(map)) {
|
|
525
568
|
sessionToList[sessionId] = taskListName;
|
|
526
569
|
}
|
|
527
|
-
} catch
|
|
570
|
+
} catch { /* skip invalid */ }
|
|
528
571
|
}
|
|
529
|
-
} catch
|
|
572
|
+
} catch { /* ignore */ }
|
|
530
573
|
sessionToTaskListCache = { sessionToList, listToSessions };
|
|
531
574
|
lastTaskMapScan = now;
|
|
532
575
|
return sessionToTaskListCache;
|
|
@@ -596,7 +639,7 @@ function getTaskCounts(sessionPath) {
|
|
|
596
639
|
try {
|
|
597
640
|
const taskPath = path.join(sessionPath, file);
|
|
598
641
|
const task = JSON.parse(readFileSync(taskPath, 'utf8'));
|
|
599
|
-
if (task.metadata
|
|
642
|
+
if (task.metadata?._internal) continue;
|
|
600
643
|
if (task.status === 'completed') completed++;
|
|
601
644
|
else if (task.status === 'in_progress') inProgress++;
|
|
602
645
|
else pending++;
|
|
@@ -604,7 +647,7 @@ function getTaskCounts(sessionPath) {
|
|
|
604
647
|
if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
|
|
605
648
|
newestTaskMtime = taskStat.mtime;
|
|
606
649
|
}
|
|
607
|
-
} catch
|
|
650
|
+
} catch { /* skip invalid */ }
|
|
608
651
|
}
|
|
609
652
|
|
|
610
653
|
const taskCount = completed + inProgress + pending;
|
|
@@ -743,7 +786,7 @@ function loadSessionMetadata() {
|
|
|
743
786
|
for (const entry of indexEntries) {
|
|
744
787
|
if (entry.projectPath) { indexProjectPath = entry.projectPath; break; }
|
|
745
788
|
}
|
|
746
|
-
} catch
|
|
789
|
+
} catch {}
|
|
747
790
|
}
|
|
748
791
|
|
|
749
792
|
// First pass: read all JSONL files
|
|
@@ -763,7 +806,7 @@ function loadSessionMetadata() {
|
|
|
763
806
|
// JSONLs that only hold custom-title/agent-name records when a session
|
|
764
807
|
// is continued from a worktree). Don't let a weaker entry (no cwd, no
|
|
765
808
|
// project) overwrite a previously resolved one — just merge scalars.
|
|
766
|
-
if (existing
|
|
809
|
+
if (existing?.project && !candidateProject) {
|
|
767
810
|
if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
|
|
768
811
|
if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
|
|
769
812
|
if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
|
|
@@ -905,7 +948,7 @@ function getWorkflowIndex() {
|
|
|
905
948
|
function getWorkflowScripts(sessionId) {
|
|
906
949
|
const idx = getWorkflowIndex();
|
|
907
950
|
let list = idx.get(sessionId);
|
|
908
|
-
if (!list
|
|
951
|
+
if (!list?.length) {
|
|
909
952
|
const alt = resolveSessionId(sessionId);
|
|
910
953
|
if (alt && alt !== sessionId) list = idx.get(alt);
|
|
911
954
|
}
|
|
@@ -914,7 +957,7 @@ function getWorkflowScripts(sessionId) {
|
|
|
914
957
|
|
|
915
958
|
function getWorkflowInfoSummary(sessionId) {
|
|
916
959
|
const list = getWorkflowIndex().get(sessionId);
|
|
917
|
-
return { hasWorkflow: !!(list
|
|
960
|
+
return { hasWorkflow: !!(list?.length), workflowCount: list ? list.length : 0 };
|
|
918
961
|
}
|
|
919
962
|
|
|
920
963
|
function getPlanInfo(slug) {
|
|
@@ -925,7 +968,7 @@ function getPlanInfo(slug) {
|
|
|
925
968
|
const head = readFileSync(planPath, 'utf8').slice(0, 512);
|
|
926
969
|
const match = head.match(/^#\s+(.+)$/m);
|
|
927
970
|
return { hasPlan: true, planTitle: match ? match[1].trim() : null, planPath };
|
|
928
|
-
} catch
|
|
971
|
+
} catch {
|
|
929
972
|
return { hasPlan: true, planTitle: null, planPath };
|
|
930
973
|
}
|
|
931
974
|
}
|
|
@@ -935,7 +978,7 @@ function getPlanInfo(slug) {
|
|
|
935
978
|
const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
|
|
936
979
|
|
|
937
980
|
function isWakeupActive(w, now = Date.now()) {
|
|
938
|
-
if (!w
|
|
981
|
+
if (!w?.timestamp || w.delaySeconds == null) return true;
|
|
939
982
|
const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
|
|
940
983
|
return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
|
|
941
984
|
}
|
|
@@ -975,7 +1018,7 @@ function getLoopInfoSummary(meta) {
|
|
|
975
1018
|
} catch (_) { return empty; }
|
|
976
1019
|
}
|
|
977
1020
|
|
|
978
|
-
function getSessionDisplayName(
|
|
1021
|
+
function getSessionDisplayName(_sessionId, meta) {
|
|
979
1022
|
if (meta?.customTitle) return meta.customTitle;
|
|
980
1023
|
if (meta?.slug) return meta.slug;
|
|
981
1024
|
return null;
|
|
@@ -1031,6 +1074,9 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1031
1074
|
};
|
|
1032
1075
|
}
|
|
1033
1076
|
|
|
1077
|
+
// #endregion
|
|
1078
|
+
|
|
1079
|
+
// #region SESSION_ROUTES
|
|
1034
1080
|
// API: List all sessions
|
|
1035
1081
|
app.get('/api/sessions', async (req, res) => {
|
|
1036
1082
|
// Prevent browser caching
|
|
@@ -1214,7 +1260,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1214
1260
|
hasWaitingForUser: true,
|
|
1215
1261
|
}));
|
|
1216
1262
|
}
|
|
1217
|
-
} catch
|
|
1263
|
+
} catch { /* ignore */ }
|
|
1218
1264
|
}
|
|
1219
1265
|
|
|
1220
1266
|
// Enrich leader sessions with team info and remove team-named duplicates
|
|
@@ -1299,13 +1345,13 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1299
1345
|
|
|
1300
1346
|
// Correlate plan sessions with their implementation sessions (same slug)
|
|
1301
1347
|
const slugGroups = new Map();
|
|
1302
|
-
for (const [
|
|
1348
|
+
for (const [_sid, session] of sessionsMap) {
|
|
1303
1349
|
if (session.slug) {
|
|
1304
1350
|
if (!slugGroups.has(session.slug)) slugGroups.set(session.slug, []);
|
|
1305
1351
|
slugGroups.get(session.slug).push(session);
|
|
1306
1352
|
}
|
|
1307
1353
|
}
|
|
1308
|
-
for (const [
|
|
1354
|
+
for (const [_slug, group] of slugGroups) {
|
|
1309
1355
|
if (group.length < 2) continue;
|
|
1310
1356
|
group.sort((a, b) => new Date(a.modifiedAt) - new Date(b.modifiedAt));
|
|
1311
1357
|
const planSession = group.find(s => s.hasPlan);
|
|
@@ -1407,7 +1453,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1407
1453
|
});
|
|
1408
1454
|
|
|
1409
1455
|
// API: Get distinct project paths with last-modified timestamps
|
|
1410
|
-
app.get('/api/projects', (
|
|
1456
|
+
app.get('/api/projects', (_req, res) => {
|
|
1411
1457
|
res.setHeader('Cache-Control', 'no-store');
|
|
1412
1458
|
const metadata = loadSessionMetadata();
|
|
1413
1459
|
const projectMap = {};
|
|
@@ -1446,7 +1492,7 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
|
1446
1492
|
}
|
|
1447
1493
|
|
|
1448
1494
|
// Sort by ID (numeric)
|
|
1449
|
-
tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
1495
|
+
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
1450
1496
|
|
|
1451
1497
|
res.json(tasks);
|
|
1452
1498
|
} catch (error) {
|
|
@@ -1493,7 +1539,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
|
|
|
1493
1539
|
} catch (_) {}
|
|
1494
1540
|
}
|
|
1495
1541
|
}
|
|
1496
|
-
tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
1542
|
+
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
1497
1543
|
res.json(tasks);
|
|
1498
1544
|
} catch (error) {
|
|
1499
1545
|
console.error('Error getting project tasks:', error);
|
|
@@ -1575,6 +1621,9 @@ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
|
|
|
1575
1621
|
}
|
|
1576
1622
|
});
|
|
1577
1623
|
|
|
1624
|
+
// #endregion
|
|
1625
|
+
|
|
1626
|
+
// #region WORKFLOWS
|
|
1578
1627
|
// meta.phases and meta.name/description are pure literals per the Workflow tool
|
|
1579
1628
|
// contract, so pull them by regex rather than executing the untrusted script.
|
|
1580
1629
|
// name/description take the first match (the meta block is at the top of the file).
|
|
@@ -1588,9 +1637,7 @@ function parseWorkflowMeta(source) {
|
|
|
1588
1637
|
const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
|
|
1589
1638
|
const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
|
|
1590
1639
|
if (phasesM) {
|
|
1591
|
-
const
|
|
1592
|
-
let m;
|
|
1593
|
-
while ((m = re.exec(phasesM[1]))) {
|
|
1640
|
+
for (const m of phasesM[1].matchAll(/\{[\s\S]*?\}/g)) {
|
|
1594
1641
|
const title = matchStr(m[0], 'title');
|
|
1595
1642
|
if (title) meta.phases.push({ title, detail: matchStr(m[0], 'detail') });
|
|
1596
1643
|
}
|
|
@@ -1760,6 +1807,9 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
|
1760
1807
|
}
|
|
1761
1808
|
});
|
|
1762
1809
|
|
|
1810
|
+
// #endregion
|
|
1811
|
+
|
|
1812
|
+
// #region OPEN_TARGETS
|
|
1763
1813
|
// API: Open folder (and optionally a file within it) in editor
|
|
1764
1814
|
app.post('/api/open-folder', (req, res) => {
|
|
1765
1815
|
try {
|
|
@@ -1789,7 +1839,7 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1789
1839
|
const safeName = (title || 'message').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
|
|
1790
1840
|
const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
|
|
1791
1841
|
const tmpFile = path.join(os.tmpdir(), `claude-kanban-${safeName}-${hash}.md`);
|
|
1792
|
-
require('fs').writeFileSync(tmpFile, content, 'utf8');
|
|
1842
|
+
require('node:fs').writeFileSync(tmpFile, content, 'utf8');
|
|
1793
1843
|
|
|
1794
1844
|
openInEditor([tmpFile]);
|
|
1795
1845
|
res.json({ success: true, path: tmpFile });
|
|
@@ -1800,6 +1850,60 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1800
1850
|
}
|
|
1801
1851
|
});
|
|
1802
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
|
|
1803
1907
|
// API: Get team config
|
|
1804
1908
|
app.get('/api/teams/:name', (req, res) => {
|
|
1805
1909
|
const config = loadTeamConfig(req.params.name);
|
|
@@ -1851,7 +1955,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1851
1955
|
}
|
|
1852
1956
|
}
|
|
1853
1957
|
agents.push(agent);
|
|
1854
|
-
} catch
|
|
1958
|
+
} catch { /* skip invalid */ }
|
|
1855
1959
|
}
|
|
1856
1960
|
const liveAgents = agents.filter(isAgentLive);
|
|
1857
1961
|
if (liveAgents.length && meta.jsonlPath) {
|
|
@@ -2008,7 +2112,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2008
2112
|
// Subagents (Explore, general-purpose, etc.) are not in teamMemberNames and bypass
|
|
2009
2113
|
// dedup entirely, so parallel siblings of the same subagent type remain visible.
|
|
2010
2114
|
let visibleAgents = agents;
|
|
2011
|
-
if (teamMemberNames
|
|
2115
|
+
if (teamMemberNames?.size) {
|
|
2012
2116
|
const groups = new Map();
|
|
2013
2117
|
for (const a of agents) {
|
|
2014
2118
|
const t = agentDisplayName(a);
|
|
@@ -2030,7 +2134,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2030
2134
|
|
|
2031
2135
|
const waitingForUser = checkWaitingForUser(agentDir, logMtime);
|
|
2032
2136
|
res.json({ agents: visibleAgents, waitingForUser, teamColors });
|
|
2033
|
-
} catch
|
|
2137
|
+
} catch {
|
|
2034
2138
|
res.json({ agents: [], waitingForUser: null });
|
|
2035
2139
|
}
|
|
2036
2140
|
});
|
|
@@ -2044,29 +2148,57 @@ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
|
|
|
2044
2148
|
try {
|
|
2045
2149
|
clearWaitingFile(resolveSessionId(req.params.sessionId));
|
|
2046
2150
|
res.json({ ok: true });
|
|
2047
|
-
} catch
|
|
2151
|
+
} catch {
|
|
2048
2152
|
res.status(500).json({ error: 'Failed to discard waiting' });
|
|
2049
2153
|
}
|
|
2050
2154
|
});
|
|
2051
2155
|
|
|
2156
|
+
// UI-driven approvals: answers the ask approval-gate.sh is blocking on by
|
|
2157
|
+
// writing _decision-<id>.json next to the marker. The hook consumes and deletes
|
|
2158
|
+
// both files; a write for a hook that already gave up is accepted anyway (D13)
|
|
2159
|
+
// and left for cleanupAgentActivity's sweep.
|
|
2160
|
+
app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
2161
|
+
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2162
|
+
const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
2163
|
+
let marker = null;
|
|
2164
|
+
try { marker = JSON.parse(readFileSync(path.join(dir, '_waiting.json'), 'utf8')); }
|
|
2165
|
+
catch { /* missing or invalid → buildDecision reports 410 */ }
|
|
2166
|
+
// The gate stopped polling after waitSeconds — an orphaned decision file would
|
|
2167
|
+
// sit unconsumed while the card pretends the click worked.
|
|
2168
|
+
if (marker && isWaitingLapsed(marker)) {
|
|
2169
|
+
return res.status(410).json({ error: 'Ask lapsed — answer it in the terminal' });
|
|
2170
|
+
}
|
|
2171
|
+
const result = buildDecision(marker, req.body || {});
|
|
2172
|
+
if (result.error) return res.status(result.status).json({ error: result.error });
|
|
2173
|
+
try {
|
|
2174
|
+
writeJsonAtomic(path.join(dir, decisionFileName(marker.id)), result.decision);
|
|
2175
|
+
res.json({ ok: true });
|
|
2176
|
+
} catch {
|
|
2177
|
+
res.status(500).json({ error: 'Failed to write decision' });
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2052
2181
|
app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
|
|
2053
2182
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2054
2183
|
const agentId = sanitizeAgentId(req.params.agentId);
|
|
2055
|
-
const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId
|
|
2184
|
+
const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, `${agentId}.jsonl`);
|
|
2056
2185
|
if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
|
|
2057
2186
|
try {
|
|
2058
2187
|
const agent = readAgentJsonl(agentFile);
|
|
2059
2188
|
agent.status = 'stopped';
|
|
2060
2189
|
agent.stoppedAt = new Date().toISOString();
|
|
2061
2190
|
const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
|
|
2062
|
-
writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt)
|
|
2191
|
+
writeFileSync(agentFile, `${readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt)}\n`, 'utf8'); // sync — response depends on write
|
|
2063
2192
|
clearWaitingFile(sessionId);
|
|
2064
2193
|
res.json({ ok: true });
|
|
2065
|
-
} catch
|
|
2194
|
+
} catch {
|
|
2066
2195
|
res.status(500).json({ error: 'Failed to stop agent' });
|
|
2067
2196
|
}
|
|
2068
2197
|
});
|
|
2069
2198
|
|
|
2199
|
+
// #endregion
|
|
2200
|
+
|
|
2201
|
+
// #region SUBAGENT_RESOLUTION
|
|
2070
2202
|
function sanitizeAgentId(raw) {
|
|
2071
2203
|
return path.basename(raw).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
2072
2204
|
}
|
|
@@ -2076,7 +2208,7 @@ function sessionDirFromMeta(meta) {
|
|
|
2076
2208
|
}
|
|
2077
2209
|
|
|
2078
2210
|
function subagentJsonlPath(meta, agentId) {
|
|
2079
|
-
return path.join(sessionDirFromMeta(meta), 'subagents',
|
|
2211
|
+
return path.join(sessionDirFromMeta(meta), 'subagents', `agent-${agentId}.jsonl`);
|
|
2080
2212
|
}
|
|
2081
2213
|
|
|
2082
2214
|
// A subagent transcript is normally at <sessionDir>/subagents/agent-<id>.jsonl.
|
|
@@ -2084,14 +2216,14 @@ function subagentJsonlPath(meta, agentId) {
|
|
|
2084
2216
|
// <sessionDir>/subagents/workflows/<wf_id>/agent-<id>.jsonl. Return whichever
|
|
2085
2217
|
// exists (flat first), else null.
|
|
2086
2218
|
function subagentJsonlInDir(sessionDir, agentId) {
|
|
2087
|
-
const flat = path.join(sessionDir, 'subagents',
|
|
2219
|
+
const flat = path.join(sessionDir, 'subagents', `agent-${agentId}.jsonl`);
|
|
2088
2220
|
if (existsSync(flat)) return flat;
|
|
2089
2221
|
const wfRoot = path.join(sessionDir, 'subagents', 'workflows');
|
|
2090
2222
|
let wfDirs;
|
|
2091
2223
|
try { wfDirs = readdirSync(wfRoot, { withFileTypes: true }); } catch { return null; }
|
|
2092
2224
|
for (const wf of wfDirs) {
|
|
2093
2225
|
if (!wf.isDirectory()) continue;
|
|
2094
|
-
const nested = path.join(wfRoot, wf.name,
|
|
2226
|
+
const nested = path.join(wfRoot, wf.name, `agent-${agentId}.jsonl`);
|
|
2095
2227
|
if (existsSync(nested)) return nested;
|
|
2096
2228
|
}
|
|
2097
2229
|
return null;
|
|
@@ -2126,7 +2258,7 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
|
|
|
2126
2258
|
// Same session dir: flat path or nested under subagents/workflows/ (both checked here).
|
|
2127
2259
|
const local = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
|
|
2128
2260
|
if (local) return local;
|
|
2129
|
-
const key = sessionId
|
|
2261
|
+
const key = `${sessionId}/${agentId}`;
|
|
2130
2262
|
const cached = subagentPathCache.get(key);
|
|
2131
2263
|
if (cached) return cached;
|
|
2132
2264
|
let found = null;
|
|
@@ -2216,7 +2348,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2216
2348
|
try { text = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
2217
2349
|
if (!text.includes(targetUuid)) continue;
|
|
2218
2350
|
for (const l of text.split('\n')) {
|
|
2219
|
-
if (!l
|
|
2351
|
+
if (!l?.includes(targetUuid)) continue;
|
|
2220
2352
|
try {
|
|
2221
2353
|
const d = JSON.parse(l);
|
|
2222
2354
|
if (d.uuid === targetUuid && d.sessionId) {
|
|
@@ -2268,6 +2400,9 @@ function lookupParentSession(sessionId) {
|
|
|
2268
2400
|
parentSessionCache.set(sessionId, result);
|
|
2269
2401
|
return result;
|
|
2270
2402
|
}
|
|
2403
|
+
// #endregion
|
|
2404
|
+
|
|
2405
|
+
// #region MESSAGE_ROUTES
|
|
2271
2406
|
app.get('/api/sessions/:sessionId/parent', (req, res) => {
|
|
2272
2407
|
res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
|
|
2273
2408
|
});
|
|
@@ -2367,7 +2502,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
2367
2502
|
if (entry.usage) msg.agentUsageStats = entry.usage;
|
|
2368
2503
|
if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
|
|
2369
2504
|
try {
|
|
2370
|
-
const agentFile = path.join(agentDir, entry.agentId
|
|
2505
|
+
const agentFile = path.join(agentDir, `${entry.agentId}.jsonl`);
|
|
2371
2506
|
const agent = readAgentJsonl(agentFile);
|
|
2372
2507
|
if (agent.lastMessage) msg.agentLastMessage = agent.lastMessage;
|
|
2373
2508
|
if (agent.prompt && !msg.agentPrompt) msg.agentPrompt = agent.prompt;
|
|
@@ -2390,7 +2525,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
2390
2525
|
if (unresolved.length && existsSync(agentDir)) {
|
|
2391
2526
|
const activity = listAgentFiles(agentDir)
|
|
2392
2527
|
.map((f) => { try { return readAgentJsonl(path.join(agentDir, f)); } catch (_) { return null; } })
|
|
2393
|
-
.filter((a) => a
|
|
2528
|
+
.filter((a) => a?.agentId && a.prompt);
|
|
2394
2529
|
const usedIds = new Set(agentMessages.map(m => m.agentId).filter(Boolean));
|
|
2395
2530
|
for (const msg of unresolved) {
|
|
2396
2531
|
const key = msg.agentPrompt.slice(0, 200);
|
|
@@ -2590,17 +2725,28 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
|
|
|
2590
2725
|
res.end(img.buffer);
|
|
2591
2726
|
});
|
|
2592
2727
|
|
|
2593
|
-
|
|
2728
|
+
// #endregion
|
|
2729
|
+
|
|
2730
|
+
// #region META_ROUTES
|
|
2731
|
+
app.get('/api/version', (_req, res) => {
|
|
2594
2732
|
const pkg = require('./package.json');
|
|
2595
2733
|
res.json({ version: pkg.version });
|
|
2596
2734
|
});
|
|
2597
2735
|
|
|
2598
|
-
app.get('/api/config', (
|
|
2599
|
-
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
|
+
});
|
|
2600
2743
|
});
|
|
2601
2744
|
|
|
2745
|
+
// #endregion
|
|
2746
|
+
|
|
2747
|
+
// #region TASK_ROUTES
|
|
2602
2748
|
// API: Get all tasks across all sessions
|
|
2603
|
-
app.get('/api/tasks/all', async (
|
|
2749
|
+
app.get('/api/tasks/all', async (_req, res) => {
|
|
2604
2750
|
try {
|
|
2605
2751
|
if (!existsSync(TASKS_DIR)) {
|
|
2606
2752
|
return res.json([]);
|
|
@@ -2640,7 +2786,7 @@ app.get('/api/tasks/all', async (req, res) => {
|
|
|
2640
2786
|
sessionName: getSessionDisplayName(sessionDir.name, meta),
|
|
2641
2787
|
project
|
|
2642
2788
|
});
|
|
2643
|
-
} catch
|
|
2789
|
+
} catch {
|
|
2644
2790
|
// Skip invalid files
|
|
2645
2791
|
}
|
|
2646
2792
|
}
|
|
@@ -2745,16 +2891,16 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2745
2891
|
return res.status(404).json({ error: 'Task not found' });
|
|
2746
2892
|
}
|
|
2747
2893
|
|
|
2748
|
-
//
|
|
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.
|
|
2749
2896
|
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
2750
2897
|
|
|
2751
2898
|
for (const file of taskFiles) {
|
|
2752
|
-
const
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
});
|
|
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));
|
|
2758
2904
|
}
|
|
2759
2905
|
}
|
|
2760
2906
|
|
|
@@ -2768,6 +2914,9 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2768
2914
|
}
|
|
2769
2915
|
});
|
|
2770
2916
|
|
|
2917
|
+
// #endregion
|
|
2918
|
+
|
|
2919
|
+
// #region PREVIEW
|
|
2771
2920
|
// API: File preview — read file and broadcast to clients
|
|
2772
2921
|
const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
|
|
2773
2922
|
// Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
|
|
@@ -2838,8 +2987,11 @@ function dropUrlFragment(abs) {
|
|
|
2838
2987
|
return existsSync(trimmed) ? trimmed : abs;
|
|
2839
2988
|
}
|
|
2840
2989
|
|
|
2841
|
-
function resolvePreviewPath(
|
|
2842
|
-
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);
|
|
2843
2995
|
if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
|
|
2844
2996
|
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
2845
2997
|
let baseDir = base;
|
|
@@ -2944,7 +3096,7 @@ app.post('/api/session/pin', async (req, res) => {
|
|
|
2944
3096
|
}
|
|
2945
3097
|
});
|
|
2946
3098
|
|
|
2947
|
-
app.get('/api/session/pins', (
|
|
3099
|
+
app.get('/api/session/pins', (_req, res) => {
|
|
2948
3100
|
res.setHeader('Cache-Control', 'no-store');
|
|
2949
3101
|
try {
|
|
2950
3102
|
const pins = readPins();
|
|
@@ -2983,6 +3135,9 @@ app.get('/api/file/resolve', async (req, res) => {
|
|
|
2983
3135
|
}
|
|
2984
3136
|
});
|
|
2985
3137
|
|
|
3138
|
+
// #endregion
|
|
3139
|
+
|
|
3140
|
+
// #region SSE
|
|
2986
3141
|
// SSE endpoint for live updates
|
|
2987
3142
|
app.get('/api/events', (req, res) => {
|
|
2988
3143
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -3012,11 +3167,11 @@ function broadcast(data) {
|
|
|
3012
3167
|
}
|
|
3013
3168
|
}
|
|
3014
3169
|
|
|
3015
|
-
app.get('/api/context-status', (
|
|
3170
|
+
app.get('/api/context-status', (_req, res) => {
|
|
3016
3171
|
res.json(Object.fromEntries(contextStatusCache));
|
|
3017
3172
|
});
|
|
3018
3173
|
|
|
3019
|
-
app.use('/api', (
|
|
3174
|
+
app.use('/api', (_req, res) => {
|
|
3020
3175
|
res.status(404).json({ error: 'Not found' });
|
|
3021
3176
|
});
|
|
3022
3177
|
|
|
@@ -3042,6 +3197,9 @@ watcher.on('all', (event, filePath) => {
|
|
|
3042
3197
|
// shared list or a team dir is one directory several sessions map onto -- so anything
|
|
3043
3198
|
// addressing a session from a task path has to fan out the same way. One resolver for
|
|
3044
3199
|
// both readers of that mapping: the SSE broadcast and the doorbell.
|
|
3200
|
+
// #endregion
|
|
3201
|
+
|
|
3202
|
+
// #region DOORBELL
|
|
3045
3203
|
function resolveSessionsForTaskDir(name) {
|
|
3046
3204
|
if (isUUID(name)) return [name];
|
|
3047
3205
|
const { listToSessions } = loadAllTaskMaps();
|
|
@@ -3172,7 +3330,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
|
|
|
3172
3330
|
fs.unlink(file).catch(() => {});
|
|
3173
3331
|
}
|
|
3174
3332
|
}
|
|
3175
|
-
} catch
|
|
3333
|
+
} catch { /* ignore */ }
|
|
3176
3334
|
}
|
|
3177
3335
|
broadcast({ type: 'agent-update', sessionId });
|
|
3178
3336
|
// For team sessions, also broadcast with team name so frontend picks it up
|
|
@@ -3186,7 +3344,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
|
|
|
3186
3344
|
break;
|
|
3187
3345
|
}
|
|
3188
3346
|
}
|
|
3189
|
-
} catch
|
|
3347
|
+
} catch { /* ignore */ }
|
|
3190
3348
|
}
|
|
3191
3349
|
}
|
|
3192
3350
|
});
|
|
@@ -3207,7 +3365,7 @@ contextStatusWatcher.on('all', (event, filePath) => {
|
|
|
3207
3365
|
try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
|
|
3208
3366
|
contextStatusCache.set(sessionId, data);
|
|
3209
3367
|
evictStaleCache(contextStatusCache);
|
|
3210
|
-
} catch
|
|
3368
|
+
} catch { /* ignore malformed */ }
|
|
3211
3369
|
broadcast({ type: 'context-update', sessionId });
|
|
3212
3370
|
} else if (event === 'unlink') {
|
|
3213
3371
|
contextStatusCache.delete(sessionId);
|
|
@@ -3215,6 +3373,9 @@ contextStatusWatcher.on('all', (event, filePath) => {
|
|
|
3215
3373
|
}
|
|
3216
3374
|
});
|
|
3217
3375
|
|
|
3376
|
+
// #endregion
|
|
3377
|
+
|
|
3378
|
+
// #region CLEANUP
|
|
3218
3379
|
async function cleanupContextStatus() {
|
|
3219
3380
|
try {
|
|
3220
3381
|
const entries = await fs.readdir(CONTEXT_STATUS_DIR);
|
|
@@ -3228,9 +3389,9 @@ async function cleanupContextStatus() {
|
|
|
3228
3389
|
await fs.unlink(fp);
|
|
3229
3390
|
contextStatusCache.delete(path.basename(f, '.json'));
|
|
3230
3391
|
}
|
|
3231
|
-
} catch
|
|
3392
|
+
} catch { /* ignore */ }
|
|
3232
3393
|
}
|
|
3233
|
-
} catch
|
|
3394
|
+
} catch { /* dir may not exist */ }
|
|
3234
3395
|
}
|
|
3235
3396
|
|
|
3236
3397
|
async function cleanupAgentActivity() {
|
|
@@ -3246,10 +3407,22 @@ async function cleanupAgentActivity() {
|
|
|
3246
3407
|
const age = now - stat.mtimeMs;
|
|
3247
3408
|
if ((contents.length === 0 && age > AGENT_STALE_MS) || age > CLEANUP_MAX_AGE_MS) {
|
|
3248
3409
|
await fs.rm(dirPath, { recursive: true, force: true });
|
|
3410
|
+
continue;
|
|
3249
3411
|
}
|
|
3250
|
-
|
|
3412
|
+
// Orphaned decision files: a terminal deny fires no hook event (D13), so a
|
|
3413
|
+
// board answer for an already-gone gate is never consumed. The gate waits at
|
|
3414
|
+
// most PERMISSION_TTL_MS, so anything older is unclaimable.
|
|
3415
|
+
for (const f of contents) {
|
|
3416
|
+
if (!isDecisionFile(f)) continue;
|
|
3417
|
+
const fp = path.join(dirPath, f);
|
|
3418
|
+
try {
|
|
3419
|
+
const fst = await fs.stat(fp);
|
|
3420
|
+
if (now - fst.mtimeMs > PERMISSION_TTL_MS) await fs.rm(fp, { force: true });
|
|
3421
|
+
} catch { /* ignore per-file errors */ }
|
|
3422
|
+
}
|
|
3423
|
+
} catch { /* ignore per-folder errors */ }
|
|
3251
3424
|
}
|
|
3252
|
-
} catch
|
|
3425
|
+
} catch { /* agent-activity dir may not exist */ }
|
|
3253
3426
|
}
|
|
3254
3427
|
|
|
3255
3428
|
cleanupAgentActivity();
|
|
@@ -3306,3 +3479,5 @@ async function prewarmCaches() {
|
|
|
3306
3479
|
}
|
|
3307
3480
|
});
|
|
3308
3481
|
|
|
3482
|
+
|
|
3483
|
+
// #endregion
|