forked from L1AD/claude-task-viewer
-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathserver.js
More file actions
3366 lines (3071 loc) · 136 KB
/
Copy pathserver.js
File metadata and controls
3366 lines (3071 loc) · 136 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync } = require('fs');
const readline = require('readline');
const chokidar = require('chokidar');
const os = require('os');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
const { createNetGuard } = require('./lib/net-guard');
const { isContained } = require('./lib/contain');
const {
readRecentMessages: _readRecentMessagesUncached,
readMessagesPage: _readMessagesPageUncached,
readSessionInfoFromJsonl,
buildAgentProgressMap,
buildSessionDigest,
readCompactSummaries,
findTerminatedTeammates,
extractPromptFromTranscript,
extractModelFromTranscript,
extractStructuredResultFromTranscript,
extractTranscriptStats,
readFullToolResult,
readUserImage,
readToolResultImage,
readCachedImage,
updateLoopInfo,
buildLoopInfoFromState
} = require('./lib/parsers');
const { inlineHtmlAssets } = require('./lib/inline-assets');
const { buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed } = require('./lib/approvals');
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
const { runInstall, runUninstall } = require("./install");
const pluginOnly = process.argv.includes("--plugin-only");
(process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
.then(() => process.exit(0))
.catch(e => { console.error(e.message); process.exit(1); });
return;
}
if (require("./cli").runCli(process.argv)) return;
const app = express();
const PORT = process.env.PORT || 3541;
// Mounted before express.json() so a rejected request never buffers a body, and
// before the /api catch-all so no route escapes the check.
const net = createNetGuard({ appName: 'Claude Task Kanban' });
app.use(net.hostGuard);
app.use(net.frameGuard);
app.use(net.originGuard);
// Parse --dir flag for custom Claude directory
function getClaudeDir() {
const dirIndex = process.argv.findIndex(arg => arg.startsWith('--dir'));
if (dirIndex !== -1) {
const arg = process.argv[dirIndex];
if (arg.includes('=')) {
const dir = arg.split('=')[1];
return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
} else if (process.argv[dirIndex + 1]) {
const dir = process.argv[dirIndex + 1];
return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
}
}
return process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
}
function getArgUrl(argName, envName) {
const idx = process.argv.findIndex(arg => arg.startsWith(`--${argName}`));
if (idx !== -1) {
const arg = process.argv[idx];
if (arg.includes('=')) return arg.split('=').slice(1).join('=');
if (process.argv[idx + 1]) return process.argv[idx + 1];
}
return process.env[envName] || null;
}
const MARKETPLACE_URL = getArgUrl('marketplace-url', 'MARKETPLACE_URL');
const COST_URL = getArgUrl('cost-url', 'COST_URL');
const MEMORY_URL = getArgUrl('memory-url', 'MEMORY_URL');
const CLAUDE_DIR = getClaudeDir();
const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
const SESSIONS_DIR = path.join(CLAUDE_DIR, 'sessions');
const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
// Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
function readPins() {
try {
const obj = JSON.parse(readFileSync(PINS_FILE, 'utf8'));
if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
} catch (_) {}
return {};
}
function writeJsonAtomic(file, obj) {
try {
mkdirSync(CCK_DIR, { recursive: true });
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
renameSync(tmp, file);
} catch (e) {
console.error(`Failed to write ${path.basename(file)}:`, e.message);
}
}
function writePins(pins) {
writeJsonAtomic(PINS_FILE, pins);
}
// Port discovery for out-of-process helpers (the postman monitor). The pid rides along
// so a reader can tell a live server from a file left behind by a crashed one.
function writeServerInfo(port) {
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
}
// #region TIMINGS
const PERMISSION_TTL_MS = 30 * 60 * 1000;
const AGENT_TTL_MS = 60 * 60 * 1000;
const AGENT_STALE_MS = 30 * 60 * 1000; // safety net for crashed sessions
const SESSION_STALE_MS = 5 * 60 * 1000;
// Keep an idle session in the active list this long after its last log write, so it
// doesn't vanish the instant a turn ends. Ungated by registry-idle — visibility only,
// never the "active" status (which stays accurate via hasRecentLog).
const SESSION_GRACE_MS = 2 * 60 * 1000;
const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
// #endregion
function readAgentJsonl(filePath) {
const raw = readFileSync(filePath, 'utf8');
const merged = {};
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) continue;
try { Object.assign(merged, JSON.parse(line)); } catch (_) { /* skip malformed */ }
}
return merged;
}
// Agent-activity record files in a session dir, excluding the `_`-prefixed sidecars
// (_waiting.json, _name-*). Returns [] if the dir is missing/unreadable.
function listAgentFiles(agentDir) {
try {
return readdirSync(agentDir).filter((f) => f.endsWith('.jsonl') && !f.startsWith('_'));
} catch (_) {
return [];
}
}
function persistAgent(dir, agent) {
const file = path.join(dir, agent.agentId + '.jsonl');
fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
}
// Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
// this only reads the config, cached so the polled session list doesn't re-read
// it per hit (marker stays visible for the badge until TTL).
const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
function approvalsWaitMs() {
if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
let cfg = null;
try {
cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
} catch (e) { /* missing config — gate default */ }
approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
return approvalsWaitCache.ms;
}
function isWaitingLapsed(data) {
return isLapsed(data.timestamp, approvalsWaitMs());
}
function checkWaitingForUser(agentDir, logMtime) {
try {
const data = JSON.parse(readFileSync(path.join(agentDir, '_waiting.json'), 'utf8'));
if (data.status === 'waiting' && data.timestamp) {
const waitTime = new Date(data.timestamp).getTime();
const age = Date.now() - waitTime;
if (age >= PERMISSION_TTL_MS) return null;
// After grace period, check if session resumed activity (user already responded)
if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
if (isWaitingLapsed(data)) return { ...data, lapsed: true };
return data;
}
} catch (e) { /* skip — missing or invalid */ }
return null;
}
function agentDisplayName(agent) {
return agent.type || agent.name;
}
function isGhostAgent(agent) {
if (agent.startedAt !== agent.updatedAt || agent.lastMessage) return false;
return (Date.now() - new Date(agent.startedAt).getTime()) >= AGENT_STALE_MS;
}
function getContextStatus(sessionId, meta) {
return contextStatusCache.get(sessionId) || (meta?.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null;
}
function isAgentFresh(agent) {
if (isGhostAgent(agent)) return false;
const ts = agent.updatedAt || agent.startedAt;
if (!ts) return true;
return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
}
function isAgentLive(agent) {
return agent.status === 'active' || agent.status === 'idle';
}
// Claude Code records gitBranch from the launch-time repo and never updates it
// when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
// the live cwd instead. Cached per-cwd with a short TTL so a list refresh
// across N sessions sharing one cwd spawns git at most once per TTL window.
const gitBranchCache = new Map();
const GIT_BRANCH_TTL_MS = 30000;
const GIT_BRANCH_CACHE_MAX = 500;
function getGitBranch(cwd) {
if (!cwd) return null;
const now = Date.now();
const cached = gitBranchCache.get(cwd);
if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
let branch = null;
try {
// cwd rather than `-C <cwd>`: a path beginning with a dash would otherwise be
// read by git as an option.
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd, encoding: 'utf8', timeout: 500, windowsHide: true
});
if (r.status === 0) {
const out = (r.stdout || '').trim();
if (out && out !== 'HEAD') branch = out;
}
} catch (_) {}
gitBranchCache.set(cwd, { branch, ts: now });
if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
const firstKey = gitBranchCache.keys().next().value;
gitBranchCache.delete(firstKey);
}
return branch;
}
// Only spawn git when cwd has diverged from the launch project — that's the
// only case the JSONL value is wrong. Saves N spawns on a typical list build.
function resolveSessionGitBranch(meta) {
if (meta.cwd && meta.project && meta.cwd !== meta.project) {
return getGitBranch(meta.cwd) || meta.gitBranch || null;
}
return meta.gitBranch || null;
}
function getSessionLogStat(meta) {
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
try {
const st = statSync(meta.jsonlPath);
return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
} catch (e) { return { mtime: null, hasMessages: false }; }
}
function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
const result = { hasActive: false, hasRunning: false, waitingForUser: null };
if (!existsSync(agentDir)) return result;
result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
if (result.waitingForUser) result.hasActive = true;
if (stale && !isTeam) return result;
try {
for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
try {
const agent = readAgentJsonl(path.join(agentDir, file));
// Idle agents never mark a session active (an idle teammate lingers and
// would pin the filter). Teams skip freshness so long-running teammates stay visible.
if (agent.status === 'active' && (isTeam || isAgentFresh(agent))) {
result.hasActive = true;
result.hasRunning = true;
}
if (result.hasRunning) break;
} catch (e) { /* skip invalid */ }
}
} catch (e) { /* ignore */ }
return result;
}
// isContained canonicalises both sides with realpath, which costs ~100x a plain
// path.join. This is called once per session on the /api/sessions hot path, and
// the verdict for a given name cannot change while TEAMS_DIR is fixed — so the
// resolved path (or the null rejection) is memoized.
const teamPathCache = new Map();
const TEAM_PATH_CACHE_MAX = 1000;
function teamConfigPath(teamName) {
if (typeof teamName !== 'string' || !teamName) return null;
if (teamPathCache.has(teamName)) return teamPathCache.get(teamName);
const candidate = path.join(TEAMS_DIR, teamName, 'config.json');
// TEAMS_DIR follows --dir / CLAUDE_CONFIG_DIR, so containment is checked against
// the module-level constant rather than a hardcoded ~/.claude/teams.
const configPath = isContained(candidate, TEAMS_DIR) ? candidate : null;
if (teamPathCache.size >= TEAM_PATH_CACHE_MAX) teamPathCache.clear();
teamPathCache.set(teamName, configPath);
return configPath;
}
function isTeamSession(sessionId) {
const configPath = teamConfigPath(sessionId);
return !!configPath && existsSync(configPath);
}
const teamConfigCache = new Map();
const TEAM_CACHE_TTL = 5000;
function loadTeamConfig(teamName) {
const cached = teamConfigCache.get(teamName);
if (cached && Date.now() - cached.ts < TEAM_CACHE_TTL) return cached.data;
try {
const configPath = teamConfigPath(teamName);
if (!configPath || !existsSync(configPath)) return null;
const data = JSON.parse(readFileSync(configPath, 'utf8'));
teamConfigCache.set(teamName, { data, ts: Date.now() });
return data;
} catch (e) {
return null;
}
}
function resolveSessionId(sessionId) {
const teamConfig = loadTeamConfig(sessionId);
return (teamConfig && teamConfig.leadSessionId) ? teamConfig.leadSessionId : sessionId;
}
// Recent Claude Code releases auto-create a single-member "self-team" for every
// session: a teams/session-<id>/config.json whose only member is the "team-lead"
// (the session itself). These are not real multi-agent teams — surfacing them
// makes every solo session render a team badge, member panel, and (via the empty
// team-named task dir) a shared-task-list link. Treat them as plain sessions.
// As soon as a real teammate joins (members.length > 1) it becomes a true team again.
function isAutoSelfTeam(cfg) {
if (!cfg || !Array.isArray(cfg.members)) return false;
const namedSession = typeof cfg.name === 'string' && cfg.name.startsWith('session-');
const soleLead = cfg.members.length === 0
|| (cfg.members.length === 1 && cfg.members[0]?.agentType === 'team-lead');
return namedSession && soleLead;
}
// Claude Code 2.1.x stores a session's tasks in its self-team list (tasks/session-<id>/).
// Usually `cfg.leadSessionId` is that session and already has a card. But a resumed /
// continued session keeps writing to the original team's list while running under a new
// session id, so `leadSessionId` points at the original (often a ghost with no card) and
// the tasks can't be matched to the live session by id. The on-disk bridge is the
// live-session registry (~/.claude/sessions/<pid>.json): the team's `createdAt` ≈ the
// owning session's `startedAt` (both written at boot) and they share a cwd. Match on that.
const SELF_TEAM_BOOT_WINDOW_MS = 60 * 1000;
let liveSessionsCache = null;
let lastLiveSessionsScan = 0;
const LIVE_SESSIONS_TTL = 5000;
function loadLiveSessions() {
const now = Date.now();
if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
const sessions = [];
if (existsSync(SESSIONS_DIR)) {
try {
for (const file of readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
try {
const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
if (s?.sessionId && s.kind === 'interactive') {
sessions.push({ sessionId: s.sessionId, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
}
} catch (_) { /* skip invalid */ }
}
} catch (_) { /* ignore */ }
}
liveSessionsCache = sessions;
lastLiveSessionsScan = now;
return sessions;
}
// An open-but-idle interactive session keeps touching its JSONL (metadata-line
// rewrites), so mtime alone reads as activity for as long as the terminal stays
// open. Claude Code's live-session registry knows the real state — trust its
// 'idle' over the mtime. No registry entry (or any other status) falls back to
// the mtime rule.
function isRegistryIdle(sessionId) {
const live = loadLiveSessions().find(s => s.sessionId === sessionId);
return live?.status === 'idle';
}
function hasRecentLogActivity(sessionId, logAge) {
return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
}
// Visibility-only recency: hasRecentLogActivity widened by the post-turn grace
// window (ungated by registry-idle). Drives whether a session appears in the
// active list — never the "active" status badges, which stay on hasRecentLog.
function hasVisibleLogActivity(sessionId, logAge) {
return logAge <= SESSION_GRACE_MS || hasRecentLogActivity(sessionId, logAge);
}
// Given a self-team config, return the live interactive session id that owns it
// (same cwd, startedAt within the boot window of the team's createdAt), or null.
function resolveSelfTeamOwner(cfg) {
if (!cfg?.createdAt) return null;
const teamCwd = cfg.members?.[0]?.cwd;
if (!teamCwd) return null;
let best = null, bestDelta = Infinity;
for (const s of loadLiveSessions()) {
if (s.cwd !== teamCwd) continue;
const delta = Math.abs(s.startedAt - cfg.createdAt);
if (delta <= SELF_TEAM_BOOT_WINDOW_MS && delta < bestDelta) {
best = s.sessionId;
bestDelta = delta;
}
}
return best;
}
// Attach a team-named task dir's counts to a session card, preferring the most recently written
// dir (see taskDirBeats — "latest wins"). A resumed session owns several team dirs: a stale
// prior-boot dir plus the current run's dir; the older "most tasks wins" rule attached the stale
// dir when it had more tasks than the live one. The incumbent's mtime comes from the path-cached
// counts (every card.tasksDir was already passed through getTaskCounts by the caller, so this is a
// cache hit, not disk I/O). Caller passes the already-computed candidate counts.
function attachTeamTasks(card, teamTaskDir, teamName, counts) {
let curMtime = -1, curCount = -1;
if (card.tasksDir) {
const cur = getTaskCounts(card.tasksDir);
curMtime = taskDirMtime(cur);
curCount = cur.taskCount;
}
if (taskDirBeats(taskDirMtime(counts), counts.taskCount, curMtime, curCount)) {
Object.assign(card, {
taskCount: counts.taskCount,
completed: counts.completed,
inProgress: counts.inProgress,
pending: counts.pending,
tasksDir: teamTaskDir,
sharedTaskList: teamName,
});
}
}
// SSE clients for live updates
const clients = new Set();
// Cache for session metadata (refreshed periodically)
let sessionMetadataCache = {};
let lastMetadataRefresh = 0;
const METADATA_CACHE_TTL = 10000; // 10 seconds
// Watcher-driven invalidation. `change` events (append to existing jsonl) only
// dirty the one path so we can do a targeted refresh; `add` / `unlink` events
// are structural and force a full rescan.
const dirtyMetadataPaths = new Set();
let metadataNeedsFullScan = true;
const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
function isSafeId(id) {
return typeof id === 'string' && id.length > 0 && id.length <= 128 && SAFE_ID_RE.test(id);
}
app.param('sessionId', (req, res, next, val) => {
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid session ID' });
next();
});
app.param('taskId', (req, res, next, val) => {
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid task ID' });
next();
});
// Team names are directory names under TEAMS_DIR, so /api/teams/:name was a
// straight traversal before this.
app.param('name', (req, res, next, val) => {
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid team name' });
next();
});
// Parse JSON bodies
app.use(express.json());
app.get('/hub-config', (_req, res) => {
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
});
// Serve static files
app.get('/sw.js', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
});
app.use(express.static(path.join(__dirname, 'public')));
const messageCache = new Map();
const MESSAGE_CACHE_TTL = 5000;
const MAX_CACHE_ENTRIES = 200;
const compactSummaryCache = new Map();
const taskCountsCache = new Map();
const contextStatusCache = new Map();
const TASK_MAPS_DIR = path.join(AGENT_ACTIVITY_DIR, '_task-maps');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isUUID(s) { return UUID_RE.test(s); }
function evictStaleCache(cache) {
if (cache.size <= MAX_CACHE_ENTRIES) return;
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
let sessionToTaskListCache = null;
let lastTaskMapScan = 0;
const TASK_MAP_SCAN_TTL = 5000;
function loadAllTaskMaps() {
const now = Date.now();
if (sessionToTaskListCache && now - lastTaskMapScan < TASK_MAP_SCAN_TTL) return sessionToTaskListCache;
const sessionToList = {};
const listToSessions = {};
if (!existsSync(TASK_MAPS_DIR)) {
sessionToTaskListCache = { sessionToList, listToSessions };
lastTaskMapScan = now;
return sessionToTaskListCache;
}
try {
for (const file of readdirSync(TASK_MAPS_DIR).filter(f => f.endsWith('.json'))) {
const taskListName = file.replace(/\.json$/, '');
const mapPath = path.join(TASK_MAPS_DIR, file);
try {
const map = JSON.parse(readFileSync(mapPath, 'utf8'));
listToSessions[taskListName] = map;
for (const sessionId of Object.keys(map)) {
sessionToList[sessionId] = taskListName;
}
} catch (e) { /* skip invalid */ }
}
} catch (e) { /* ignore */ }
sessionToTaskListCache = { sessionToList, listToSessions };
lastTaskMapScan = now;
return sessionToTaskListCache;
}
function getCustomTaskDir(sessionId) {
const { sessionToList } = loadAllTaskMaps();
const taskListName = sessionToList[sessionId];
if (taskListName) {
const dir = path.join(TASKS_DIR, taskListName);
if (existsSync(dir)) return dir;
}
// Check team-named task directory (teams store tasks under ~/.claude/tasks/<teamName>/).
// Match either the recorded leadSessionId, or — for 2.1.x self-teams whose lead is a
// team-lead agent id — the live interactive session that owns the team (see resolveSelfTeamOwner).
// A live session can own several team dirs at once: a stale dir from a prior boot of the same
// session id, plus the current run's dir (a resume creates a fresh self-team). Pick the most
// recently written dir ("latest wins") — the old "most tasks wins" rule picked the stale dir
// whenever a completed prior run had accumulated more tasks than the live one. taskCount breaks
// ties; empty dirs (no task mtime) lose, so a real dir still beats an empty self-team.
if (existsSync(TEAMS_DIR)) {
try {
let bestDir = null, bestMtime = -1, bestCount = -1;
for (const dir of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
if (!dir.isDirectory()) continue;
const cfg = loadTeamConfig(dir.name);
if (!cfg) continue;
const owns = cfg.leadSessionId === sessionId
|| (isAutoSelfTeam(cfg) && resolveSelfTeamOwner(cfg) === sessionId);
if (!owns) continue;
const teamTaskDir = path.join(TASKS_DIR, dir.name);
if (!existsSync(teamTaskDir)) continue;
const counts = getTaskCounts(teamTaskDir);
const mtime = taskDirMtime(counts);
if (taskDirBeats(mtime, counts.taskCount, bestMtime, bestCount)) {
bestMtime = mtime;
bestCount = counts.taskCount;
bestDir = teamTaskDir;
}
}
if (bestDir) return bestDir;
} catch (_) {}
}
return null;
}
// Where a session's task files live. The custom-list and team lookups can both miss, and
// the fallback is the plain per-session dir -- every route that touches a task file needs
// that same resolution, so it lives in one place.
function taskDirFor(sessionId) {
return getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
}
function getTaskCounts(sessionPath) {
const cached = taskCountsCache.get(sessionPath);
if (cached) return cached;
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
let completed = 0, inProgress = 0, pending = 0, newestTaskMtime = null;
// Directory mtime bumps when task files are added/removed, so it stays fresh even for an
// emptied dir (task list closed) that has no files left to date. Task-file mtime alone would
// report 0 for such a dir and lose "latest wins" to a stale prior-boot dir.
let dirMtime = 0;
try { dirMtime = statSync(sessionPath).mtimeMs; } catch (_) {}
for (const file of taskFiles) {
try {
const taskPath = path.join(sessionPath, file);
const task = JSON.parse(readFileSync(taskPath, 'utf8'));
if (task.metadata && task.metadata._internal) continue;
if (task.status === 'completed') completed++;
else if (task.status === 'in_progress') inProgress++;
else pending++;
const taskStat = statSync(taskPath);
if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
newestTaskMtime = taskStat.mtime;
}
} catch (e) { /* skip invalid */ }
}
const taskCount = completed + inProgress + pending;
const result = { taskCount, completed, inProgress, pending, newestTaskMtime, dirMtime };
taskCountsCache.set(sessionPath, result);
return result;
}
// Last-write recency of a getTaskCounts() result, in ms: the newer of the directory mtime (bumps
// on add/remove, so it survives an emptied dir) and the newest task-file mtime (bumps on in-place
// status edits, which don't touch the dir). An emptied current dir thus still ranks by when it was
// cleared, letting it win "latest wins" over a stale prior-boot dir instead of reporting 0.
function taskDirMtime(counts) {
const fileMtime = counts.newestTaskMtime ? counts.newestTaskMtime.getTime() : 0;
return Math.max(counts.dirMtime || 0, fileMtime);
}
// "Latest wins" ranking for two owned task dirs of the same session: more recently written wins,
// taskCount breaks ties. Callers seed the incumbent with mtime/count -1 so the first candidate
// always wins.
function taskDirBeats(candMtime, candCount, curMtime, curCount) {
return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
}
function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
try {
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.data;
const st = statSync(filePath);
if (cached && cached.mtime === st.mtimeMs) {
cached.ts = Date.now();
return cached.data;
}
const data = loadFn();
cache.set(cacheKey, { data, mtime: st.mtimeMs, ts: Date.now() });
evictStaleCache(cache);
return data;
} catch (_) { return fallback; }
}
const sessionDigestCache = new Map();
function getSessionDigest(jsonlPath) {
return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
}
function getProgressMap(jsonlPath) {
return getSessionDigest(jsonlPath).progressMap;
}
function getTerminatedTeammates(jsonlPath) {
return getSessionDigest(jsonlPath).terminated;
}
function readRecentMessages(jsonlPath, limit = 10) {
return cachedByMtime(messageCache, `${jsonlPath}:${limit}`, jsonlPath, () => _readRecentMessagesUncached(jsonlPath, limit), []);
}
/**
* Scan all project directories to find session JSONL files and extract slugs
*/
// Returns false when sessionId is unknown — caller must promote to full scan.
function refreshSessionMetadataPath(jsonlPath) {
const sessionId = path.basename(jsonlPath, '.jsonl');
if (!isSafeId(sessionId)) return false;
const existing = sessionMetadataCache[sessionId];
if (!existing) return false;
let info;
try {
info = readSessionInfoFromJsonl(jsonlPath);
} catch (_) {
return false;
}
// Shadow JSONLs (continued from a worktree) hold only custom-title / agent-
// name records — no projectPath. Don't let a shadow clobber the real entry.
const shadow = existing.project && !info.projectPath;
if (shadow) {
if (!existing.slug && info.slug) existing.slug = info.slug;
if (!existing.customTitle && info.customTitle) existing.customTitle = info.customTitle;
return true;
}
if (info.slug) existing.slug = info.slug;
if (info.cwd) existing.cwd = info.cwd;
if (info.gitBranch) existing.gitBranch = info.gitBranch;
if (info.customTitle) existing.customTitle = info.customTitle;
// Direct assign (not guarded) so a /goal clear propagates as null.
existing.goal = info.goal || null;
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
return true;
}
function loadSessionMetadata() {
const now = Date.now();
if (!metadataNeedsFullScan && now - lastMetadataRefresh < METADATA_CACHE_TTL) {
if (dirtyMetadataPaths.size > 0) {
for (const p of dirtyMetadataPaths) {
if (!refreshSessionMetadataPath(p)) {
// Unknown sessionId — structural change snuck in. Promote to full.
metadataNeedsFullScan = true;
break;
}
}
dirtyMetadataPaths.clear();
if (!metadataNeedsFullScan) return sessionMetadataCache;
} else {
return sessionMetadataCache;
}
}
const metadata = {};
try {
if (!existsSync(PROJECTS_DIR)) {
return metadata;
}
const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory());
for (const projectDir of projectDirs) {
const projectPath = path.join(PROJECTS_DIR, projectDir.name);
// Find all .jsonl files (session logs)
const files = readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
const sessionIds = [];
// Read sessions-index.json first for canonical projectPath
let indexProjectPath = null;
const indexPath = path.join(projectPath, 'sessions-index.json');
let indexEntries = [];
if (existsSync(indexPath)) {
try {
const indexData = JSON.parse(readFileSync(indexPath, 'utf8'));
indexEntries = indexData.entries || [];
for (const entry of indexEntries) {
if (entry.projectPath) { indexProjectPath = entry.projectPath; break; }
}
} catch (e) {}
}
// First pass: read all JSONL files
let resolvedProjectPath = null;
for (const file of files) {
const sessionId = file.replace('.jsonl', '');
const jsonlPath = path.join(projectPath, file);
const sessionInfo = readSessionInfoFromJsonl(jsonlPath);
if (sessionInfo.projectPath && !resolvedProjectPath) {
resolvedProjectPath = sessionInfo.projectPath;
}
const candidateProject = indexProjectPath || sessionInfo.projectPath || null;
const existing = metadata[sessionId];
// Same sessionId can appear in multiple project dirs (e.g. "shadow"
// JSONLs that only hold custom-title/agent-name records when a session
// is continued from a worktree). Don't let a weaker entry (no cwd, no
// project) overwrite a previously resolved one — just merge scalars.
if (existing && existing.project && !candidateProject) {
if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
sessionIds.push(sessionId);
continue;
}
metadata[sessionId] = {
slug: sessionInfo.slug,
project: candidateProject,
cwd: sessionInfo.cwd || null,
gitBranch: sessionInfo.gitBranch || null,
customTitle: sessionInfo.customTitle || null,
goal: sessionInfo.goal || null,
jsonlPath: jsonlPath,
logicalParentUuid: sessionInfo.logicalParentUuid || null,
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
};
sessionIds.push(sessionId);
}
// Second pass: fill in missing project paths from siblings
const canonicalProject = indexProjectPath || resolvedProjectPath;
if (canonicalProject) {
for (const sid of sessionIds) {
if (!metadata[sid].project) {
metadata[sid].project = canonicalProject;
}
}
}
// Apply index metadata (descriptions, custom titles, etc.)
for (const entry of indexEntries) {
if (entry.sessionId) {
if (!metadata[entry.sessionId]) {
metadata[entry.sessionId] = {
slug: null,
project: indexProjectPath || entry.projectPath || null,
cwd: null,
jsonlPath: null
};
}
metadata[entry.sessionId].description = entry.description || null;
if (entry.gitBranch) metadata[entry.sessionId].gitBranch = entry.gitBranch;
if (entry.customTitle) metadata[entry.sessionId].customTitle = entry.customTitle;
metadata[entry.sessionId].created = entry.created || null;
}
}
}
} catch (e) {
console.error('Error loading session metadata:', e);
}
// For team sessions with no JSONL match, resolve from team config + parent session
if (existsSync(TASKS_DIR)) {
const taskDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory());
for (const dir of taskDirs) {
if (!metadata[dir.name]) {
const teamConfig = loadTeamConfig(dir.name);
if (teamConfig) {
const parentMeta = teamConfig.leadSessionId ? metadata[teamConfig.leadSessionId] : null;
const leadMember = teamConfig.members?.find(m => m.agentId === teamConfig.leadAgentId) || teamConfig.members?.[0];
const project = parentMeta?.project || leadMember?.cwd || teamConfig.working_dir || null;
metadata[dir.name] = {
slug: teamConfig.description || dir.name,
project,
jsonlPath: parentMeta?.jsonlPath || null,
description: teamConfig.description || parentMeta?.description || null,
gitBranch: parentMeta?.gitBranch || null,
created: parentMeta?.created || null,
isTeamLeader: false,
teamLeaderId: teamConfig.leadSessionId || null
};
}
}
}
}
sessionMetadataCache = metadata;
lastMetadataRefresh = now;
metadataNeedsFullScan = false;
dirtyMetadataPaths.clear();
return metadata;
}
// Workflow (Workflow tool) scripts are persisted at
// projects/<projEnc>/<sessionId>/workflows/scripts/<name>-<wf_id>.js
// The script's projEnc can differ from the session's own JSONL dir (the workflow
// may run from a different cwd), so we scan every project dir into a TTL-cached
// index rather than deriving the path from meta.jsonlPath. Isolated from the
// session-scan hot path: a single Map lookup per buildSessionObject call.
const WORKFLOW_INDEX_TTL_MS = 5000;
let workflowIndexCache = null; // Map<sessionId, Array<{id,name,path,mtimeMs}>>
let workflowIndexBuiltAt = 0;
function buildWorkflowIndex() {
const index = new Map();
if (!existsSync(PROJECTS_DIR)) return index;
let projs;
try { projs = readdirSync(PROJECTS_DIR, { withFileTypes: true }); } catch { return index; }
for (const proj of projs) {
if (!proj.isDirectory()) continue;
const projPath = path.join(PROJECTS_DIR, proj.name);
let sessDirs;
try { sessDirs = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
for (const sess of sessDirs) {
if (!sess.isDirectory() || !isUUID(sess.name)) continue;
const scriptsDir = path.join(projPath, sess.name, 'workflows', 'scripts');
let scripts;
try { scripts = readdirSync(scriptsDir); } catch { continue; } // ENOENT for most — skip fast
for (const f of scripts) {
if (!f.endsWith('.js')) continue;
const full = path.join(scriptsDir, f);
let mtimeMs = 0;
try { mtimeMs = statSync(full).mtimeMs; } catch {}
const base = f.slice(0, -3);
const m = base.match(/^(.*)-(wf_[a-z0-9-]+)$/i);
const entry = { id: m ? m[2] : base, name: m ? m[1] : base, path: full, mtimeMs };
if (!index.has(sess.name)) index.set(sess.name, []);
index.get(sess.name).push(entry);
}
}
}
return index;
}
function getWorkflowIndex() {
const now = Date.now();
if (workflowIndexCache && now - workflowIndexBuiltAt < WORKFLOW_INDEX_TTL_MS) return workflowIndexCache;
workflowIndexCache = buildWorkflowIndex();
workflowIndexBuiltAt = now;
return workflowIndexCache;
}
// Resolve a session's workflow scripts, newest first. Tries the raw id then the
// team-lead resolution (a team session's scripts live under the lead's dir).
function getWorkflowScripts(sessionId) {
const idx = getWorkflowIndex();
let list = idx.get(sessionId);
if (!list || !list.length) {
const alt = resolveSessionId(sessionId);
if (alt && alt !== sessionId) list = idx.get(alt);
}
return list ? [...list].sort((a, b) => b.mtimeMs - a.mtimeMs) : [];
}
function getWorkflowInfoSummary(sessionId) {
const list = getWorkflowIndex().get(sessionId);
return { hasWorkflow: !!(list && list.length), workflowCount: list ? list.length : 0 };
}
function getPlanInfo(slug) {
if (!slug) return { hasPlan: false, planTitle: null, planPath: null };
const planPath = path.join(PLANS_DIR, `${slug}.md`);
if (!existsSync(planPath)) return { hasPlan: false, planTitle: null, planPath: null };
try {
const head = readFileSync(planPath, 'utf8').slice(0, 512);
const match = head.match(/^#\s+(.+)$/m);
return { hasPlan: true, planTitle: match ? match[1].trim() : null, planPath };
} catch (e) {
return { hasPlan: true, planTitle: null, planPath };
}
}
// Hide wakeups whose fire time is more than this far in the past — long /loop
// sessions otherwise produce dozens of stale entries that drown the badge.
const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
function isWakeupActive(w, now = Date.now()) {
if (!w || !w.timestamp || w.delaySeconds == null) return true;
const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
}
function filterActiveLoopInfo(info) {
const now = Date.now();
return {
wakeups: info.wakeups.filter(w => isWakeupActive(w, now)),
crons: info.crons
};
}
// Per-path incremental scan state. Populated lazily on first access and
// updated in place; the projectsWatcher event handler keeps entries warm so
// the request path does O(1) work in steady state.
const loopInfoStateByPath = new Map();
function refreshLoopInfoState(jsonlPath) {
if (!jsonlPath) return null;
const prev = loopInfoStateByPath.get(jsonlPath);
const next = updateLoopInfo(jsonlPath, prev);
if (next) loopInfoStateByPath.set(jsonlPath, next);
return next;
}
function getLoopInfoSummary(meta) {
const empty = { wakeupCount: 0, cronCount: 0, latest: null };
if (!meta?.jsonlPath) return empty;
try {
const state = refreshLoopInfoState(meta.jsonlPath);
const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
return {
wakeupCount: filtered.wakeups.length,
cronCount: filtered.crons.length,
latest: filtered.wakeups[filtered.wakeups.length - 1] || filtered.crons[filtered.crons.length - 1] || null
};
} catch (_) { return empty; }
}
function getSessionDisplayName(sessionId, meta) {
if (meta?.customTitle) return meta.customTitle;