@gotcos/glasses-server 6.27.6 → 6.27.9

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.
@@ -0,0 +1,1262 @@
1
+ // Local Claude / Codex / Cursor transcripts on this Mac.
2
+ // Default window is 7 days of last write (mtime). Codex Desktop pins keep
3
+ // the original YYYY/MM/DD folder, so listing by calendar path misses them.
4
+ // `sort=opened` uses session start instead. Same clocks as COS Control.
5
+ // The glasses companion cannot read these directories; the server can.
6
+
7
+ import { execFile } from 'node:child_process'
8
+ import { createReadStream } from 'node:fs'
9
+ import { lstat, readdir, readFile, stat } from 'node:fs/promises'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { createInterface } from 'node:readline'
13
+ import { promisify } from 'node:util'
14
+
15
+ const execFileAsync = promisify(execFile)
16
+
17
+ export const AGENT_SESSION_WINDOW_HOURS = 7 * 24
18
+ export const AGENT_SESSION_MAX_AGE_MS = AGENT_SESSION_WINDOW_HOURS * 3600 * 1000
19
+ export const AGENT_SESSION_MAX_FILE_BYTES = 32 * 1024 * 1024
20
+ export const AGENT_SESSION_PER_PROVIDER_LIMIT = 20
21
+ export const AGENT_SESSION_LIST_LIMIT = AGENT_SESSION_PER_PROVIDER_LIMIT * 3
22
+ export const AGENT_SESSION_LIST_MAX = 80
23
+ const HEAD_BYTES = 256 * 1024
24
+ export const CLAUDE_UUID_JSONL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/
25
+ const CODEX_ROLLOUT_STAMP = /^rollout-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-/
26
+
27
+ export type AgentProvider = 'claude' | 'codex' | 'cursor'
28
+ export type AgentSessionSort = 'updated' | 'opened'
29
+
30
+ export interface AgentSessionRow {
31
+ session_id: string
32
+ provider: AgentProvider
33
+ display_label: string
34
+ project: string
35
+ modified: string
36
+ created: string
37
+ alive: boolean
38
+ state: 'running' | 'recent'
39
+ pinned: boolean
40
+ first_prompt?: string
41
+ discussion_summary?: string
42
+ }
43
+
44
+ export interface AgentSessionRoots {
45
+ claudeProjects: string
46
+ claudeDesktopConfig: string
47
+ claudeCodeSessions: string
48
+ codexSessions: string
49
+ cursorProjects: string
50
+ cursorComposerDb: string
51
+ cursorWorkspaceStorage: string
52
+ }
53
+
54
+ export function agentSessionRoots(home = process.env.COS_AGENT_SESSIONS_HOME || homedir()): AgentSessionRoots {
55
+ return {
56
+ claudeProjects: join(home, '.claude', 'projects'),
57
+ claudeDesktopConfig: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
58
+ claudeCodeSessions: join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions'),
59
+ codexSessions: join(home, '.codex', 'sessions'),
60
+ cursorProjects: join(home, '.cursor', 'projects'),
61
+ cursorComposerDb: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
62
+ cursorWorkspaceStorage: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage'),
63
+ }
64
+ }
65
+
66
+ export function isSafeSessionId(value: string): boolean {
67
+ const needle = value.trim().toLowerCase()
68
+ return needle.length >= 8
69
+ && !needle.includes('..')
70
+ && !needle.includes('/')
71
+ && [...needle].every(ch => /[0-9a-f-]/.test(ch))
72
+ }
73
+
74
+ export function workspaceLabel(raw: string): string {
75
+ const trimmed = raw.trim()
76
+ if (!trimmed) return ''
77
+ if (trimmed.startsWith('/')) {
78
+ const parts = trimmed.split('/').filter(Boolean)
79
+ return parts[parts.length - 1] || ''
80
+ }
81
+ const encoded = trimmed.startsWith('-') ? trimmed.slice(1) : trimmed
82
+ if (encoded.includes('MU-Chief-Staff')) return 'MU-Chief-Staff'
83
+ const github = encoded.lastIndexOf('GitHub-')
84
+ if (github >= 0) {
85
+ const rest = encoded.slice(github + 'GitHub-'.length)
86
+ if (rest === 'MU-Chief-Staff' || rest.endsWith('-MU-Chief-Staff')) return 'MU-Chief-Staff'
87
+ return rest
88
+ }
89
+ const parts = trimmed.split('/').filter(Boolean)
90
+ return parts[parts.length - 1] || trimmed
91
+ }
92
+
93
+ export function isScratchCursorProject(folder: string): boolean {
94
+ const name = folder.split('/').filter(Boolean).pop()?.toLowerCase() || folder.toLowerCase()
95
+ return name.includes('var-folders') || name.includes('private-var') || name === 'empty-window'
96
+ }
97
+
98
+ export const isSkippedCursorFolder = isScratchCursorProject
99
+
100
+ export function isKeepWarmSessionTitle(title: string): boolean {
101
+ const t = title.trim().toLowerCase()
102
+ if (t === 'ready') return true
103
+ return t.startsWith('this is an automated local readiness check')
104
+ }
105
+
106
+ export function isWrapperPrompt(text: string): boolean {
107
+ const trimmed = text.trim()
108
+ return trimmed.startsWith('<')
109
+ || trimmed.startsWith('SYSTEM INSTRUCTIONS')
110
+ || trimmed.startsWith('You are an agent')
111
+ || trimmed.startsWith('You are QA Agent')
112
+ || trimmed.startsWith('Message Type:')
113
+ }
114
+
115
+ export function firstLineTitle(text: string): string {
116
+ let body = text
117
+ const start = body.indexOf('<user_query>')
118
+ const end = body.indexOf('</user_query>')
119
+ if (start >= 0 && end > start) body = body.slice(start + '<user_query>'.length, end)
120
+ body = body.replace(/<[^>]+>/g, ' ')
121
+ const line = body.split('\n').map(s => s.trim()).find(s => s.length > 0) ?? ''
122
+ return line.slice(0, 80)
123
+ }
124
+
125
+ export function proseSnippet(text: string, max = 160): string {
126
+ let body = text.replace(/```[\s\S]*?```/g, ' ')
127
+ body = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
128
+ if (!body || isWrapperPrompt(body)) return ''
129
+ return body.slice(0, max)
130
+ }
131
+
132
+ export function composeDiscussionSummary(input: {
133
+ title?: string
134
+ firstPrompt?: string
135
+ latestAssistant?: string
136
+ max?: number
137
+ }): string {
138
+ const max = input.max ?? 180
139
+ const title = (input.title ?? '').replace(/\s+/g, ' ').trim()
140
+ const first = firstLineTitle(input.firstPrompt ?? '')
141
+ const latest = (input.latestAssistant ?? '').replace(/\s+/g, ' ').trim()
142
+ const opening = first && first !== title ? first : ''
143
+ const parts: string[] = []
144
+ if (opening) parts.push(opening)
145
+ if (latest && latest !== title && latest !== opening) parts.push(latest)
146
+ const joined = parts.join(' · ')
147
+ if (!joined) return ''
148
+ return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`
149
+ }
150
+
151
+ /** Deep digest budget. The glasses detail page paginates at 200 chars, so 2000 is
152
+ * ~10 swipes — long by G2 standards, and deliberately so: this exists to be READ
153
+ * before deciding whether to follow up on a session, the one moment depth beats
154
+ * brevity. The LIST row keeps the short `discussion_summary`; a 2000-char gist
155
+ * appended to a single row would destroy it. Two fields, two jobs. */
156
+ export const DISCUSSION_DIGEST_MAX = 2000
157
+
158
+ /** Per-turn cap, so one enormous paste cannot eat the whole budget. */
159
+ const DIGEST_TURN_MAX = 220
160
+
161
+ /** Turns kept from the START. The opening ask frames everything after it. */
162
+ const DIGEST_HEAD_TURNS = 2
163
+
164
+ /** Recent turns retained while streaming. Comfortably more than 2000 chars can
165
+ * render (~20-25 at typical length), so the budget and not the buffer decides
166
+ * what appears. */
167
+ const DIGEST_RECENT_WINDOW = 60
168
+
169
+ /**
170
+ * A readable account of what actually happened in a session.
171
+ *
172
+ * `composeDiscussionSummary` is the opening prompt plus the last assistant snippet at
173
+ * 180 chars — it says where a session started and where it stopped, and nothing about
174
+ * the turns between, which is exactly what you need to decide whether to reopen it.
175
+ *
176
+ * Shape: the opening ask, then the MOST RECENT user turns in chronological order, then
177
+ * where the assistant left off. Recency is weighted because a follow-up continues from
178
+ * the end, not the beginning.
179
+ *
180
+ * NO LLM, by design — assembled from turns `parseAgentSession` already streams, so a
181
+ * digest costs no tokens and no extra file reads.
182
+ *
183
+ * Elision is STATED, never silent: dropping 40 turns and rendering the rest as clean
184
+ * prose reads like the whole story. `… N earlier turns …` says otherwise.
185
+ */
186
+ export function composeDiscussionDigest(input: {
187
+ userTurns: string[]
188
+ latestAssistant?: string
189
+ max?: number
190
+ /** True number of user turns in the session, when `userTurns` is a bounded sample.
191
+ * The caller keeps only head + a recent window so a 900-turn session cannot balloon
192
+ * memory, and without this the elision line would report only the turns it can see
193
+ * and quietly under-count the rest — a silent cap wearing an honest label. */
194
+ totalTurns?: number
195
+ /** The transcript was read as head+tail, so the middle was never seen and the true
196
+ * turn count is unknown. Print elision WITHOUT a number rather than a wrong one. */
197
+ truncated?: boolean
198
+ }): string {
199
+ const max = input.max ?? DISCUSSION_DIGEST_MAX
200
+ const clean = (s: string): string => s.replace(/\s+/g, ' ').trim()
201
+ const cap = (s: string): string => (s.length <= DIGEST_TURN_MAX ? s : `${s.slice(0, DIGEST_TURN_MAX - 1)}…`)
202
+
203
+ const turns = input.userTurns.map(clean).filter(Boolean).map(cap)
204
+ const latest = clean(input.latestAssistant ?? '')
205
+ if (turns.length === 0 && !latest) return ''
206
+ const total = Math.max(input.totalTurns ?? turns.length, turns.length)
207
+
208
+ // Reserve room for the closing state before spending budget on asks.
209
+ const tail = latest ? `\n\nLatest: ${cap(latest)}` : ''
210
+ let budget = max - tail.length
211
+
212
+ const head = turns.slice(0, DIGEST_HEAD_TURNS)
213
+ const rest = turns.slice(DIGEST_HEAD_TURNS)
214
+
215
+ // HEAD FIRST. Claiming the opening ask "frames everything after it" and then
216
+ // spending the budget from the end left no room for it — a 40-turn session rendered
217
+ // as "… 22 earlier turns …" with the original request nowhere on the page. Reserve
218
+ // the framing, then spend what is left on recency. Caught by its own test.
219
+ const headKept: string[] = []
220
+ for (const turn of head) {
221
+ const cost = turn.length + 3
222
+ if (cost > budget) break
223
+ headKept.push(turn)
224
+ budget -= cost
225
+ }
226
+
227
+ // Then fill from the END backwards — the turns nearest a follow-up.
228
+ const kept: string[] = []
229
+ for (let i = rest.length - 1; i >= 0; i--) {
230
+ const cost = rest[i].length + 3
231
+ if (cost > budget) break
232
+ kept.unshift(rest[i])
233
+ budget -= cost
234
+ }
235
+
236
+ const dropped = total - headKept.length - kept.length
237
+ const lines: string[] = []
238
+ for (const t of headKept) lines.push(`• ${t}`)
239
+ if (input.truncated) lines.push('… middle of a large session not read …')
240
+ else if (dropped > 0) lines.push(`… ${dropped} earlier turn${dropped === 1 ? '' : 's'} …`)
241
+ for (const t of kept) lines.push(`• ${t}`)
242
+
243
+ const body = lines.join('\n') + tail
244
+ return body.length <= max ? body : `${body.slice(0, max - 1)}…`
245
+ }
246
+
247
+ export function assistantProseFromRecord(obj: Record<string, unknown>): string | null {
248
+ if (obj.type === 'assistant') {
249
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
250
+ const text = message ? payloadText(message) : null
251
+ return text ? proseSnippet(text) || null : null
252
+ }
253
+ if (obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
254
+ const payload = obj.payload as Record<string, unknown>
255
+ if (payload.role === 'assistant' && (payload.type === 'message' || !payload.type)) {
256
+ const text = payloadText(payload)
257
+ return text ? proseSnippet(text) || null : null
258
+ }
259
+ }
260
+ if (obj.role === 'assistant') {
261
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
262
+ const text = message ? payloadText(message) : null
263
+ return text ? proseSnippet(text) || null : null
264
+ }
265
+ return null
266
+ }
267
+
268
+ export function latestAssistantFromWindow(text: string): string {
269
+ let latest = ''
270
+ for (const line of text.split('\n')) {
271
+ const obj = parseJsonLine(line)
272
+ if (!obj) continue
273
+ const prose = assistantProseFromRecord(obj)
274
+ if (prose) latest = prose
275
+ }
276
+ return latest
277
+ }
278
+
279
+ function discussionFields(title: string, firstPrompt: string, latestAssistant: string): {
280
+ first_prompt: string
281
+ discussion_summary: string
282
+ } {
283
+ return {
284
+ first_prompt: firstPrompt,
285
+ discussion_summary: composeDiscussionSummary({ title, firstPrompt, latestAssistant }),
286
+ }
287
+ }
288
+
289
+ function cursorUserTitle(body: string, requireQuery: boolean): string | null {
290
+ if (requireQuery && !body.includes('<user_query>')) return null
291
+ const title = firstLineTitle(body)
292
+ if (!title || isWrapperPrompt(title)) return null
293
+ return title
294
+ }
295
+
296
+ export function payloadText(payload: Record<string, unknown>): string | null {
297
+ const content = payload.content
298
+ if (typeof content === 'string') {
299
+ const trimmed = content.trim()
300
+ return trimmed ? trimmed : null
301
+ }
302
+ if (!Array.isArray(content)) return null
303
+ const parts: string[] = []
304
+ for (const block of content) {
305
+ if (!block || typeof block !== 'object') continue
306
+ const type = String((block as { type?: unknown }).type ?? '')
307
+ if (type === 'tool_result' || type === 'tool_use' || type === 'thinking') continue
308
+ if (type === 'text' || type === 'input_text' || type === 'output_text') {
309
+ const text = String((block as { text?: unknown }).text ?? '').trim()
310
+ if (text) parts.push(text)
311
+ }
312
+ }
313
+ const joined = parts.join('\n\n').trim()
314
+ return joined || null
315
+ }
316
+
317
+ export function isoFromMtime(mtimeMs: number): string {
318
+ return new Date(mtimeMs).toISOString()
319
+ }
320
+
321
+ function row(partial: Omit<AgentSessionRow, 'created' | 'state' | 'pinned'> & {
322
+ created?: string
323
+ state?: AgentSessionRow['state']
324
+ pinned?: boolean
325
+ }): AgentSessionRow {
326
+ return {
327
+ ...partial,
328
+ created: partial.created ?? partial.modified,
329
+ state: partial.state ?? (partial.alive ? 'running' : 'recent'),
330
+ pinned: partial.pinned ?? false,
331
+ }
332
+ }
333
+
334
+ export function idFromCodexFilename(name: string): string | null {
335
+ if (!name.endsWith('.jsonl')) return null
336
+ const parts = name.slice(0, -6).split('-')
337
+ if (parts.length < 5) return null
338
+ const uuid = parts.slice(-5).join('-').toLowerCase()
339
+ return uuid.length >= 36 ? uuid : null
340
+ }
341
+
342
+ export async function dirents(dir: string): Promise<string[]> {
343
+ try {
344
+ return await readdir(dir)
345
+ } catch {
346
+ return []
347
+ }
348
+ }
349
+
350
+ export async function fileStat(path: string): Promise<{ mtimeMs: number; birthtimeMs: number; size: number; isFile: boolean } | null> {
351
+ try {
352
+ const st = await lstat(path)
353
+ return {
354
+ mtimeMs: st.mtimeMs,
355
+ birthtimeMs: st.birthtimeMs || st.mtimeMs,
356
+ size: st.size,
357
+ isFile: st.isFile(),
358
+ }
359
+ } catch {
360
+ return null
361
+ }
362
+ }
363
+
364
+ export async function readWindow(path: string, fromEnd: boolean): Promise<string> {
365
+ const st = await fileStat(path)
366
+ if (!st?.isFile || st.size <= 0) return ''
367
+ let start = 0
368
+ let end = st.size - 1
369
+ if (st.size > HEAD_BYTES) {
370
+ if (fromEnd) start = st.size - HEAD_BYTES
371
+ else end = HEAD_BYTES - 1
372
+ }
373
+ const stream = createReadStream(path, { start, end })
374
+ const chunks: Buffer[] = []
375
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
376
+ return Buffer.concat(chunks).toString('utf8')
377
+ }
378
+
379
+ export function parseJsonLine(line: string): Record<string, unknown> | null {
380
+ try {
381
+ const obj = JSON.parse(line) as unknown
382
+ return obj && typeof obj === 'object' ? obj as Record<string, unknown> : null
383
+ } catch {
384
+ return null
385
+ }
386
+ }
387
+
388
+ export async function lastCustomTitle(path: string): Promise<string | null> {
389
+ const text = await readWindow(path, true)
390
+ let found: string | null = null
391
+ for (const line of text.split('\n')) {
392
+ if (!line.includes('custom-title')) continue
393
+ const obj = parseJsonLine(line)
394
+ if (obj?.type !== 'custom-title') continue
395
+ const title = String(obj.customTitle ?? '').trim()
396
+ if (title) found = title.slice(0, 120)
397
+ }
398
+ return found
399
+ }
400
+
401
+ export async function peekClaudeDiscussion(path: string): Promise<{ customTitle: string | null; latestAssistant: string }> {
402
+ const text = await readWindow(path, true)
403
+ let customTitle: string | null = null
404
+ for (const line of text.split('\n')) {
405
+ if (line.includes('custom-title')) {
406
+ const obj = parseJsonLine(line)
407
+ if (obj?.type === 'custom-title') {
408
+ const title = String(obj.customTitle ?? '').trim()
409
+ if (title) customTitle = title.slice(0, 120)
410
+ }
411
+ }
412
+ }
413
+ return { customTitle, latestAssistant: latestAssistantFromWindow(text) }
414
+ }
415
+
416
+ export async function firstClaudeUserTitle(path: string): Promise<string | null> {
417
+ const text = await readWindow(path, false)
418
+ for (const line of text.split('\n')) {
419
+ const obj = parseJsonLine(line)
420
+ if (!obj || obj.type !== 'user' || obj.toolUseResult || obj.isSidechain === true) continue
421
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
422
+ const body = message ? payloadText(message) : null
423
+ if (!body) continue
424
+ const title = firstLineTitle(body)
425
+ if (title) return title
426
+ }
427
+ return null
428
+ }
429
+
430
+ export function createdFromCodexFilename(name: string): string | null {
431
+ const match = CODEX_ROLLOUT_STAMP.exec(name)
432
+ if (!match) return null
433
+ const stamp = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}`)
434
+ return Number.isFinite(stamp.getTime()) ? stamp.toISOString() : null
435
+ }
436
+
437
+ export async function loadCodexThreadNames(sessionsRoot: string): Promise<Map<string, string>> {
438
+ const names = new Map<string, string>()
439
+ const indexPath = join(sessionsRoot, '..', 'session_index.jsonl')
440
+ try {
441
+ const rl = createInterface({ input: createReadStream(indexPath), crlfDelay: Infinity })
442
+ for await (const line of rl) {
443
+ const obj = parseJsonLine(line)
444
+ if (!obj) continue
445
+ const id = String(obj.id ?? '').trim()
446
+ const name = String(obj.thread_name ?? '').trim()
447
+ if (id && name) names.set(id, name.slice(0, 120))
448
+ }
449
+ } catch {
450
+ return names
451
+ }
452
+ return names
453
+ }
454
+
455
+ export async function loadCodexPinnedIds(sessionsRoot: string): Promise<Set<string>> {
456
+ const pinned = new Set<string>()
457
+ const statePath = join(sessionsRoot, '..', '.codex-global-state.json')
458
+ try {
459
+ const raw = await readFile(statePath, 'utf8')
460
+ const obj = JSON.parse(raw) as { 'pinned-thread-ids'?: unknown }
461
+ const list = obj['pinned-thread-ids']
462
+ if (!Array.isArray(list)) return pinned
463
+ for (const value of list) {
464
+ const id = String(value ?? '').trim().toLowerCase()
465
+ if (id) pinned.add(id)
466
+ }
467
+ } catch {
468
+ return pinned
469
+ }
470
+ return pinned
471
+ }
472
+
473
+ export function normalizeClaudeSessionId(raw: string): string {
474
+ let id = raw.trim().toLowerCase()
475
+ if (id.startsWith('local_')) id = id.slice('local_'.length)
476
+ return id
477
+ }
478
+
479
+ export function peekClaudeDesktopHead(text: string): { title: string; cwd: string } {
480
+ const unescape = (value: string) => value.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
481
+ const title = /"title"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
482
+ const cwd = /"cwd"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
483
+ return {
484
+ title: title ? unescape(title[1]).slice(0, 120) : '',
485
+ cwd: cwd ? unescape(cwd[1]) : '',
486
+ }
487
+ }
488
+
489
+ export async function loadClaudeStarredIds(configPath: string): Promise<Set<string>> {
490
+ const starred = new Set<string>()
491
+ if (!configPath) return starred
492
+ try {
493
+ const obj = JSON.parse(await readFile(configPath, 'utf8')) as {
494
+ preferences?: { epitaxyPrefs?: { 'starred-local-code-sessions'?: unknown } }
495
+ }
496
+ const list = obj.preferences?.epitaxyPrefs?.['starred-local-code-sessions']
497
+ if (!Array.isArray(list)) return starred
498
+ for (const value of list) {
499
+ const id = normalizeClaudeSessionId(String(value ?? ''))
500
+ if (id) starred.add(id)
501
+ }
502
+ } catch {
503
+ return starred
504
+ }
505
+ return starred
506
+ }
507
+
508
+ function parsePinnedComposerList(raw: string): string[] {
509
+ try {
510
+ const parsed = JSON.parse(raw) as unknown
511
+ const list = Array.isArray(parsed)
512
+ ? parsed
513
+ : parsed && typeof parsed === 'object'
514
+ ? (parsed as { composerIds?: unknown; ids?: unknown }).composerIds
515
+ ?? (parsed as { ids?: unknown }).ids
516
+ : null
517
+ if (!Array.isArray(list)) return []
518
+ return list.map(value => String(value ?? '').trim().toLowerCase()).filter(Boolean)
519
+ } catch {
520
+ return []
521
+ }
522
+ }
523
+
524
+ export async function loadCursorPinnedIds(workspaceStorage: string): Promise<Set<string>> {
525
+ const pinned = new Set<string>()
526
+ if (!workspaceStorage) return pinned
527
+ for (const folder of await dirents(workspaceStorage)) {
528
+ const dbPath = join(workspaceStorage, folder, 'state.vscdb')
529
+ const st = await fileStat(dbPath)
530
+ if (!st?.isFile) continue
531
+ try {
532
+ const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
533
+ '-readonly',
534
+ dbPath,
535
+ "SELECT value FROM ItemTable WHERE key = 'cursor/pinnedComposers' LIMIT 1",
536
+ ], { timeout: 2000, maxBuffer: 256 * 1024 })
537
+ for (const id of parsePinnedComposerList(stdout.trim())) pinned.add(id)
538
+ } catch {
539
+ continue
540
+ }
541
+ }
542
+ return pinned
543
+ }
544
+
545
+ export async function findClaudeDesktopFile(sessionsRoot: string, uuid: string): Promise<string | null> {
546
+ if (!sessionsRoot || !uuid) return null
547
+ const name = `local_${uuid}.json`
548
+ for (const account of await dirents(sessionsRoot)) {
549
+ const accountDir = join(sessionsRoot, account)
550
+ const accountSt = await fileStat(accountDir)
551
+ if (!accountSt || accountSt.isFile) continue
552
+ for (const workspace of await dirents(accountDir)) {
553
+ const file = join(accountDir, workspace, name)
554
+ const st = await fileStat(file)
555
+ if (st?.isFile) return file
556
+ }
557
+ }
558
+ return null
559
+ }
560
+
561
+ export function preferCursorCopy(
562
+ next: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
563
+ existing: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
564
+ ): typeof next {
565
+ if (next.project === 'empty-window' && existing.project !== 'empty-window') return existing
566
+ if (existing.project === 'empty-window' && next.project !== 'empty-window') return next
567
+ return next.mtimeMs >= existing.mtimeMs ? next : existing
568
+ }
569
+
570
+ export async function loadCursorComposerNames(dbPath: string): Promise<Map<string, string>> {
571
+ const names = new Map<string, string>()
572
+ if (!dbPath) return names
573
+ const st = await fileStat(dbPath)
574
+ if (!st?.isFile) return names
575
+ try {
576
+ const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
577
+ '-readonly',
578
+ '-json',
579
+ dbPath,
580
+ "SELECT composerId AS id, json_extract(value, '$.name') AS name FROM composerHeaders WHERE json_extract(value, '$.name') IS NOT NULL AND json_extract(value, '$.name') != ''",
581
+ ], { timeout: 4000, maxBuffer: 8 * 1024 * 1024 })
582
+ const rows = JSON.parse(stdout || '[]') as Array<{ id?: string; composerId?: string; name?: string }>
583
+ for (const row of rows) {
584
+ const id = String(row.id ?? row.composerId ?? '').trim()
585
+ const name = String(row.name ?? '').trim()
586
+ if (id && name) names.set(id, name.slice(0, 120))
587
+ }
588
+ } catch {
589
+ return names
590
+ }
591
+ return names
592
+ }
593
+
594
+ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: string; title: string; subagent: boolean; created: string } | null> {
595
+ const text = await readWindow(path, false)
596
+ let id = ''
597
+ let cwd = ''
598
+ let title = ''
599
+ let created = ''
600
+ let subagent = false
601
+ for (const line of text.split('\n')) {
602
+ const obj = parseJsonLine(line)
603
+ if (!obj) continue
604
+ if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
605
+ const payload = obj.payload as Record<string, unknown>
606
+ id = String(payload.id ?? payload.session_id ?? id)
607
+ cwd = String(payload.cwd ?? cwd)
608
+ subagent = payload.thread_source === 'subagent'
609
+ const nick = String(payload.agent_nickname ?? '').trim()
610
+ if (nick && !title) title = nick
611
+ const stamp = String(payload.timestamp ?? obj.timestamp ?? '').trim()
612
+ if (stamp && !created) created = stamp
613
+ }
614
+ if (!title && obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
615
+ const payload = obj.payload as Record<string, unknown>
616
+ if (payload.type === 'message' && payload.role === 'user') {
617
+ const textBody = payloadText(payload)
618
+ if (textBody && !isWrapperPrompt(textBody)) title = firstLineTitle(textBody)
619
+ }
620
+ }
621
+ if (id && title) break
622
+ }
623
+ return { id, cwd, title, subagent, created }
624
+ }
625
+
626
+ async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
627
+ const text = await readWindow(path, true)
628
+ let lastUser: string | null = null
629
+ for (const line of text.split('\n')) {
630
+ const obj = parseJsonLine(line)
631
+ if (!obj || obj.role !== 'user') continue
632
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
633
+ const body = message ? payloadText(message) : null
634
+ if (!body) continue
635
+ const title = cursorUserTitle(body, true)
636
+ if (title) lastUser = title
637
+ }
638
+ return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
639
+ }
640
+
641
+ async function firstCursorUserTitle(path: string): Promise<string | null> {
642
+ const text = await readWindow(path, false)
643
+ let fallback: string | null = null
644
+ for (const line of text.split('\n')) {
645
+ const obj = parseJsonLine(line)
646
+ if (!obj || obj.role !== 'user') continue
647
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
648
+ const body = message ? payloadText(message) : null
649
+ if (!body) continue
650
+ const query = cursorUserTitle(body, true)
651
+ if (query) return query
652
+ if (!fallback) fallback = cursorUserTitle(body, false)
653
+ }
654
+ return fallback
655
+ }
656
+
657
+ export async function listCodexJsonlFiles(sessionsRoot: string): Promise<string[]> {
658
+ const files: string[] = []
659
+ for (const year of await dirents(sessionsRoot)) {
660
+ const yearDir = join(sessionsRoot, year)
661
+ const yearSt = await fileStat(yearDir)
662
+ if (!yearSt || yearSt.isFile) continue
663
+ for (const month of await dirents(yearDir)) {
664
+ const monthDir = join(yearDir, month)
665
+ const monthSt = await fileStat(monthDir)
666
+ if (!monthSt || monthSt.isFile) continue
667
+ for (const day of await dirents(monthDir)) {
668
+ const dayDir = join(monthDir, day)
669
+ const daySt = await fileStat(dayDir)
670
+ if (!daySt || daySt.isFile) continue
671
+ for (const name of await dirents(dayDir)) {
672
+ if (name.endsWith('.jsonl')) files.push(join(dayDir, name))
673
+ }
674
+ }
675
+ }
676
+ }
677
+ return files
678
+ }
679
+
680
+ export async function listClaudeSessions(
681
+ projectsRoot: string,
682
+ now: Date,
683
+ liveIds: Set<string>,
684
+ cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
685
+ starredIds: ReadonlySet<string> = new Set(),
686
+ desktopSessionsRoot = '',
687
+ ): Promise<AgentSessionRow[]> {
688
+ const seen = new Set<string>()
689
+ const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
690
+ const recentCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
691
+ for (const folder of await dirents(projectsRoot)) {
692
+ const dir = join(projectsRoot, folder)
693
+ const dirSt = await fileStat(dir)
694
+ if (!dirSt || dirSt.isFile) continue
695
+ for (const name of await dirents(dir)) {
696
+ if (!CLAUDE_UUID_JSONL.test(name)) continue
697
+ const native = name.slice(0, -6)
698
+ const shortId = native.slice(0, 8)
699
+ if (liveIds.has(shortId) || liveIds.has(native)) continue
700
+ const file = join(dir, name)
701
+ const st = await fileStat(file)
702
+ if (!st?.isFile) continue
703
+ const pinned = starredIds.has(native.toLowerCase())
704
+ const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
705
+ if (!pinned && !fresh) continue
706
+ const candidate = {
707
+ file,
708
+ native,
709
+ project: workspaceLabel(folder),
710
+ mtimeMs: st.mtimeMs,
711
+ birthtimeMs: st.birthtimeMs,
712
+ }
713
+ seen.add(native.toLowerCase())
714
+ if (pinned) pinnedCandidates.push(candidate)
715
+ else recentCandidates.push(candidate)
716
+ }
717
+ }
718
+ for (const starred of starredIds) {
719
+ if (seen.has(starred) || liveIds.has(starred) || liveIds.has(starred.slice(0, 8))) continue
720
+ const desktop = await findClaudeDesktopFile(desktopSessionsRoot, starred)
721
+ if (!desktop) continue
722
+ const st = await fileStat(desktop)
723
+ if (!st?.isFile) continue
724
+ pinnedCandidates.push({
725
+ file: '',
726
+ native: starred,
727
+ project: '',
728
+ mtimeMs: st.mtimeMs,
729
+ birthtimeMs: st.birthtimeMs,
730
+ desktop,
731
+ })
732
+ seen.add(starred)
733
+ }
734
+ pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
735
+ recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
736
+
737
+ const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
738
+ const rows: AgentSessionRow[] = []
739
+ for (const candidate of candidates) {
740
+ if (rows.length >= Math.max(0, limit)) break
741
+ let title = ''
742
+ let project = candidate.project
743
+ let firstPrompt = ''
744
+ let latestAssistant = ''
745
+ if (candidate.file) {
746
+ const peek = await peekClaudeDiscussion(candidate.file)
747
+ firstPrompt = await firstClaudeUserTitle(candidate.file) ?? ''
748
+ title = peek.customTitle ?? firstPrompt
749
+ latestAssistant = peek.latestAssistant
750
+ }
751
+ if ((!title || !project) && (candidate.desktop || desktopSessionsRoot)) {
752
+ const desktop = candidate.desktop || await findClaudeDesktopFile(desktopSessionsRoot, candidate.native)
753
+ if (desktop) {
754
+ const head = peekClaudeDesktopHead(await readWindow(desktop, false))
755
+ if (!title && head.title) title = head.title
756
+ if (!project && head.cwd) project = workspaceLabel(head.cwd)
757
+ }
758
+ }
759
+ title = title || 'Claude session'
760
+ if (isKeepWarmSessionTitle(title)) continue
761
+ rows.push(row({
762
+ session_id: candidate.native,
763
+ provider: 'claude',
764
+ display_label: title,
765
+ project,
766
+ modified: isoFromMtime(candidate.mtimeMs),
767
+ created: isoFromMtime(candidate.birthtimeMs),
768
+ alive: false,
769
+ pinned: starredIds.has(candidate.native.toLowerCase()),
770
+ ...discussionFields(title, firstPrompt, latestAssistant),
771
+ }))
772
+ }
773
+ return rows
774
+ }
775
+
776
+ return [
777
+ ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
778
+ ...await toRows(recentCandidates, Math.max(0, cap)),
779
+ ]
780
+ }
781
+
782
+ export async function listCodexSessions(
783
+ sessionsRoot: string,
784
+ now: Date,
785
+ cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
786
+ ): Promise<AgentSessionRow[]> {
787
+ const names = await loadCodexThreadNames(sessionsRoot)
788
+ const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
789
+ const pinnedCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
790
+ const recentCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
791
+ for (const file of await listCodexJsonlFiles(sessionsRoot)) {
792
+ const st = await fileStat(file)
793
+ if (!st?.isFile) continue
794
+ const name = file.split('/').pop() || file
795
+ const fileId = idFromCodexFilename(name)
796
+ const pinned = fileId ? pinnedIds.has(fileId) : false
797
+ const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
798
+ if (!pinned && !fresh) continue
799
+ const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
800
+ if (pinned) pinnedCandidates.push(candidate)
801
+ else recentCandidates.push(candidate)
802
+ }
803
+ pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
804
+ recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
805
+
806
+ const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
807
+ const rows: AgentSessionRow[] = []
808
+ for (const candidate of candidates) {
809
+ if (rows.length >= Math.max(0, limit)) break
810
+ const meta = await peekCodexMeta(candidate.file)
811
+ if (!meta || meta.subagent) continue
812
+ const native = meta.id || candidate.name.slice(0, -6)
813
+ const title = names.get(native) || meta.title || 'Codex session'
814
+ if (isKeepWarmSessionTitle(title)) continue
815
+ const created = meta.created || createdFromCodexFilename(candidate.name) || isoFromMtime(candidate.birthtimeMs)
816
+ const latestAssistant = latestAssistantFromWindow(await readWindow(candidate.file, true))
817
+ rows.push(row({
818
+ session_id: native,
819
+ provider: 'codex',
820
+ display_label: title,
821
+ project: workspaceLabel(meta.cwd),
822
+ modified: isoFromMtime(candidate.mtimeMs),
823
+ created,
824
+ alive: false,
825
+ pinned: pinnedIds.has(native.toLowerCase()),
826
+ ...discussionFields(title, meta.title, latestAssistant),
827
+ }))
828
+ }
829
+ return rows
830
+ }
831
+
832
+ const pinnedRows = await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0))
833
+ const recentRows = await toRows(recentCandidates, Math.max(0, cap))
834
+ return [...pinnedRows, ...recentRows]
835
+ }
836
+
837
+ export async function listCursorSessions(
838
+ projectsRoot: string,
839
+ now: Date,
840
+ cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
841
+ composerDb = '',
842
+ pinnedIds: ReadonlySet<string> = new Set(),
843
+ ): Promise<AgentSessionRow[]> {
844
+ const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
845
+ const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
846
+ for (const folder of await dirents(projectsRoot)) {
847
+ if (folder.includes('var-folders') || folder.includes('private-var')) continue
848
+ const transcripts = join(projectsRoot, folder, 'agent-transcripts')
849
+ for (const sessionDir of await dirents(transcripts)) {
850
+ if (sessionDir === 'subagents') continue
851
+ const pinned = pinnedIds.has(sessionDir.toLowerCase())
852
+ if (folder === 'empty-window' && !pinned) continue
853
+ const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
854
+ const st = await fileStat(file)
855
+ if (!st?.isFile) continue
856
+ if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
857
+ const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
858
+ if (!pinned && !fresh) continue
859
+ const next = {
860
+ file,
861
+ sessionDir,
862
+ project: workspaceLabel(folder),
863
+ mtimeMs: st.mtimeMs,
864
+ birthtimeMs: st.birthtimeMs,
865
+ }
866
+ const existing = byId.get(sessionDir)
867
+ byId.set(sessionDir, existing ? preferCursorCopy(next, existing) : next)
868
+ }
869
+ }
870
+ const pinnedCandidates = [...byId.values()].filter(c => pinnedIds.has(c.sessionDir.toLowerCase()))
871
+ const recentCandidates = [...byId.values()].filter(c => !pinnedIds.has(c.sessionDir.toLowerCase()))
872
+ pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
873
+ recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
874
+
875
+ const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
876
+ const rows: AgentSessionRow[] = []
877
+ for (const candidate of candidates) {
878
+ if (rows.length >= Math.max(0, limit)) break
879
+ const peek = await peekCursorDiscussion(candidate.file)
880
+ const firstPrompt = await firstCursorUserTitle(candidate.file) ?? peek.lastUser ?? ''
881
+ const title = composerNames.get(candidate.sessionDir)
882
+ ?? peek.lastUser
883
+ ?? firstPrompt
884
+ ?? 'Cursor session'
885
+ if (isKeepWarmSessionTitle(title)) continue
886
+ const alive = now.getTime() - candidate.mtimeMs < 180_000
887
+ rows.push(row({
888
+ session_id: candidate.sessionDir,
889
+ provider: 'cursor',
890
+ display_label: title,
891
+ project: candidate.project,
892
+ modified: isoFromMtime(candidate.mtimeMs),
893
+ created: isoFromMtime(candidate.birthtimeMs),
894
+ alive,
895
+ state: alive ? 'running' : 'recent',
896
+ pinned: pinnedIds.has(candidate.sessionDir.toLowerCase()),
897
+ ...discussionFields(title, firstPrompt, peek.latestAssistant),
898
+ }))
899
+ }
900
+ return rows
901
+ }
902
+
903
+ return [
904
+ ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
905
+ ...await toRows(recentCandidates, Math.max(0, cap)),
906
+ ]
907
+ }
908
+
909
+ async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots): Promise<AgentSessionRow> {
910
+ if (row.provider !== 'claude') return row
911
+ const found = await findAgentSessionFile('claude', row.session_id, roots)
912
+ if (!found) return row
913
+ const peek = await peekClaudeDiscussion(found)
914
+ const firstPrompt = await firstClaudeUserTitle(found)
915
+ const title = peek.customTitle ?? firstPrompt ?? row.display_label
916
+ const fullId = found.split('/').pop()?.replace(/\.jsonl$/i, '') || row.session_id
917
+ return {
918
+ ...row,
919
+ session_id: fullId,
920
+ display_label: title || row.display_label || 'Claude session',
921
+ ...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
922
+ }
923
+ }
924
+
925
+ function sessionKey(row: AgentSessionRow): string {
926
+ return `${row.provider}:${row.session_id.trim().toLowerCase()}`
927
+ }
928
+
929
+ function preferSession(next: AgentSessionRow, current: AgentSessionRow): AgentSessionRow {
930
+ if (next.alive !== current.alive) return next.alive ? next : current
931
+ if (next.pinned !== current.pinned) return next.pinned ? next : current
932
+ if (next.project === 'empty-window' && current.project !== 'empty-window') return current
933
+ if (current.project === 'empty-window' && next.project !== 'empty-window') return next
934
+ return (next.modified || '') > (current.modified || '') ? next : current
935
+ }
936
+
937
+ function dedupeSessions(rows: AgentSessionRow[]): AgentSessionRow[] {
938
+ const byKey = new Map<string, AgentSessionRow>()
939
+ for (const row of rows) {
940
+ const key = sessionKey(row)
941
+ const existing = byKey.get(key)
942
+ byKey.set(key, existing ? preferSession(row, existing) : row)
943
+ }
944
+ return [...byKey.values()]
945
+ }
946
+
947
+ export async function listAgentSessions(
948
+ roots: AgentSessionRoots,
949
+ now = new Date(),
950
+ live: AgentSessionRow[] = [],
951
+ limit = AGENT_SESSION_LIST_LIMIT,
952
+ sort: AgentSessionSort = 'updated',
953
+ ): Promise<AgentSessionRow[]> {
954
+ const starredIds = await loadClaudeStarredIds(roots.claudeDesktopConfig)
955
+ const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
956
+ const enrichedLive = (await Promise.all(live.map(row => enrichLiveClaude(row, roots))))
957
+ .filter(entry => !isKeepWarmSessionTitle(entry.display_label))
958
+ .map(entry => {
959
+ if (entry.provider === 'claude' && starredIds.has(normalizeClaudeSessionId(entry.session_id))) {
960
+ return { ...entry, pinned: true }
961
+ }
962
+ if (entry.provider === 'cursor' && cursorPinned.has(entry.session_id.toLowerCase())) {
963
+ return { ...entry, pinned: true }
964
+ }
965
+ return entry
966
+ })
967
+ const liveIds = new Set(enrichedLive.flatMap(row => [row.session_id, row.session_id.slice(0, 8)]))
968
+ const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
969
+ let rows = dedupeSessions([
970
+ ...enrichedLive,
971
+ ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions),
972
+ ...await listCodexSessions(roots.codexSessions, now, cap),
973
+ ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned),
974
+ ])
975
+ if (sort === 'opened') {
976
+ rows = rows.filter(entry => {
977
+ if (entry.alive) return true
978
+ const created = Date.parse(entry.created)
979
+ return Number.isFinite(created) && now.getTime() - created <= AGENT_SESSION_MAX_AGE_MS
980
+ })
981
+ // Control Activity → Opened: created desc. Pins stay in the payload
982
+ // when they were opened in-window; they do not cluster at the top.
983
+ rows.sort((a, b) => (b.created || '').localeCompare(a.created || ''))
984
+ } else {
985
+ // Control Activity → Updated: newest write first. Stale pins still
986
+ // appear (any age) but sink by mtime. Glasses has no Pinned clock, so
987
+ // pin-boosting here made the lens show July stars instead of today.
988
+ rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
989
+ }
990
+ return rows.slice(0, limit)
991
+ }
992
+
993
+ export async function findAgentSessionFile(
994
+ provider: AgentProvider,
995
+ sessionId: string,
996
+ roots: AgentSessionRoots,
997
+ now = new Date(),
998
+ ): Promise<string | null> {
999
+ if (!isSafeSessionId(sessionId)) return null
1000
+ const needle = sessionId.trim().toLowerCase()
1001
+ if (provider === 'claude') {
1002
+ for (const folder of await dirents(roots.claudeProjects)) {
1003
+ const dir = join(roots.claudeProjects, folder)
1004
+ for (const name of await dirents(dir)) {
1005
+ if (!name.endsWith('.jsonl')) continue
1006
+ const id = name.slice(0, -6).toLowerCase()
1007
+ if (id === needle || id.startsWith(needle)) return join(dir, name)
1008
+ }
1009
+ }
1010
+ return null
1011
+ }
1012
+ if (provider === 'codex') {
1013
+ for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
1014
+ const name = file.split('/').pop()?.toLowerCase() ?? ''
1015
+ if (name.includes(needle)) return file
1016
+ }
1017
+ return null
1018
+ }
1019
+ let best: { file: string; mtimeMs: number } | null = null
1020
+ for (const folder of await dirents(roots.cursorProjects)) {
1021
+ if (isSkippedCursorFolder(folder)) continue
1022
+ const file = join(roots.cursorProjects, folder, 'agent-transcripts', needle, `${needle}.jsonl`)
1023
+ const st = await fileStat(file)
1024
+ if (!st?.isFile) continue
1025
+ if (!best || st.mtimeMs >= best.mtimeMs) best = { file, mtimeMs: st.mtimeMs }
1026
+ }
1027
+ return best?.file ?? null
1028
+ }
1029
+
1030
+ /** Bytes read from the START of an oversized transcript — enough for the opening ask
1031
+ * and the session_meta record that carries cwd/branch. */
1032
+ export const PARTIAL_HEAD_BYTES = 256 * 1024
1033
+ /** Bytes read from the END. Larger than the head because the recent turns are what a
1034
+ * follow-up continues from. */
1035
+ export const PARTIAL_TAIL_BYTES = 768 * 1024
1036
+
1037
+ /**
1038
+ * Lines of a transcript, reading the WHOLE file when it fits and head+tail when it
1039
+ * does not.
1040
+ *
1041
+ * WHY. `GET /api/agent-sessions/:provider/:id` used to answer 413 "Session too large
1042
+ * to open" for anything over 32 MiB, so the biggest sessions — the ones most worth
1043
+ * summarizing before a follow-up — returned nothing at all. A 67 MB transcript is
1044
+ * real; today's own session is one. Refusing was never necessary: the detail page
1045
+ * needs the opening turns, the recent turns, and the counts, and two bounded windows
1046
+ * carry all three without loading 67 MB into memory.
1047
+ *
1048
+ * The first line of the tail window is almost always a fragment of a JSON record.
1049
+ * `parseJsonLine` returns null for it and the caller's loop skips it, which is why
1050
+ * this can slice at an arbitrary byte offset and stay correct.
1051
+ */
1052
+ export async function* agentSessionLines(
1053
+ path: string,
1054
+ size: number,
1055
+ maxBytes: number,
1056
+ // Window sizes are injectable so a test can prove ELISION without writing a
1057
+ // multi-megabyte fixture. With the production 256 KiB + 768 KiB defaults, any
1058
+ // fixture small enough to build quickly is fully covered by the two windows — so
1059
+ // the test would read the whole file, see every turn, and pass identically with
1060
+ // windowing removed. It did exactly that until a mutation caught it.
1061
+ headBytes = PARTIAL_HEAD_BYTES,
1062
+ tailBytes = PARTIAL_TAIL_BYTES,
1063
+ ): AsyncGenerator<string> {
1064
+ if (size <= maxBytes) {
1065
+ yield* createInterface({ input: createReadStream(path), crlfDelay: Infinity })
1066
+ return
1067
+ }
1068
+ yield* createInterface({
1069
+ input: createReadStream(path, { start: 0, end: headBytes - 1 }),
1070
+ crlfDelay: Infinity,
1071
+ })
1072
+ yield* createInterface({
1073
+ input: createReadStream(path, { start: Math.max(headBytes, size - tailBytes) }),
1074
+ crlfDelay: Infinity,
1075
+ })
1076
+ }
1077
+
1078
+ export interface AgentSessionDetail {
1079
+ session_id: string
1080
+ provider: AgentProvider
1081
+ display_label: string
1082
+ project: string
1083
+ git_branch: string
1084
+ first_prompt: string
1085
+ discussion_summary: string
1086
+ /** Deep, paginated body text for the detail page — up to 2000 chars. The list row
1087
+ * keeps `discussion_summary` at 180. Older clients ignore this field. */
1088
+ discussion_digest: string
1089
+ user_message_count: number
1090
+ assistant_message_count: number
1091
+ omitted_tools: number
1092
+ file_size_bytes: number
1093
+ /** True when the transcript exceeded the read ceiling and only head+tail were
1094
+ * parsed. The message counts are then counts of what was READ, not of the
1095
+ * session — the client must not present them as exact. */
1096
+ truncated: boolean
1097
+ }
1098
+
1099
+ export async function parseAgentSession(
1100
+ provider: AgentProvider,
1101
+ path: string,
1102
+ opts: { maxBytes?: number; headBytes?: number; tailBytes?: number } = {},
1103
+ ): Promise<AgentSessionDetail> {
1104
+ const st = await stat(path)
1105
+ const maxBytes = opts.maxBytes ?? AGENT_SESSION_MAX_FILE_BYTES
1106
+ const truncated = st.size > maxBytes
1107
+ let title = ''
1108
+ let project = ''
1109
+ let gitBranch = ''
1110
+ let sessionId = path.split('/').pop()?.replace(/\.jsonl$/, '') || ''
1111
+ let firstPrompt = ''
1112
+ let latestAssistant = ''
1113
+ let userCount = 0
1114
+ // Bounded sample for the deep digest: the opening turns plus a recent window.
1115
+ // The composer only ever renders head + as many recent as fit 2000 chars, so
1116
+ // retaining the whole conversation would be memory held for nothing — a 900-turn
1117
+ // session is real. `userCount` still carries the TRUE total so the elision line
1118
+ // reports what was actually dropped rather than what this buffer happens to hold.
1119
+ const digestHead: string[] = []
1120
+ const digestRecent: string[] = []
1121
+ const collectTurn = (text: string): void => {
1122
+ // Reuse the wrapper filter the rest of this module already trusts. Without it the
1123
+ // opening turns of a slash-command session render as
1124
+ // "<command-message>cos-glasses</command-message>…" — scaffolding, not the ask,
1125
+ // and it lands in the two most valuable slots in the digest.
1126
+ const raw = (text ?? '').trim()
1127
+ if (!raw || isWrapperPrompt(raw)) return
1128
+ // Same tag-stripping firstLineTitle does, so an inline <user_query> wrapper does
1129
+ // not leak angle brackets into the body.
1130
+ const unwrapped = (() => {
1131
+ const start = raw.indexOf('<user_query>')
1132
+ const end = raw.indexOf('</user_query>')
1133
+ const inner = start >= 0 && end > start ? raw.slice(start + '<user_query>'.length, end) : raw
1134
+ return inner.replace(/<[^>]+>/g, ' ')
1135
+ })()
1136
+ const line = unwrapped.replace(/\s+/g, ' ').trim()
1137
+ if (!line) return
1138
+ if (digestHead.length < DIGEST_HEAD_TURNS) { digestHead.push(line.slice(0, DIGEST_TURN_MAX)); return }
1139
+ digestRecent.push(line.slice(0, DIGEST_TURN_MAX))
1140
+ if (digestRecent.length > DIGEST_RECENT_WINDOW) digestRecent.shift()
1141
+ }
1142
+ let assistantCount = 0
1143
+ let omittedTools = 0
1144
+
1145
+ for await (const line of agentSessionLines(path, st.size, maxBytes, opts.headBytes, opts.tailBytes)) {
1146
+ const obj = parseJsonLine(line)
1147
+ if (!obj) continue
1148
+ if (provider === 'claude') {
1149
+ if (typeof obj.sessionId === 'string' && obj.sessionId) sessionId = obj.sessionId
1150
+ if (obj.type === 'custom-title' && typeof obj.customTitle === 'string' && obj.customTitle.trim()) {
1151
+ title = obj.customTitle.trim()
1152
+ continue
1153
+ }
1154
+ if (obj.isSidechain === true) continue
1155
+ if (!project && typeof obj.cwd === 'string') project = workspaceLabel(obj.cwd)
1156
+ if (!gitBranch && typeof obj.gitBranch === 'string') gitBranch = obj.gitBranch
1157
+ if (obj.type === 'user' && obj.toolUseResult) { omittedTools += 1; continue }
1158
+ if (obj.type !== 'user' && obj.type !== 'assistant') continue
1159
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
1160
+ const text = message ? payloadText(message) : null
1161
+ if (!text) {
1162
+ if (obj.type === 'assistant') omittedTools += 1
1163
+ continue
1164
+ }
1165
+ if (obj.type === 'user') {
1166
+ userCount += 1
1167
+ collectTurn(text)
1168
+ if (!firstPrompt) firstPrompt = firstLineTitle(text)
1169
+ if (!title) title = firstLineTitle(text)
1170
+ } else {
1171
+ assistantCount += 1
1172
+ const snippet = proseSnippet(text)
1173
+ if (snippet) latestAssistant = snippet
1174
+ }
1175
+ } else if (provider === 'codex') {
1176
+ if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
1177
+ const payload = obj.payload as Record<string, unknown>
1178
+ if (typeof payload.cwd === 'string') project = workspaceLabel(payload.cwd)
1179
+ if (typeof payload.id === 'string' && payload.id) sessionId = payload.id
1180
+ const git = payload.git && typeof payload.git === 'object' ? payload.git as Record<string, unknown> : null
1181
+ if (git && typeof git.branch === 'string') gitBranch = git.branch
1182
+ continue
1183
+ }
1184
+ if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
1185
+ const payload = obj.payload as Record<string, unknown>
1186
+ const kind = String(payload.type ?? '')
1187
+ if (kind === 'custom_tool_call' || kind === 'custom_tool_call_output' || kind === 'function_call') {
1188
+ omittedTools += 1
1189
+ continue
1190
+ }
1191
+ if (kind !== 'message' || payload.role === 'developer') continue
1192
+ const text = payloadText(payload)
1193
+ if (!text || isWrapperPrompt(text)) continue
1194
+ if (payload.role === 'assistant') {
1195
+ assistantCount += 1
1196
+ const snippet = proseSnippet(text)
1197
+ if (snippet) latestAssistant = snippet
1198
+ } else {
1199
+ userCount += 1
1200
+ collectTurn(text)
1201
+ if (!firstPrompt) firstPrompt = firstLineTitle(text)
1202
+ if (!title) title = firstLineTitle(text)
1203
+ }
1204
+ } else {
1205
+ if (obj.role !== 'user' && obj.role !== 'assistant') continue
1206
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
1207
+ if (Array.isArray(message?.content) && (message.content as Array<{ type?: string }>).some(b => b?.type === 'tool_use')) {
1208
+ omittedTools += 1
1209
+ }
1210
+ const text = message ? payloadText(message) : null
1211
+ if (!text) continue
1212
+ if (obj.role === 'user') {
1213
+ userCount += 1
1214
+ const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
1215
+ collectTurn(query ?? text)
1216
+ if (query) {
1217
+ title = query
1218
+ if (!firstPrompt) firstPrompt = query
1219
+ }
1220
+ } else {
1221
+ assistantCount += 1
1222
+ const snippet = proseSnippet(text)
1223
+ if (snippet) latestAssistant = snippet
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ if (provider === 'cursor' && !project) {
1229
+ const parts = path.split('/')
1230
+ const encoded = parts[parts.indexOf('agent-transcripts') - 1] || ''
1231
+ project = workspaceLabel(encoded)
1232
+ }
1233
+
1234
+ const display = title || (provider === 'codex' ? 'Codex session' : provider === 'cursor' ? 'Cursor session' : 'Claude session')
1235
+ return {
1236
+ session_id: sessionId,
1237
+ provider,
1238
+ display_label: display,
1239
+ project,
1240
+ git_branch: gitBranch,
1241
+ first_prompt: firstPrompt || title,
1242
+ discussion_summary: composeDiscussionSummary({
1243
+ title: display,
1244
+ firstPrompt: firstPrompt || title,
1245
+ latestAssistant,
1246
+ }),
1247
+ discussion_digest: composeDiscussionDigest({
1248
+ userTurns: [...digestHead, ...digestRecent],
1249
+ latestAssistant,
1250
+ // On a truncated read `userCount` counts only the sampled windows, so passing
1251
+ // it as the total would print a confidently WRONG "… 12 earlier turns …" for a
1252
+ // session with hundreds. The digest drops the number instead of inventing one.
1253
+ totalTurns: truncated ? undefined : userCount,
1254
+ truncated,
1255
+ }),
1256
+ user_message_count: userCount,
1257
+ assistant_message_count: assistantCount,
1258
+ omitted_tools: omittedTools,
1259
+ file_size_bytes: st.size,
1260
+ truncated,
1261
+ }
1262
+ }