Repository navigation
Expand file tree
/
Copy pathlocal-server.mjs
More file actions
4286 lines (3964 loc) · 169 KB
/
Copy pathlocal-server.mjs
File metadata and controls
4286 lines (3964 loc) · 169 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
/**
* Local server — full replacement for the Cloudflare Worker.
* Reads ~/.claude/projects/ directly; no Cloudflare account needed.
*
* Run via: npm run local
* Config persisted to: ~/.claude/agent-session-viewer-local.json
*/
import { createReadStream, existsSync, mkdirSync, openSync, readSync, closeSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "fs"
import { homedir, tmpdir } from "os"
import { basename, dirname, extname, join, sep } from "path"
import http from "http"
import https from "https"
import { fileURLToPath } from "url"
import { Worker } from "worker_threads"
import { exec, execFile, execFileSync, execSync, spawnSync } from "child_process"
import { stripXml, trimProjectsByRecentSessionCount, countSessionsInProjects, parseJsonlStream, hasClaudeTranscriptMessage } from "./shared-utils.mjs"
import { isLegacyCodexProjectPath, loadSessionMessages } from "./lib/session-message-loader.mjs"
import { isOnDemandSessionPlatform } from "./lib/session-platform-routing.mjs"
import { normalizeCodexRateLimit, normalizeResetTime, resetDescription } from "./lib/usage-window-normalizer.mjs"
import {
readCodexSessions,
CODEX_SESSIONS_ROOT,
readCursorSessions,
readCursorSessionMsgs,
readCursorAgentSessions,
readCursorAgentSessionFile,
listCursorAgentTranscriptFiles,
CURSOR_PROJECTS_ROOT,
readOpenCodeSession,
readOpenCodeSessionFromSqlite,
readOpenCodeSessionIndex,
iterOpenCodeSessions,
OPENCODE_DIR,
OPENCODE_DB,
OPENCODE_STORAGE,
OPENCODE_CORRELATION_FILE,
ANTIGRAVITY_BRAIN_DIR,
parseAntigravitySessionIndex,
readAntigravitySession,
readAntigravityRpcSessions,
ANTIGRAVITY_CLI_DIR,
readAntigravityCliSessions,
HERMES_DB,
readHermesSessions,
readCodexSessionById,
listCodexSessionFiles,
normProjectDir,
readOpenclawSessions,
OPENCLAW_ROOT,
findOpenclawSessionFile,
findCodexSessionFile,
readGeminiSessions,
readGeminiSessionMsgs,
GEMINI_TMP_ROOT,
} from "./platform-readers.mjs"
import {
DEVIN_DB,
GOOSE_DB,
isExtendedAgentProject,
MIMO_DB,
PIER_DB,
PI_ROOT,
readExtendedAgentSessions,
} from "./lib/extended-agent-readers.mjs"
import { buildSidebarSearchDoc, runSidebarSessionSearch, runThreadKeywordSearch } from "./lib/session-search-core.mjs"
import { extractLatestPlan } from "./lib/plan-status-core.mjs"
import { inferClaudeCodexParent } from "./lib/codex-claude-lineage.mjs"
import { loadOpenCodeCorrelations } from "./lib/opencode-correlation.mjs"
import { indexSession, removeSession, getSearchRows } from "./lib/search-index.mjs"
import { rgGlobalSearch } from "./lib/rg-search.mjs"
import { contentSearchStream } from "./lib/content-search.mjs"
import { isDebugTrace, debugLog, debugWarn } from "./lib/debug-trace.mjs"
import { canResumeSessionWithAgl, getAgentProviders, parseConfiguredProviders, runLocalAglChat } from "./lib/agent-chat-core.mjs"
import { buildLiveSessionPreview, compressLiveSummaryContext, isActivelyUpdating, openAILiveSummaryBody, openAIStreamDelta } from "./lib/live-summary-core.mjs"
import {
openSidebarCacheDb,
getSidebarCacheMap,
getSidebarSessionCount,
getTopSidebarEntries,
getAllSidebarEntries,
getSidebarEntry,
deleteSidebarEntry,
upsertSidebarEntry,
expandSidebarLinkageEntries,
} from "./lib/sidebar-cache-db.mjs"
import {
cacheEntryToSessionRow,
groupCacheSessionsToProjects,
sessionUpsertPayload,
} from "./lib/sidebar-cache-format.mjs"
const __dirname = dirname(fileURLToPath(import.meta.url))
// Load .env files from the project and shared ~/Code workspace (without overriding process env).
try {
for (const envFile of [join(__dirname, ".env"), join(dirname(__dirname), ".env")]) {
if (!existsSync(envFile)) continue
for (const line of readFileSync(envFile, "utf8").split("\n")) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/)
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "")
}
}
} catch {}
const PLATFORM_LOADER_WORKER = join(__dirname, "lib", "platform-loader-worker.mjs")
// Priority order: codex first (Claude scans on the main thread), then cursor/opencode;
// the rest queue behind the MAX_PLATFORM_WORKERS pool.
const STREAM_PLATFORM_WORKERS = ["codex", "cursor", "cursor-agent", "opencode", "gemini", "hermes", "openclaw", "antigravity", "antigravity-cli", "extended-agents"]
const BUNDLE_PLATFORM_WORKERS = STREAM_PLATFORM_WORKERS
const SERVER_START_TIME = Date.now()
const SERVER_GIT = readServerGitMetadata()
const CLAUDE_DIR = join(homedir(), ".claude", "projects")
const APP_CONFIG_DIR = join(homedir(), ".config", "agent-session-viewer")
const CONFIG_FILE = join(APP_CONFIG_DIR, "config.json")
const SIDEBAR_CACHE_DB = join(APP_CONFIG_DIR, "sidebar-cache.db")
function getTranscriptReadLocations() {
return [
join(homedir(), ".claude", "projects"),
join(homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"),
CURSOR_PROJECTS_ROOT,
OPENCODE_DB,
OPENCODE_STORAGE,
CODEX_SESSIONS_ROOT,
ANTIGRAVITY_BRAIN_DIR,
ANTIGRAVITY_CLI_DIR,
HERMES_DB,
GEMINI_TMP_ROOT,
OPENCLAW_ROOT,
PI_ROOT,
GOOSE_DB,
MIMO_DB,
PIER_DB,
DEVIN_DB,
join(homedir(), ".config", "agent-session-viewer", "transcripts"),
].filter((location, index, locations) => location && locations.indexOf(location) === index)
}
function wallClock() {
return new Date().toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
})
}
function readServerGitMetadata() {
const runGit = (args) => {
try {
return execFileSync("git", args, {
cwd: __dirname,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim() || null
} catch {
return null
}
}
const status = runGit(["status", "--short"]) ?? ""
return {
commit: runGit(["rev-parse", "HEAD"]),
shortCommit: runGit(["rev-parse", "--short", "HEAD"]),
branch: runGit(["branch", "--show-current"]),
commitDate: runGit(["show", "-s", "--format=%cI", "HEAD"]),
dirtyAtStart: status.length > 0,
statusAtStart: status ? status.split("\n") : [],
}
}
function readCurrentGitState() {
const runGit = (args) => {
try {
return execFileSync("git", args, {
cwd: __dirname,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim() || null
} catch {
return null
}
}
const commit = runGit(["rev-parse", "HEAD"])
const status = runGit(["status", "--short"]) ?? ""
return {
commit,
shortCommit: runGit(["rev-parse", "--short", "HEAD"]),
branch: runGit(["branch", "--show-current"]),
dirty: status.length > 0,
status: status ? status.split("\n") : [],
commitMatchesDeployed: Boolean(commit && SERVER_GIT.commit && commit === SERVER_GIT.commit),
}
}
/**
* Turn an encoded project dir (e.g. "-Users-dhruv-Code-my-cool-project") into a
* human-readable display name. Reconstructs the absolute path and checks if it
* exists on disk — if so, shows path relative to home. Falls back to stripping
* the common ~/Code prefix without replacing remaining dashes (avoids turning
* "my-cool-project" into "my/cool/project").
*/
function encodedDirToDisplayName(encodedDir) {
// Reconstruct absolute path and verify on disk — preserves actual dashes in folder names
const abs = "/" + encodedDir.replace(/-/g, "/")
if (existsSync(abs)) return abs.replace(homedir() + "/", "")
// Disk check failed (path has ambiguous dashes): strip the common ~/Code/ prefix and show remainder as-is
return encodedDir.replace(/^-?Users-[^-]+-Code-/, "")
}
/**
* Decode a Claude-style dash-encoded dir name to the best-guess absolute path.
*
* Claude encodes workspace paths by replacing every "/" with "-" and stripping the
* leading "/". So "/Users/alice/Code/my-project" becomes "-Users-alice-Code-my-project".
* This is ambiguous when directory names themselves contain dashes.
*
* Strategy: strip the "-Users-<username>-" prefix, then greedily match the longest
* run of dash-separated segments that names a real directory on disk, descending one
* level at a time. This correctly resolves both:
* - flat names with dashes: "Code-agent-session-viewer" → ~/Code/agent-session-viewer
* - nested paths: "Code-home-debug-CloudSweeper" → ~/Code/home-debug/CloudSweeper
*/
function decodeClaudeEncodedDir(encodedDir) {
const withoutUser = encodedDir.replace(/^-?Users-[^-]+-/, "")
const segments = withoutUser.split("-").filter(Boolean)
let current = homedir()
let i = 0
while (i < segments.length) {
let matched = false
for (let j = segments.length; j > i; j--) {
const name = segments.slice(i, j).join("-")
if (existsSync(join(current, name))) {
current = join(current, name)
i = j
matched = true
break
}
}
if (!matched) {
current = join(current, segments.slice(i).join("-"))
break
}
}
return current
}
/**
* Resolve any projectPath variant to the best-guess absolute real directory.
* Used as the canonical merge key in mergeProjectsInto and for display.
*/
function resolveProjectDir(projectPath) {
// Cursor "no workspace" variants — both map to the same virtual canonical dir
if (projectPath === "cursor:cursor-unknown") return join(homedir(), ".cursor", "no-workspace")
if (projectPath === "cursor-agent:/empty/window") return join(homedir(), ".cursor", "no-workspace")
if (projectPath === "cursor-agent:empty-window") return join(homedir(), ".cursor", "no-workspace")
// gemini: absolute path
if (projectPath.startsWith("gemini:")) {
const p = projectPath.slice(7)
if (p.startsWith("/")) return p
// fall through to legacy encoded path handling
}
// Modern platform-prefixed with absolute path: "prefix:/actual/path"
const absMatch = projectPath.match(/^[a-z-]+:(\/.+)$/)
if (absMatch) return absMatch[1]
// Claude absolute path under CLAUDE_DIR (always dash-encoded by Claude itself)
if (projectPath.startsWith(CLAUDE_DIR + "/")) {
return decodeClaudeEncodedDir(projectPath.slice(CLAUDE_DIR.length + 1))
}
// Legacy platform-prefixed with encoded path (old normProjectDir / cursor-agent slug)
// e.g. "cursor-agent:Users-dhruvanand-Code-myproject" or "codex:Code-myproject"
const encodedMatch = projectPath.match(/^[a-z-]+:([^/].+)$/)
if (encodedMatch) {
const encoded = encodedMatch[1]
return decodeClaudeEncodedDir(encoded.startsWith("Users-") ? `-${encoded}` : encoded)
}
if (projectPath.startsWith("/")) return projectPath
return join(homedir(), projectPath)
}
const DIST_DIR = join(__dirname, "dist")
const PORT = parseInt(process.env.PORT ?? "3001")
const AUTH_PIN = process.env.AUTH_PIN ?? null
const ts = () => new Date().toISOString().replace("T", " ").slice(0, 23)
const ENABLE_BACKGROUND_INDEXER = false // LanceDB removed; rg handles global search
/** Max lines sent on initial debug load / SSE init (full file still tracked for append). */
const DEBUG_TAIL_LINES = 500
const FIVE_MIN = 5 * 60 * 1000
const RECENT_CODEX_SEED_LIMIT = 40
const STREAM_BACKGROUND_DELAY_MS = 1200
// --- Config persistence (names + settings) ---
function loadConfig() {
try { return JSON.parse(readFileSync(CONFIG_FILE, "utf8")) } catch { return {} }
}
function saveConfig(data) {
mkdirSync(APP_CONFIG_DIR, { recursive: true })
writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2))
}
// --- Sidebar cache (SQLite sidebar-cache.db + in-memory Map for O(1) lookup) ---
let _sidebarCacheReady = false
function initSidebarCache() {
if (_sidebarCacheReady) return
const _t0 = performance.now()
const { migrated } = openSidebarCacheDb(APP_CONFIG_DIR)
let pruned = 0
for (const entry of getAllSidebarEntries()) {
if (entry.source !== "claude") continue
const fp = join(entry.projectPath, `${entry.id}.jsonl`)
if (hasClaudeTranscriptMessage(fp)) continue
if (deleteSidebarEntry(entry.id)) pruned++
}
const count = getSidebarSessionCount()
for (const entry of getAllSidebarEntries()) {
indexSession(entry.projectPath, entry.id, [], {
id: entry.id,
customName: entry.customName ?? null,
firstName: entry.firstName ?? null,
source: entry.source ?? "claude",
})
}
_sidebarCacheReady = true
const ms = (performance.now() - _t0).toFixed(1)
if (migrated) {
debugLog(`${ts()} [sidebar-cache] migrated ${migrated} sessions JSON→SQLite (${ms}ms)`)
} else {
debugLog(`${ts()} [sidebar-cache] opened ${count} sessions from ${SIDEBAR_CACHE_DB}; pruned ${pruned} missing Claude transcripts (${ms}ms)`)
}
}
/** @deprecated Use getSidebarCacheMap() — kept for callers expecting { sessions, _map }. */
function loadSidebarCache() {
initSidebarCache()
return { v: 2, sessions: getAllSidebarEntries(), _map: getSidebarCacheMap() }
}
/**
* Convert the cache into ProjectData[] groups, applying stored customNames.
* Returns null if cache is empty.
*/
function loadCachedSidebarState() {
initSidebarCache()
const entries = getAllSidebarEntries()
if (!entries.length) return null
const names = loadConfig().names ?? {}
return groupCacheSessionsToProjects(entries, names)
}
/**
* Sidebar cache only — no live platform scan. Top N via indexed SQLite query.
*/
function loadPinnedSessionEntry(pinProjectPath, pinSessionId, names) {
if (!pinProjectPath || !pinSessionId) return null
if (pinProjectPath.startsWith("codex:")) {
const fp = findCodexSessionFile(pinSessionId)
if (!fp) return null
let stat
try { stat = statSync(fp) } catch { return null }
const parsed = readCodexSessionById(pinSessionId, null, null)
const parsedMeta = parsed?.meta
const meta = parsedMeta ?? cheapCodexMetaFromFile(fp, names)
if (!meta || (meta.projectPath !== pinProjectPath && !isLegacyCodexProjectPath(pinProjectPath, meta.projectPath))) return null
return {
id: pinSessionId,
projectPath: meta.projectPath,
projectDisplayName: displayNameForProjectPath(meta.projectPath),
source: "codex",
messageCount: parsed?.msgs?.length ?? meta.messageCount ?? countJsonlLines(fp),
userMessageCount: meta.userMessageCount ?? null,
firstName: meta.firstName ?? pinSessionId.slice(0, 8),
lastActivity: meta.lastActivity ?? stat.mtime.toISOString(),
mtime: stat.mtimeMs,
customName: names[`${meta.projectPath}/${pinSessionId}`] ?? meta.customName ?? null,
...(meta.isSidechain ? {
isSidechain: true,
parentSessionId: meta.parentSessionId,
agentType: meta.agentType,
} : {}),
}
}
const msgs = loadSessionMessages(pinProjectPath, pinSessionId)
if (!Array.isArray(msgs) || !msgs.length) return null
const source = pinProjectPath.match(/^([a-z-]+):/)?.[1] ?? "claude"
const firstUserText = msgs.find(m => m.message?.role === "user" && typeof m.message?.content === "string")
?.message?.content
const lastActivity = msgs[msgs.length - 1]?.timestamp ?? ""
return {
id: pinSessionId,
projectPath: pinProjectPath,
projectDisplayName: displayNameForProjectPath(pinProjectPath),
source,
messageCount: msgs.length,
userMessageCount: msgs.filter(m => m.message?.role === "user").length,
firstName: typeof firstUserText === "string"
? firstUserText.replace(/\s+/g, " ").trim().slice(0, 80)
: pinSessionId.slice(0, 8),
lastActivity,
mtime: lastActivity,
customName: names[`${pinProjectPath}/${pinSessionId}`] ?? null,
}
}
function upsertSidebarCacheFromLoadedSession(projectPath, sessionId) {
const names = loadConfig().names ?? {}
const entry = loadPinnedSessionEntry(projectPath, sessionId, names)
if (!entry) return
const { changed, entry: cached } = updateSidebarCacheEntry(sessionId, entry)
if (changed) sseBroadcastSessionUpserts([cached])
}
function displayNameForProjectPath(projectPath) {
const withoutSource = String(projectPath ?? "").replace(/^[a-z-]+:/, "")
const parts = withoutSource.split("/").filter(Boolean)
return parts[parts.length - 1] || String(projectPath ?? "")
}
function loadProjectsFromSidebarCache(maxSessions, pinSessionId = null, pinProjectPath = null) {
initSidebarCache()
const _t0 = performance.now()
const total = getSidebarSessionCount()
if (!total) return { projects: [], total: 0 }
const names = loadConfig().names ?? {}
const n = Number(maxSessions)
let entries = Number.isFinite(n) && n > 0 ? getTopSidebarEntries(n) : getAllSidebarEntries()
if (pinSessionId) {
const pinned = getSidebarEntry(pinSessionId) ?? loadPinnedSessionEntry(pinProjectPath, pinSessionId, names)
if (pinned?.id) entries = expandSidebarLinkageEntries([...entries, pinned])
}
const projects = sortProjectGroups(groupCacheSessionsToProjects(entries, names))
const ms = (performance.now() - _t0).toFixed(1)
debugLog(`[perf ${wallClock()}] loadProjectsFromSidebarCache n=${entries.length}/${total} ${ms}ms`)
return { projects, total }
}
/** Apply cached counts/names to sessions that are missing them (cheap-scan results). */
function applySidebarCache(sessions) {
initSidebarCache()
const map = getSidebarCacheMap()
for (const s of sessions) {
const entry = map.get(s.id)
if (!entry) continue
// Use cached value whenever the live value is 0/null (cheap-scan placeholder)
if (!s.messageCount && entry.messageCount) s.messageCount = entry.messageCount
if (!s.userMessageCount && entry.userMessageCount) s.userMessageCount = entry.userMessageCount
if (!s.firstName && entry.firstName) s.firstName = entry.firstName
}
}
/** Upsert a cache entry. Returns { changed, entry }. */
function updateSidebarCacheEntry(sessionId, { projectPath, projectDisplayName, source, messageCount, userMessageCount, firstName, lastActivity, mtime, customName, isSidechain, parentSessionId, agentType }) {
initSidebarCache()
const { changed, entry } = upsertSidebarEntry({
id: sessionId,
projectPath,
projectDisplayName,
source,
messageCount,
userMessageCount,
firstName,
lastActivity,
mtime,
customName,
isSidechain,
parentSessionId,
agentType,
})
if (changed) {
indexSession(entry.projectPath, entry.id, [], {
id: entry.id,
customName: entry.customName ?? null,
firstName: entry.firstName ?? null,
source: entry.source ?? "claude",
})
}
return { changed, entry }
}
/** Flush updated cache entries from a hydrated projects array. Returns changed entries. */
function flushSidebarCacheFromProjects(projects, fileBySessKey) {
const changed = []
for (const p of projects) {
for (const s of p.sessions) {
const mtimeMs = fileBySessKey
? fileBySessKey.get(SESS_PATH_KEY(p.path, s.id))?.stat?.mtimeMs
: null
const { changed: c, entry } = updateSidebarCacheEntry(s.id, {
projectPath: p.path,
projectDisplayName: p.displayName,
source: s.source ?? "claude",
messageCount: s.messageCount ?? 0,
userMessageCount: s.userMessageCount ?? null,
firstName: s.firstName ?? null,
lastActivity: s.lastActivity,
mtime: mtimeMs ?? s.lastActivity,
customName: s.customName ?? null,
isSidechain: s.isSidechain,
parentSessionId: s.parentSessionId,
agentType: s.agentType,
})
if (c) changed.push(entry)
}
}
return changed
}
function codexSessionIdFromFile(filePath) {
const base = basename(filePath, ".jsonl")
const matches = base.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)
return matches?.at(-1) ?? base
}
function readJsonlPrefixRows(filePath, maxBytes = 1024 * 1024, maxLines = 100) {
let fd = null
try {
const st = statSync(filePath)
const len = Math.min(maxBytes, st.size)
const buf = Buffer.alloc(len)
fd = openSync(filePath, "r")
const bytes = readSync(fd, buf, 0, len, 0)
const text = buf.toString("utf8", 0, bytes)
return text
.split("\n")
.filter(Boolean)
.slice(0, maxLines)
.flatMap(line => {
try { return [JSON.parse(line)] } catch { return [] }
})
} catch {
return []
} finally {
if (fd != null) {
try { closeSync(fd) } catch { /* ignore */ }
}
}
}
function cheapCodexMetaFromFile(filePath, names) {
let stat
try { stat = statSync(filePath) } catch { return null }
const rows = readJsonlPrefixRows(filePath)
const sessionMeta = rows.find(r => r?.type === "session_meta")?.payload ?? {}
const turnContext = rows.find(r => r?.type === "turn_context")?.payload ?? {}
const sessionId = sessionMeta.id ?? codexSessionIdFromFile(filePath)
const cwd = sessionMeta.cwd ?? turnContext.cwd ?? ""
const projectDir = cwd ? normProjectDir(cwd) : "codex-global"
const projectPath = `codex:${projectDir}`
const cacheEntry = loadSidebarCache()._map.get(sessionId)
const isSubagent = sessionMeta.thread_source === "subagent" ||
(typeof sessionMeta.source === "object" && sessionMeta.source?.subagent != null)
const parentSessionId = sessionMeta.forked_from_id ??
sessionMeta.parent_thread_id ??
sessionMeta.source?.subagent?.thread_spawn?.parent_thread_id ??
null
const subagentNickname = sessionMeta.agent_nickname ??
sessionMeta.source?.subagent?.thread_spawn?.agent_nickname ??
null
const firstUser = rows.find(r => r?.type === "event_msg" && r?.payload?.type === "user_message")
const firstUserText = typeof firstUser?.payload?.message === "string" ? firstUser.payload.message : ""
const claudeCodexParent = isSubagent ? null : inferClaudeCodexParent({ sessionMeta, turnContext, firstUserText })
const firstName = isSubagent && typeof subagentNickname === "string" && subagentNickname.trim()
? subagentNickname.trim()
: firstUserText
? stripXml(firstUserText).replace(/\s+/g, " ").trim().slice(0, 80)
: cacheEntry?.firstName ?? null
return {
id: sessionId,
projectPath,
messageCount: cacheEntry?.messageCount ?? 0,
userMessageCount: cacheEntry?.userMessageCount ?? null,
lastActivity: stat.mtime.toISOString(),
isActive: Date.now() - stat.mtimeMs < FIVE_MIN,
firstName,
customName: names[`${projectPath}/${sessionId}`] ?? cacheEntry?.customName ?? null,
source: "codex",
version: sessionMeta.cli_version ?? cacheEntry?.version ?? null,
lastUsedModel: turnContext.model ?? cacheEntry?.lastUsedModel ?? null,
...(isSubagent ? { isSidechain: true, parentSessionId, agentType: "subagent" } : {}),
...(claudeCodexParent ? {
isSidechain: true,
parentSessionId: claudeCodexParent.parentSessionId,
agentType: claudeCodexParent.agentType,
} : {}),
}
}
/** Live-scan Codex rollout files for subagents of a parent (cache backfill on deep links). */
function findCodexSubagentsForParent(parentSessionId, names = {}) {
if (!parentSessionId || !existsSync(CODEX_SESSIONS_ROOT)) return []
const out = []
for (const filePath of listCodexSessionFiles()) {
const rows = readJsonlPrefixRows(filePath)
const sessionMeta = rows.find(r => r?.type === "session_meta")?.payload ?? {}
const parentId = sessionMeta.forked_from_id ??
sessionMeta.parent_thread_id ??
sessionMeta.source?.subagent?.thread_spawn?.parent_thread_id ??
null
if (parentId !== parentSessionId) continue
const meta = cheapCodexMetaFromFile(filePath, names)
if (meta) out.push(meta)
}
return out
}
function backfillPinnedSessionLinkage(pinSessionId) {
if (!pinSessionId) return []
const pinned = getSidebarEntry(pinSessionId)
if (!pinned?.id || pinned.isSidechain) return []
const names = loadConfig().names ?? {}
const changed = []
for (const meta of findCodexSubagentsForParent(pinSessionId, names)) {
const existing = getSidebarEntry(meta.id)
if (existing?.isSidechain && existing.parentSessionId === pinSessionId) continue
const { changed: c, entry } = updateSidebarCacheEntry(meta.id, {
projectPath: meta.projectPath,
source: meta.source ?? "codex",
messageCount: meta.messageCount ?? 0,
userMessageCount: meta.userMessageCount ?? null,
firstName: meta.firstName ?? null,
lastActivity: meta.lastActivity,
mtime: meta.lastActivity,
isSidechain: meta.isSidechain,
parentSessionId: meta.parentSessionId,
agentType: meta.agentType,
})
if (c) changed.push(entry)
}
return changed
}
function loadRecentCodexProjectsCheap(limit = RECENT_CODEX_SEED_LIMIT) {
if (!existsSync(CODEX_SESSIONS_ROOT)) return []
const names = loadConfig().names ?? {}
const recentFiles = listCodexSessionFiles()
.flatMap(filePath => {
try { return [{ filePath, mtimeMs: statSync(filePath).mtimeMs }] } catch { return [] }
})
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.slice(0, Math.max(1, limit))
const projects = new Map()
for (const { filePath } of recentFiles) {
const meta = cheapCodexMetaFromFile(filePath, names)
if (!meta) continue
const projectPath = meta.projectPath
if (!projects.has(projectPath)) {
projects.set(projectPath, {
path: projectPath,
displayName: projectPath,
sessions: [],
})
}
projects.get(projectPath).sessions.push(meta)
}
for (const project of projects.values()) {
project.sessions.sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)))
}
return Array.from(projects.values())
}
function stringifyCodexTailOutput(value) {
if (typeof value === "string") return value
if (value == null) return ""
try { return JSON.stringify(value, null, 2) } catch { return String(value) }
}
function codexTailAssistantText(content) {
if (!Array.isArray(content)) return ""
return content
.map(item => item?.type === "output_text" && typeof item.text === "string" ? item.text : "")
.filter(Boolean)
.join("\n\n")
.trim()
}
function codexTailReasoningText(payload) {
if (typeof payload?.content === "string" && payload.content.trim()) return payload.content.trim()
if (!Array.isArray(payload?.summary)) return ""
return payload.summary
.map(part => {
if (typeof part === "string") return part
if (typeof part?.text === "string") return part.text
if (typeof part?.summary_text === "string") return part.summary_text
return ""
})
.filter(Boolean)
.join("\n")
.trim()
}
function codexTailRowsToMessages(sessionId, rows, fallbackTs) {
const out = []
let seq = 0
// Codex emits each assistant message twice (event_msg agent_message + response_item
// message); skip the copy from the other stream — see buildCodexSessionResult.
const assistantSeen = new Map()
const isDuplicateAssistant = (text, source) => {
const prev = assistantSeen.get(text)
if (prev && prev !== source) {
assistantSeen.delete(text)
return true
}
assistantSeen.set(text, source)
return false
}
const push = msg => {
out.push({
...msg,
parentUuid: out.length > 0 ? out[out.length - 1].uuid : null,
})
}
for (const row of rows) {
const ts = typeof row?.timestamp === "string" ? row.timestamp : fallbackTs
if (row?.type === "event_msg" && row?.payload?.type === "user_message") {
const text = typeof row.payload.message === "string" ? row.payload.message.trim() : ""
if (!text) continue
push({
uuid: `codex-${sessionId}-tail-u-${seq++}`,
type: "human",
sessionId,
timestamp: ts,
isSidechain: false,
message: { role: "user", content: text },
})
continue
}
if (row?.type === "event_msg" && row?.payload?.type === "agent_message") {
const text = typeof row.payload.message === "string" ? row.payload.message.trim() : ""
if (!text || isDuplicateAssistant(text, "event")) continue
push({
uuid: `codex-${sessionId}-tail-a-${seq++}`,
type: "assistant",
sessionId,
timestamp: ts,
isSidechain: false,
message: { role: "assistant", content: text },
})
continue
}
if (row?.type !== "response_item" || !row?.payload?.type) continue
if (row.payload.type === "message" && row.payload.role === "assistant") {
const text = codexTailAssistantText(row.payload.content)
if (!text || isDuplicateAssistant(text, "response")) continue
push({
uuid: `codex-${sessionId}-tail-a-${seq++}`,
type: "assistant",
sessionId,
timestamp: ts,
isSidechain: false,
message: { role: "assistant", content: text },
})
continue
}
if (row.payload.type === "reasoning") {
const thinking = codexTailReasoningText(row.payload)
if (!thinking) continue
push({
uuid: `codex-${sessionId}-tail-a-${seq++}`,
type: "assistant",
sessionId,
timestamp: ts,
isSidechain: false,
message: { role: "assistant", content: [{ type: "thinking", thinking }] },
})
continue
}
if (row.payload.type === "function_call") {
let input = {}
try { input = JSON.parse(row.payload.arguments ?? "{}") } catch { input = { _raw: row.payload.arguments ?? "" } }
push({
uuid: `codex-${sessionId}-tail-a-${seq++}`,
type: "assistant",
sessionId,
timestamp: ts,
isSidechain: false,
message: {
role: "assistant",
content: [{
type: "tool_use",
id: row.payload.call_id ?? `${sessionId}-tail-tool-${seq}`,
name: row.payload.name ?? "tool",
input,
}],
},
})
continue
}
if (row.payload.type === "function_call_output") {
push({
uuid: `codex-${sessionId}-tail-u-${seq++}`,
type: "human",
sessionId,
timestamp: ts,
isSidechain: false,
message: {
role: "user",
content: [{
type: "tool_result",
tool_use_id: row.payload.call_id ?? undefined,
content: stringifyCodexTailOutput(row.payload.output),
}],
},
})
}
}
return out
}
function readCodexSessionTailFast(projectPath, sessionId, tail) {
const result = readCodexSessionById(sessionId, null, null)
if (!result?.meta || !Array.isArray(result.msgs)) return null
if (result.meta.projectPath !== projectPath && !isLegacyCodexProjectPath(projectPath, result.meta.projectPath)) return null
const msgs = result.msgs
if (!msgs.length) return null
return {
msgs: msgs.slice(-tail),
total: msgs.length,
}
}
// --- Session reading ---
function parseJsonl(fp) {
const t0 = performance.now()
const result = parseJsonlStream(fp)
const ms = (performance.now() - t0).toFixed(1)
if (parseFloat(ms) > 50) debugWarn(`[perf] parseJsonl ${ms}ms — ${result.length} msgs — ${fp.split("/").pop()}`)
return result
}
/**
* Count non-empty lines in a JSONL file with a fast byte scan (no JSON parsing).
* Reads the whole file in 256KB chunks, counts newlines.
*/
function countJsonlLines(fp) {
try {
const { size } = statSync(fp)
if (size === 0) return 0
const CHUNK = 262144
const buf = Buffer.alloc(CHUNK)
const fd = openSync(fp, "r")
let count = 0
let offset = 0
let prevWasNewline = true // so a leading non-empty first line counts
try {
while (offset < size) {
const n = readSync(fd, buf, 0, CHUNK, offset)
if (n === 0) break
for (let i = 0; i < n; i++) {
const b = buf[i]
if (b === 10 /* \n */) {
prevWasNewline = true
} else if (prevWasNewline) {
prevWasNewline = false
count++
}
}
offset += n
}
} finally { closeSync(fd) }
return count
} catch { return 0 }
}
/**
* Parse the last `n` valid JSON lines from a JSONL file without reading the whole file.
* Reads backward in 64KB chunks until `n` parsed objects are collected.
*/
function readJsonlTail(fp, n) {
try {
const { size } = statSync(fp)
if (size === 0) return []
const CHUNK = 65536
let offset = size
let partial = ""
const lines = []
const fd = openSync(fp, "r")
try {
while (offset > 0 && lines.length < n) {
const readSize = Math.min(CHUNK, offset)
offset -= readSize
const buf = Buffer.alloc(readSize)
readSync(fd, buf, 0, readSize, offset)
const chunk = buf.toString("utf8") + partial
const parts = chunk.split("\n")
partial = parts[0] // possibly incomplete first line
for (let i = parts.length - 1; i >= 1 && lines.length < n; i--) {
const line = parts[i].trim()
if (!line) continue
try { lines.push(JSON.parse(line)) } catch { /* skip malformed */ }
}
}
} finally { closeSync(fd) }
lines.reverse()
return lines
} catch { return [] }
}
/**
* Read a Claude subagent's sibling `<basename>.meta.json` if present.
* Returns { agentType, description } or null. Description is preferred over the
* (very long) first-prompt body when present.
*/
function readClaudeSubagentMeta(jsonlPath) {
if (!jsonlPath.endsWith(".jsonl")) return null
const metaPath = jsonlPath.slice(0, -".jsonl".length) + ".meta.json"
try {
const raw = readFileSync(metaPath, "utf8")
const obj = JSON.parse(raw)
if (obj && typeof obj === "object") {
return {
agentType: typeof obj.agentType === "string" ? obj.agentType : null,
description: typeof obj.description === "string" ? obj.description : null,
}
}
} catch { /* missing or malformed — fine */ }
return null
}
/**
* Pick a display name for a subagent session. Prefers the meta.json description
* (often concise like "Research ORT protobuf parsing failure") and falls back to
* the truncated first user prompt body.
*/
function pickClaudeSubagentFirstName(jsonlPath, meta) {
if (meta?.description?.trim()) return meta.description.trim().slice(0, 100)
return cheapReadFirstUserMsg(jsonlPath)
}
/** Read just the first ~4KB of a JSONL to cheaply extract the first user message text. */
function cheapReadFirstUserMsg(fp, maxLines = 30) {
try {
const fd = openSync(fp, "r")
const buf = Buffer.alloc(65536)
const n = readSync(fd, buf, 0, 65536, 0)
closeSync(fd)
const raw = buf.toString("utf8", 0, n)
const lines = raw.split("\n")
for (let i = 0; i < Math.min(maxLines, lines.length); i++) {
const line = lines[i].trim()
if (!line) continue
try {
const msg = JSON.parse(line)
if (msg.type !== "user") continue
const c = msg.message?.content
if (!c) continue
if (typeof c === "string" && c.trim()) return stripXml(c).slice(0, 100)
if (Array.isArray(c)) {
const tb = c.find(b => b.type === "text" && b.text?.trim() && !c.some(x => x.type === "tool_result"))
if (tb) return stripXml(tb.text).slice(0, 100)
// fallback: any text block that isn't only tool results
const anyTb = c.find(b => b.type === "text" && b.text?.trim())
if (anyTb) return stripXml(anyTb.text).slice(0, 100)
}
} catch { continue }
}
return null
} catch { return null }
}
/** Roots: ~/.claude/projects plus config extraProjectRoots */
function getClaudeScanRoots() {
const config = loadConfig()
const roots = [{ path: CLAUDE_DIR, label: null }]
for (const extra of config.extraProjectRoots ?? []) {
const p = typeof extra === "string" ? extra : extra.path
const label = typeof extra === "object" ? (extra.label ?? null) : null
roots.push({ path: p.replace(/^~/, homedir()), label })
}
return roots
}
function claudeSessionMetaFromMsgs(msgs, sessionId, projectKey, names, stat) {
const first = msgs.find(m => m.sessionId)
const last = [...msgs].reverse().find(m => m.timestamp)
const firstUserMsg = msgs.find(m => {
if (m.type !== "user") return false
const c = m.message?.content
if (!c) return false
if (typeof c === "string") return c.trim().length > 0
if (!Array.isArray(c)) return false
return c.some(b => b.type !== "tool_result")
})