@gotcos/glasses-server 6.94.2 → 6.94.4

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,715 @@
1
+ /**
2
+ * The sessions meaning search can ask about (Control 0.5.363, the "find" route): which sessions exist, what each is
3
+ * called, what its first typed prompt says, and when it last did something.
4
+ *
5
+ * WHY THIS IS NOT THE LIST OR THE SESSIONS-TAB COLLECTOR (validated 2026-11-01, all measured on the owner's Mac).
6
+ * - The list keeps the first LINE of the first prompt, cut to 80 characters. 431 of 467 first prompts are longer, and
7
+ * meaning search on titles alone found the right session 15 times in 20 against 19 with the first 150 characters.
8
+ * - The list is capped at 7 days of file writes and 80 rows. mtime also lies: 234 of 854 Claude transcripts were touched
9
+ * 7 or more days after their last record (a relaunch appends bookkeeping). This collector orders by the transcript's own
10
+ * last record, with its own memo, because the list's memo holds 480 entries and thrashes at this size.
11
+ * - Machine sessions fill the newest 254 slots: raw, they reach 8 days; without them, about 33. Dropped here: no typed
12
+ * first prompt, a title seen 5 or more times, a Claude Desktop scheduled task, a keep-warm title, an archived Desktop
13
+ * session, and anything that ran on a key.
14
+ * - Codex compresses finished rollouts (1,080 of 1,099 on that Mac are `.jsonl.zst`). `listCodexJsonlFiles` only takes
15
+ * `.jsonl`, and every reader of it (detail, stream, completion, tails) is zstd-blind, so it is left alone. This module
16
+ * has its own head-only lister and reader. A compressed row carries `compressed: true` and no file path ever leaves.
17
+ *
18
+ * ZSTD IS OPTIONAL. `createZstdDecompress` exists from Node 22.15 and 23.8; `engines` is `>=20.11`. A NAMED import of it
19
+ * fails when the module loads on an older Node and the server would not boot. The default import plus a `typeof` check at
20
+ * the call keeps boot safe; without the function a compressed file is skipped and COUNTED (`skippedNoDecoder`), never
21
+ * guessed at and never silent.
22
+ *
23
+ * Nothing here reads more than the first 16 MB of a file, calls a model, or logs a title or a prompt.
24
+ */
25
+ import { createReadStream } from 'node:fs'
26
+ import { execFile } from 'node:child_process'
27
+ import { open } from 'node:fs/promises'
28
+ import { promisify } from 'node:util'
29
+ import { join } from 'node:path'
30
+ import { StringDecoder } from 'node:string_decoder'
31
+ import type { Transform } from 'node:stream'
32
+ import zlib from 'node:zlib'
33
+ import {
34
+ AGENT_SESSION_MAX_FILE_BYTES,
35
+ CLAUDE_UUID_JSONL,
36
+ agentSessionRoots,
37
+ codexUserText,
38
+ createdFromCodexFilename,
39
+ dirents,
40
+ fileStat,
41
+ firstLineTitle,
42
+ isKeepWarmSessionTitle,
43
+ isSkippedCursorFolder,
44
+ isWrapperPrompt,
45
+ isoFromMtime,
46
+ lastCustomTitle,
47
+ loadClaudeDesktopAliases,
48
+ loadCodexThreadNames,
49
+ parseJsonLine,
50
+ payloadText,
51
+ preferCursorCopy,
52
+ workspaceLabel,
53
+ type AgentProvider,
54
+ type AgentSessionRoots,
55
+ } from './agent-session-store.js'
56
+ import { ACTIVITY_READ_CONCURRENCY, ACTIVITY_TAIL_BYTES, ACTIVITY_TAIL_MAX_BYTES, activityFromTail, mapWithConcurrency } from './agent-session-activity.js'
57
+
58
+ /**
59
+ * The most that is read from the top of a file (decompressed) to find its first typed prompt, stopping at the first one. A typed
60
+ * prompt is rarely deep: 256 KB found 135 of 172 Codex first prompts, 1 MB found 151, 16 MB all of them. Only a file that has none
61
+ * near the top reads further, so the cost lands on long machine runs, once per file (the memo keeps the answer).
62
+ */
63
+ export const FIND_HEAD_BYTES = 16 * 1024 * 1024
64
+ const FIND_CHUNK_BYTES = 64 * 1024
65
+ /** A title seen this many times in the pool is a machine run (a routine, a proxy, a probe), not a conversation. */
66
+ export const FIND_REPEAT_TITLE_LIMIT = 5
67
+ /** The typed first prompt kept per session, unwrapped and on one line. Redaction reads all of it; the model gets 150. */
68
+ export const FIND_PROMPT_KEEP_CHARS = 2_000
69
+ /**
70
+ * A prompt is unwrapped from at most this much of its start, so an unclosed tag cannot make the scan quadratic (the wrapper
71
+ * pattern rescans to the end from every opener). Likewise the list's firstLineTitle, whose `<[^>]+>` is quadratic on a body of
72
+ * `a<b a<b ...` and blocked a server for 68 s on 800 KB: it only ever sees the first TITLE_SCAN_CHARS.
73
+ */
74
+ const UNWRAP_SCAN_CHARS = 16_000
75
+ const TITLE_SCAN_CHARS = 8_000
76
+ /** A line longer than this is skipped through to its newline: it is an attachment or a dump, never a typed prompt. */
77
+ const MAX_LINE_CHARS = 2 * 1024 * 1024
78
+ /** Files opened at most per walk. The newest-first stop below ends nearly every walk long before this. */
79
+ export const FIND_MAX_OPEN = 4_000
80
+ /** Files read at once, and files per batch before the stop rule is checked. */
81
+ const BATCH = 32
82
+ /** A Desktop record read whole to find its scheduled-task and archived flags. The largest seen is 0.7 MB. */
83
+ const DESKTOP_RECORD_MAX_BYTES = 8 * 1024 * 1024
84
+ export const FIND_MEMO_MAX = 8_000
85
+ /** What the memo may hold in all, by the characters it keeps (each entry is also flattened, see flat()). */
86
+ export const FIND_MEMO_MAX_CHARS = 16 * 1024 * 1024
87
+
88
+ const WRAP_TAGS = 'system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|local-command-stdout|local-command-stderr|command-name|command-message|command-args|task-notification|user-prompt-submit-hook|ide_opened_file|ide_selection|environment_context|attached_files|open_files|user_info|git_status|rules|agent_transcripts|image'
89
+ const WRAP_RE = new RegExp(`<(${WRAP_TAGS})(?:\\s[^>]*)?>[\\s\\S]*?</\\1>`, 'gi')
90
+
91
+ /**
92
+ * What the person typed, out of a user turn: the Cursor `<user_query>` body when there is one, the harness wrapper blocks
93
+ * out, whitespace on one line. The canary's rule (2026-11-01), which found the right session first in 19 of 20 searches.
94
+ */
95
+ export function unwrapPrompt(text: string): string {
96
+ let body = text.slice(0, UNWRAP_SCAN_CHARS)
97
+ const open = body.indexOf('<user_query>'), close = body.indexOf('</user_query>')
98
+ if (open >= 0 && close > open) body = body.slice(open + '<user_query>'.length, close)
99
+ return body.replace(WRAP_RE, ' ').replace(/\s+/g, ' ').trim()
100
+ }
101
+
102
+ export interface FindCandidate {
103
+ provider: AgentProvider
104
+ session_id: string
105
+ /** `provider:id` in lower case: one identity across the pool, the cache and the merge. */
106
+ key: string
107
+ /** As the list titles it (Desktop alias, then custom title, then the first line of the first prompt). Never sent as is. */
108
+ title: string
109
+ /** The list's first line of the first prompt, at most 80 characters. */
110
+ listFirstLine: string
111
+ /** The first typed prompt, unwrapped, on one line, at most FIND_PROMPT_KEEP_CHARS. Never sent as is. */
112
+ typed: string
113
+ project: string
114
+ /** The newest record's time (or the file's write time where records carry none), never later than the write time. */
115
+ clockMs: number
116
+ mtimeMs: number
117
+ birthtimeMs: number
118
+ created: string
119
+ /** A `.jsonl.zst` Codex rollout: no tail can be read, so it cannot be opened from COS, only from the Codex app. */
120
+ compressed: boolean
121
+ /** Where the transcript is. Server side only; no row carries it. */
122
+ file: string
123
+ }
124
+
125
+ export interface FindPool {
126
+ candidates: FindCandidate[]
127
+ /** Compressed files left out because this Node cannot decompress zstd. */
128
+ skippedNoDecoder: number
129
+ /** Compressed files left out because they could not be read (a damaged or half-written file). */
130
+ skippedUnreadable: number
131
+ /** Files whose heads were read this walk (memo hits are free and not counted). */
132
+ opened: number
133
+ considered: number
134
+ ms: number
135
+ }
136
+
137
+ type ZstdFactory = () => Transform | null
138
+
139
+ /** Resolved at the call, so a Node without it (and a test that removes it) both read as "absent". */
140
+ export function zstdDecompressor(): Transform | null {
141
+ const make = (zlib as { createZstdDecompress?: () => Transform }).createZstdDecompress
142
+ return typeof make === 'function' ? make.call(zlib) : null
143
+ }
144
+
145
+ /** `rollout-<stamp>-<uuid>.jsonl` or `.jsonl.zst`: the uuid, or null. Not `idFromCodexFilename`, which stays `.jsonl` only. */
146
+ export function rolloutFileId(name: string): string | null {
147
+ const base = rolloutBaseName(name)
148
+ if (!base) return null
149
+ const parts = base.split('-')
150
+ if (parts.length < 5) return null
151
+ const uuid = parts.slice(-5).join('-').toLowerCase()
152
+ return uuid.length >= 36 ? uuid : null
153
+ }
154
+ function rolloutBaseName(name: string): string | null {
155
+ if (name.endsWith('.jsonl.zst')) return name.slice(0, -'.jsonl.zst'.length)
156
+ if (name.endsWith('.jsonl')) return name.slice(0, -'.jsonl'.length)
157
+ return null
158
+ }
159
+
160
+ /** Every Codex rollout under one sessions root, plain and compressed, in directory order. Head-only: nothing is opened. */
161
+ export async function listCodexRolloutFiles(sessionsRoot: string): Promise<Array<{ file: string; compressed: boolean }>> {
162
+ const files: Array<{ file: string; compressed: boolean }> = []
163
+ for (const year of await dirents(sessionsRoot)) {
164
+ const yearDir = join(sessionsRoot, year)
165
+ const yearSt = await fileStat(yearDir)
166
+ if (!yearSt || yearSt.isFile) continue
167
+ for (const month of await dirents(yearDir)) {
168
+ const monthDir = join(yearDir, month)
169
+ const monthSt = await fileStat(monthDir)
170
+ if (!monthSt || monthSt.isFile) continue
171
+ for (const day of await dirents(monthDir)) {
172
+ const dayDir = join(monthDir, day)
173
+ const daySt = await fileStat(dayDir)
174
+ if (!daySt || daySt.isFile) continue
175
+ for (const name of await dirents(dayDir)) {
176
+ if (name.endsWith('.jsonl.zst')) files.push({ file: join(dayDir, name), compressed: true })
177
+ else if (name.endsWith('.jsonl')) files.push({ file: join(dayDir, name), compressed: false })
178
+ }
179
+ }
180
+ }
181
+ }
182
+ return files
183
+ }
184
+
185
+ class NoDecoderError extends Error {}
186
+
187
+ /**
188
+ * The first `maxBytes` of a file's (decompressed) content, one line at a time, until `onLine` says stop. A line cut by the
189
+ * limit is handed over as it stands and will usually not parse, which is the reader's cue to skip it.
190
+ */
191
+ async function readHeadLines(file: string, compressed: boolean, maxBytes: number, zstd: ZstdFactory, onLine: (line: string) => boolean): Promise<void> {
192
+ const decoder = new StringDecoder('utf8')
193
+ let carry = ''
194
+ let scanFrom = 0
195
+ let skipping = false
196
+ let total = 0
197
+ let stopped = false
198
+ const feed = (chunk: Buffer): boolean => {
199
+ total += chunk.length
200
+ carry += decoder.write(chunk)
201
+ for (;;) {
202
+ // Resume the newline search where the last one stopped: one enormous line is not rescanned from 0 on every chunk.
203
+ const at = carry.indexOf('\n', scanFrom)
204
+ if (at < 0) {
205
+ scanFrom = carry.length
206
+ if (carry.length > MAX_LINE_CHARS) { skipping = true; carry = ''; scanFrom = 0 }
207
+ return false
208
+ }
209
+ const line = carry.slice(0, at)
210
+ carry = carry.slice(at + 1)
211
+ scanFrom = 0
212
+ if (skipping) { skipping = false; continue }
213
+ if (line && line.length <= MAX_LINE_CHARS && onLine(line)) return true
214
+ }
215
+ }
216
+ if (!compressed) {
217
+ const handle = await open(file, 'r')
218
+ try {
219
+ const buffer = Buffer.alloc(FIND_CHUNK_BYTES)
220
+ while (total < maxBytes && !stopped) {
221
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(FIND_CHUNK_BYTES, maxBytes - total), total)
222
+ if (bytesRead <= 0) break
223
+ stopped = feed(Buffer.from(buffer.subarray(0, bytesRead)))
224
+ }
225
+ } finally { await handle.close() }
226
+ } else {
227
+ const inflate = zstd()
228
+ if (!inflate) throw new NoDecoderError('zstd unavailable')
229
+ const raw = createReadStream(file, { highWaterMark: FIND_CHUNK_BYTES })
230
+ raw.on('error', error => inflate.destroy(error))
231
+ raw.pipe(inflate)
232
+ try {
233
+ for await (const chunk of inflate) {
234
+ stopped = feed(chunk as Buffer)
235
+ if (stopped || total >= maxBytes) break
236
+ }
237
+ } finally { raw.destroy(); inflate.destroy() }
238
+ }
239
+ if (!stopped && !skipping && carry.trim()) onLine(carry + decoder.end())
240
+ }
241
+
242
+ interface HeadInfo {
243
+ /** A typed first prompt was found (or this is a subagent, which is left out whatever it says). */
244
+ typed: string
245
+ listFirstLine: string
246
+ subagent: boolean
247
+ metaId: string
248
+ cwd: string
249
+ created: string
250
+ }
251
+
252
+ /** A fresh flat copy. A slice of a big string keeps the whole parent alive in V8, and the memo outlives the parse (418 MB for 400 files). */
253
+ const flat = (text: string): string => Buffer.from(text, 'utf8').toString('utf8')
254
+
255
+ const emptyHead = (): HeadInfo => ({ typed: '', listFirstLine: '', subagent: false, metaId: '', cwd: '', created: '' })
256
+
257
+ function parseClaudeHead(): { head: HeadInfo; onLine: (line: string) => boolean } {
258
+ const head = emptyHead()
259
+ return { head, onLine: line => {
260
+ if (!line.includes('"user"')) return false
261
+ const obj = parseJsonLine(line)
262
+ if (!obj || obj.type !== 'user' || obj.toolUseResult || obj.isSidechain === true) return false
263
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
264
+ const body = message ? payloadText(message) : null
265
+ if (!body) return false
266
+ // Not from a wrapper body: the list titles a session from whatever the first record says, which can be harness text.
267
+ if (isWrapperPrompt(body)) return false
268
+ const typed = unwrapPrompt(body)
269
+ if (typed.length < 3) return false
270
+ head.listFirstLine = flat(firstLineTitle(body.slice(0, TITLE_SCAN_CHARS)))
271
+ head.typed = flat(typed.slice(0, FIND_PROMPT_KEEP_CHARS))
272
+ return true
273
+ } }
274
+ }
275
+
276
+ function parseCodexHead(): { head: HeadInfo; onLine: (line: string) => boolean } {
277
+ const head = emptyHead()
278
+ return { head, onLine: line => {
279
+ if (!line.includes('"session_meta"') && !line.includes('"response_item"')) return false
280
+ const obj = parseJsonLine(line)
281
+ if (!obj || !obj.payload || typeof obj.payload !== 'object') return false
282
+ const payload = obj.payload as Record<string, unknown>
283
+ if (obj.type === 'session_meta') {
284
+ head.metaId = flat(String(payload.id ?? payload.session_id ?? head.metaId))
285
+ head.cwd = flat(String(payload.cwd ?? head.cwd))
286
+ head.subagent = payload.thread_source === 'subagent'
287
+ head.created = flat(String(payload.timestamp ?? obj.timestamp ?? head.created).trim())
288
+ return head.subagent
289
+ }
290
+ if (obj.type !== 'response_item' || payload.type !== 'message' || payload.role !== 'user') return false
291
+ const body = codexUserText(payload)
292
+ if (!body) return false
293
+ if (isWrapperPrompt(body)) return false
294
+ const typed = unwrapPrompt(body)
295
+ if (typed.length < 3) return false
296
+ head.listFirstLine = flat(firstLineTitle(body.slice(0, TITLE_SCAN_CHARS)))
297
+ head.typed = flat(typed.slice(0, FIND_PROMPT_KEEP_CHARS))
298
+ return true
299
+ } }
300
+ }
301
+
302
+ function parseCursorHead(): { head: HeadInfo; onLine: (line: string) => boolean } {
303
+ const head = emptyHead()
304
+ return { head, onLine: line => {
305
+ if (!line.includes('"user"')) return false
306
+ const obj = parseJsonLine(line)
307
+ if (!obj || obj.role !== 'user') return false
308
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
309
+ const body = message ? payloadText(message) : null
310
+ if (!body) return false
311
+ if (isWrapperPrompt(body) && !body.includes('<user_query>')) return false
312
+ const typed = unwrapPrompt(body)
313
+ if (typed.length < 3) return false
314
+ head.listFirstLine = flat(firstLineTitle(body.slice(0, TITLE_SCAN_CHARS)))
315
+ head.typed = flat(typed.slice(0, FIND_PROMPT_KEEP_CHARS))
316
+ return true
317
+ } }
318
+ }
319
+
320
+ interface FileRef {
321
+ provider: AgentProvider
322
+ file: string
323
+ /** Claude: the uuid. Codex: the file's uuid. Cursor: the session directory. */
324
+ id: string
325
+ /** Claude: the project folder. Cursor: the project folder. Codex: unused. */
326
+ folder: string
327
+ mtimeMs: number
328
+ birthtimeMs: number
329
+ size: number
330
+ compressed: boolean
331
+ }
332
+
333
+ interface FileInfo {
334
+ mtimeMs: number
335
+ size: number
336
+ head: HeadInfo
337
+ /** Claude only, read the first time it is needed (a Desktop alias title wins over it). `null` is "none found". */
338
+ customTitle?: string | null
339
+ clockMs?: number
340
+ }
341
+
342
+ interface DesktopFlags { mtimeMs: number; size: number; scheduled: boolean; archived: boolean }
343
+
344
+ /** Both flags are keys of the Desktop record: archived near its top, scheduledTaskId anywhere in its first megabytes. */
345
+ async function readDesktopFlags(file: string, size: number): Promise<{ scheduled: boolean; archived: boolean } | null> {
346
+ if (size > DESKTOP_RECORD_MAX_BYTES) return null
347
+ try {
348
+ const handle = await open(file, 'r')
349
+ try {
350
+ const buffer = Buffer.alloc(size)
351
+ const { bytesRead } = await handle.read(buffer, 0, size, 0)
352
+ const text = buffer.subarray(0, bytesRead).toString('utf8')
353
+ return {
354
+ scheduled: /"scheduledTaskId"\s*:\s*"[^"]+"/.test(text),
355
+ archived: /"isArchived"\s*:\s*true/.test(text.slice(0, 64 * 1024)),
356
+ }
357
+ } finally { await handle.close() }
358
+ } catch { return null }
359
+ }
360
+
361
+ export interface FindPoolOptions {
362
+ roots?: AgentSessionRoots
363
+ /** How many sessions the pool should hold at most: 254 for the first request of a search, 508 for both. */
364
+ limit: number
365
+ signal?: AbortSignal
366
+ /** Lower-case ids (Claude session ids, Codex thread ids) that ran on a key. They never leave the Mac. */
367
+ keyedIds?: ReadonlySet<string>
368
+ /** Test seam: where the zstd decoder comes from. Defaults to node:zlib at the call. */
369
+ zstd?: ZstdFactory
370
+ maxOpen?: number
371
+ /** Test seam: how far into a file to look for the first typed prompt. Defaults to FIND_HEAD_BYTES. */
372
+ headBytes?: number
373
+ }
374
+
375
+ const normTitle = (title: string) => title.toLowerCase().replace(/\s+/g, ' ').trim()
376
+
377
+ const execFileAsync = promisify(execFile)
378
+
379
+ /**
380
+ * Cursor's sidebar names and which of its sessions are archived, read-only, from composerHeaders. loadCursorComposerNames
381
+ * (the list's reader) keeps names only; an archived session still has one, and 21 of 828 headers here are archived.
382
+ */
383
+ async function loadComposerHeaders(db: string): Promise<{ names: Map<string, string>; archived: Set<string> }> {
384
+ const names = new Map<string, string>(), archived = new Set<string>()
385
+ try {
386
+ const { stdout } = await execFileAsync('/usr/bin/sqlite3', ['-readonly', '-json', db,
387
+ "SELECT composerId AS id, json_extract(value, '$.name') AS name, json_extract(value, '$.isArchived') AS archived FROM composerHeaders"],
388
+ { timeout: 4000, maxBuffer: 16 * 1024 * 1024 })
389
+ const rows = JSON.parse(stdout || '[]') as Array<{ id?: string; name?: string | null; archived?: number | boolean | null }>
390
+ for (const row of rows) {
391
+ const id = String(row.id ?? '').trim()
392
+ if (!id) continue
393
+ const name = String(row.name ?? '').trim()
394
+ if (name) names.set(id, flat(name.slice(0, 120)))
395
+ if (row.archived === 1 || row.archived === true) archived.add(id)
396
+ }
397
+ } catch { /* unreadable: no names and nothing marked archived, as the list's reader does */ }
398
+ return { names, archived }
399
+ }
400
+
401
+ /** The newest conversation record's time from the transcript's own tail, widening x4 only while the tail has none. Not the list's memo. */
402
+ async function lastRecordMs(provider: AgentProvider, file: string, size: number): Promise<number | null> {
403
+ try {
404
+ const handle = await open(file, 'r')
405
+ try {
406
+ for (let window = ACTIVITY_TAIL_BYTES; ; window *= 4) {
407
+ const length = Math.min(size, window)
408
+ const start = size - length
409
+ const buffer = Buffer.alloc(length)
410
+ const { bytesRead } = await handle.read(buffer, 0, length, start)
411
+ const at = activityFromTail(provider, buffer.subarray(0, bytesRead).toString('utf8'), start > 0).lastActivityAt
412
+ if (at) { const ms = Date.parse(at); return Number.isFinite(ms) ? ms : null }
413
+ if (start === 0 || window >= ACTIVITY_TAIL_MAX_BYTES) return null
414
+ }
415
+ } finally { await handle.close() }
416
+ } catch { return null }
417
+ }
418
+
419
+ export class SessionFindCollector {
420
+ private memo = new Map<string, FileInfo>()
421
+ private flags = new Map<string, DesktopFlags>()
422
+ private cursorNames: { stamp: string; names: Map<string, string>; archived: Set<string> } | null = null
423
+ private flights = new Map<string, { promise: Promise<FindPool>; waiters: number; controller: AbortController }>()
424
+
425
+ constructor(private readonly memoMaxChars: number = FIND_MEMO_MAX_CHARS) {}
426
+
427
+ /** Memo entries held. A test seam: the memo must never outgrow the files it mirrors. */
428
+ memoSize(): number { return this.memo.size }
429
+
430
+ /**
431
+ * The pool. Concurrent calls for the same roots and limit share one walk; the walk stops when its LAST caller has gone
432
+ * (a request that closed), and what it had read stays in the memo, so the next try starts further along.
433
+ */
434
+ collect(options: FindPoolOptions): Promise<FindPool> {
435
+ const roots = options.roots ?? agentSessionRoots()
436
+ const key = `${options.limit}\0${JSON.stringify(roots)}\0${options.keyedIds ? [...options.keyedIds].sort().join(',') : ''}`
437
+ let flight = this.flights.get(key)
438
+ // A flight whose last caller left is stopping; a new caller starts a fresh walk (the memo keeps what the old one read).
439
+ if (!flight || flight.controller.signal.aborted) {
440
+ const controller = new AbortController()
441
+ const created = { promise: undefined as unknown as Promise<FindPool>, waiters: 0, controller }
442
+ created.promise = this.walk({ ...options, roots }, controller.signal).finally(() => { if (this.flights.get(key) === created) this.flights.delete(key) })
443
+ // A walk nobody waits for any more rejects; that is not an unhandled error.
444
+ created.promise.catch(() => undefined)
445
+ this.flights.set(key, created)
446
+ flight = created
447
+ }
448
+ const shared = flight
449
+ shared.waiters += 1
450
+ return new Promise<FindPool>((resolve, reject) => {
451
+ let left = false
452
+ const leave = () => {
453
+ if (left) return
454
+ left = true
455
+ options.signal?.removeEventListener('abort', onAbort)
456
+ shared.waiters -= 1
457
+ if (shared.waiters <= 0) shared.controller.abort()
458
+ }
459
+ const onAbort = () => { leave(); reject(abortError()) }
460
+ if (options.signal?.aborted) { onAbort(); return }
461
+ options.signal?.addEventListener('abort', onAbort, { once: true })
462
+ shared.promise.then(pool => { leave(); resolve(pool) }, error => { leave(); reject(error) })
463
+ })
464
+ }
465
+
466
+ private async walk(options: FindPoolOptions & { roots: AgentSessionRoots }, signal: AbortSignal): Promise<FindPool> {
467
+ const started = performance.now()
468
+ const { roots } = options
469
+ const zstd = options.zstd ?? zstdDecompressor
470
+ const keyed = options.keyedIds ?? new Set<string>()
471
+ const maxOpen = options.maxOpen ?? FIND_MAX_OPEN
472
+ const throwIfStopped = () => { if (signal.aborted) throw abortError() }
473
+
474
+ const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
475
+ throwIfStopped()
476
+ const desktopByCli = new Map<string, { title: string; file: string; mtimeMs: number }>()
477
+ for (const [id, head] of aliases) {
478
+ if (!head) continue
479
+ const cli = head.cliSessionId && CLAUDE_UUID_JSONL.test(head.cliSessionId + '.jsonl') ? head.cliSessionId : id
480
+ const previous = desktopByCli.get(cli)
481
+ if (!previous || head.mtimeMs >= previous.mtimeMs) desktopByCli.set(cli, { title: head.title, file: head.file, mtimeMs: head.mtimeMs })
482
+ }
483
+ const codexNames = await loadCodexThreadNames(roots.codexSessions)
484
+ const composers = await this.composerNames(roots.cursorComposerDb)
485
+ throwIfStopped()
486
+
487
+ const refs = await this.enumerate(roots)
488
+ throwIfStopped()
489
+ this.prune(refs)
490
+ refs.sort((a, b) => b.mtimeMs - a.mtimeMs)
491
+
492
+ const assembled = new Map<string, FindCandidate>()
493
+ let skippedNoDecoder = 0, skippedUnreadable = 0, opened = 0
494
+ const noDecoderFiles = new Set<string>(), unreadableFiles = new Set<string>()
495
+ let pool: FindCandidate[] = []
496
+ let next = 0
497
+ while (next < refs.length) {
498
+ throwIfStopped()
499
+ // Everything not yet opened was written no later than refs[next], and a transcript's last record is never later than its
500
+ // file's write time. So once the pool is full and its oldest entry is newer than that, no unopened file can displace it.
501
+ if (pool.length >= options.limit && pool[options.limit - 1]!.clockMs >= refs[next]!.mtimeMs) break
502
+ if (opened >= maxOpen) break
503
+ const batch = refs.slice(next, next + BATCH)
504
+ next += batch.length
505
+ const results = await mapWithConcurrency(batch, ACTIVITY_READ_CONCURRENCY, async ref => {
506
+ if (signal.aborted) return null
507
+ return this.inspect(ref, { desktopByCli, codexNames, cursorNames: composers.names, cursorArchived: composers.archived, keyed, zstd, headBytes: options.headBytes ?? FIND_HEAD_BYTES })
508
+ })
509
+ for (let i = 0; i < batch.length; i++) {
510
+ const outcome = results[i]
511
+ const ref = batch[i]!
512
+ if (!outcome) continue
513
+ if (outcome.read) opened += 1
514
+ if (outcome.skip === 'nodecoder') noDecoderFiles.add(ref.file)
515
+ if (outcome.skip === 'unreadable') unreadableFiles.add(ref.file)
516
+ if (outcome.candidate) assembled.set(ref.file, outcome.candidate)
517
+ }
518
+ pool = this.settle([...assembled.values()])
519
+ }
520
+ throwIfStopped()
521
+ this.trim()
522
+ skippedNoDecoder = noDecoderFiles.size
523
+ skippedUnreadable = unreadableFiles.size
524
+ return { candidates: pool.slice(0, options.limit), skippedNoDecoder, skippedUnreadable, opened, considered: refs.length, ms: Math.round(performance.now() - started) }
525
+ }
526
+
527
+ /** The pool-wide rules (a title seen 5 or more times is a machine), one identity per session, newest record first. */
528
+ private settle(all: FindCandidate[]): FindCandidate[] {
529
+ const seen = new Map<string, number>()
530
+ for (const candidate of all) {
531
+ const t = normTitle(candidate.title)
532
+ seen.set(t, (seen.get(t) ?? 0) + 1)
533
+ }
534
+ const byKey = new Map<string, FindCandidate>()
535
+ for (const candidate of all) {
536
+ if ((seen.get(normTitle(candidate.title)) ?? 0) >= FIND_REPEAT_TITLE_LIMIT) continue
537
+ const existing = byKey.get(candidate.key)
538
+ if (!existing || candidate.clockMs > existing.clockMs) byKey.set(candidate.key, candidate)
539
+ }
540
+ return [...byKey.values()].sort((a, b) => b.clockMs - a.clockMs || a.key.localeCompare(b.key))
541
+ }
542
+
543
+ private async composerNames(db: string): Promise<{ names: Map<string, string>; archived: Set<string> }> {
544
+ const st = db ? await fileStat(db) : null
545
+ if (!st?.isFile) return { names: new Map(), archived: new Set() }
546
+ const stamp = `${db}:${st.mtimeMs}:${st.size}`
547
+ if (this.cursorNames?.stamp === stamp) return this.cursorNames
548
+ const loaded = { stamp, ...(await loadComposerHeaders(db)) }
549
+ this.cursorNames = loaded
550
+ return loaded
551
+ }
552
+
553
+ /** Stat every transcript. Claude and Cursor from their own stores; Codex from the SERVER home only (never a keyed home). */
554
+ private async enumerate(roots: AgentSessionRoots): Promise<FileRef[]> {
555
+ const refs: FileRef[] = []
556
+ for (const folder of await dirents(roots.claudeProjects)) {
557
+ const dir = join(roots.claudeProjects, folder)
558
+ for (const name of await dirents(dir)) {
559
+ if (!CLAUDE_UUID_JSONL.test(name)) continue
560
+ const file = join(dir, name)
561
+ const st = await fileStat(file)
562
+ if (!st?.isFile) continue
563
+ refs.push({ provider: 'claude', file, id: name.slice(0, -'.jsonl'.length), folder, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, compressed: false })
564
+ }
565
+ }
566
+ for (const { file, compressed } of await listCodexRolloutFiles(roots.codexSessions)) {
567
+ const st = await fileStat(file)
568
+ if (!st?.isFile) continue
569
+ refs.push({ provider: 'codex', file, id: file.split('/').pop() || file, folder: '', mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, compressed })
570
+ }
571
+ const cursor = new Map<string, FileRef>()
572
+ for (const folder of await dirents(roots.cursorProjects)) {
573
+ if (isSkippedCursorFolder(folder)) continue
574
+ const transcripts = join(roots.cursorProjects, folder, 'agent-transcripts')
575
+ for (const sessionDir of await dirents(transcripts)) {
576
+ if (sessionDir === 'subagents') continue
577
+ const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
578
+ const st = await fileStat(file)
579
+ if (!st?.isFile || st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
580
+ const next: FileRef = { provider: 'cursor', file, id: sessionDir, folder, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, compressed: false }
581
+ const existing = cursor.get(sessionDir)
582
+ if (!existing) { cursor.set(sessionDir, next); continue }
583
+ const pick = preferCursorCopy(
584
+ { file: next.file, sessionDir, project: workspaceLabel(next.folder), mtimeMs: next.mtimeMs, birthtimeMs: next.birthtimeMs },
585
+ { file: existing.file, sessionDir, project: workspaceLabel(existing.folder), mtimeMs: existing.mtimeMs, birthtimeMs: existing.birthtimeMs })
586
+ cursor.set(sessionDir, pick.file === next.file ? next : existing)
587
+ }
588
+ }
589
+ refs.push(...cursor.values())
590
+ return refs
591
+ }
592
+
593
+ /** Forget memo entries for files that are gone, and keep the memo inside its bound. */
594
+ private prune(refs: readonly FileRef[]): void {
595
+ const live = new Set(refs.map(ref => ref.file))
596
+ for (const file of this.memo.keys()) if (!live.has(file)) this.memo.delete(file)
597
+ this.trim()
598
+ }
599
+
600
+ /** Evict least recently used entries until the memo is inside both bounds: entries and characters held. */
601
+ private trim(): void {
602
+ const chars = (info: FileInfo) => info.head.typed.length + info.head.listFirstLine.length + info.head.metaId.length + info.head.cwd.length + (info.customTitle?.length ?? 0) + 64
603
+ let held = 0
604
+ for (const info of this.memo.values()) held += chars(info)
605
+ while (this.memo.size > FIND_MEMO_MAX || held > this.memoMaxChars) {
606
+ const oldestKey = this.memo.keys().next().value
607
+ if (oldestKey === undefined) break
608
+ held -= chars(this.memo.get(oldestKey)!)
609
+ this.memo.delete(oldestKey)
610
+ }
611
+ }
612
+
613
+ private async inspect(ref: FileRef, ctx: {
614
+ desktopByCli: Map<string, { title: string; file: string; mtimeMs: number }>
615
+ codexNames: Map<string, string>
616
+ cursorNames: Map<string, string>
617
+ cursorArchived: ReadonlySet<string>
618
+ keyed: ReadonlySet<string>
619
+ zstd: ZstdFactory
620
+ headBytes: number
621
+ }): Promise<{ candidate: FindCandidate | null; read: boolean; skip?: 'nodecoder' | 'unreadable' } | null> {
622
+ let info = this.memo.get(ref.file)
623
+ let read = false
624
+ if (!info || info.mtimeMs !== ref.mtimeMs || info.size !== ref.size) {
625
+ const parser = ref.provider === 'claude' ? parseClaudeHead() : ref.provider === 'codex' ? parseCodexHead() : parseCursorHead()
626
+ try {
627
+ await readHeadLines(ref.file, ref.compressed, ctx.headBytes, ctx.zstd, parser.onLine)
628
+ } catch (error) {
629
+ return { candidate: null, read: false, skip: error instanceof NoDecoderError ? 'nodecoder' : 'unreadable' }
630
+ }
631
+ read = true
632
+ info = { mtimeMs: ref.mtimeMs, size: ref.size, head: parser.head }
633
+ this.memo.delete(ref.file)
634
+ this.memo.set(ref.file, info)
635
+ } else {
636
+ this.memo.delete(ref.file)
637
+ this.memo.set(ref.file, info)
638
+ }
639
+ const { head } = info
640
+ if (head.subagent || !head.typed) return { candidate: null, read }
641
+
642
+ let sessionId: string
643
+ let title: string
644
+ let project: string
645
+ let created = isoFromMtime(ref.birthtimeMs)
646
+ if (ref.provider === 'claude') {
647
+ sessionId = ref.id
648
+ if (ctx.keyed.has(sessionId.toLowerCase())) return { candidate: null, read }
649
+ const desktop = ctx.desktopByCli.get(sessionId)
650
+ if (desktop) {
651
+ // The Desktop record says whether it was a scheduled run or put away. Unreadable counts as yes: it fails toward not sending.
652
+ const flags = await this.desktopFlags(desktop.file)
653
+ if (!flags || flags.scheduled || flags.archived) return { candidate: null, read }
654
+ }
655
+ if (!desktop?.title && info.customTitle === undefined) info.customTitle = await lastCustomTitle(ref.file).then(t => t === null ? null : flat(t), () => null)
656
+ title = desktop?.title || info.customTitle || head.listFirstLine || 'Claude session'
657
+ project = workspaceLabel(ref.folder)
658
+ } else if (ref.provider === 'codex') {
659
+ const name = ref.file.split('/').pop() || ref.file
660
+ const fileId = rolloutFileId(name)
661
+ const metaId = head.metaId.trim().toLowerCase()
662
+ // The filename wins when it disagrees: a cloned thread carries its parent's id in session_meta (listCodexSessions).
663
+ sessionId = fileId && metaId && fileId !== metaId ? fileId : (head.metaId || rolloutBaseName(name) || name)
664
+ if (ctx.keyed.has(sessionId.toLowerCase())) return { candidate: null, read }
665
+ title = ctx.codexNames.get(sessionId) || head.listFirstLine || 'Codex session'
666
+ project = workspaceLabel(head.cwd)
667
+ created = head.created || createdFromCodexFilename(name) || created
668
+ } else {
669
+ sessionId = ref.id
670
+ if (ctx.keyed.has(sessionId.toLowerCase()) || ctx.cursorArchived.has(sessionId)) return { candidate: null, read }
671
+ title = ctx.cursorNames.get(sessionId) || head.listFirstLine || 'Cursor session'
672
+ project = workspaceLabel(ref.folder)
673
+ }
674
+ if (isKeepWarmSessionTitle(title) || isKeepWarmSessionTitle(head.listFirstLine) || isKeepWarmSessionTitle(head.typed)) return { candidate: null, read }
675
+
676
+ if (info.clockMs === undefined) {
677
+ let clock = ref.mtimeMs
678
+ // A compressed rollout cannot be tailed; its write time was preserved from the last write (median 0.21 days after the start, which is the stamp's own offset).
679
+ if (ref.provider !== 'cursor' && !ref.compressed) {
680
+ const at = await lastRecordMs(ref.provider, ref.file, ref.size)
681
+ if (at !== null) clock = Math.min(at, ref.mtimeMs)
682
+ }
683
+ info.clockMs = clock
684
+ }
685
+ return { read, candidate: {
686
+ provider: ref.provider, session_id: sessionId, key: `${ref.provider}:${sessionId.toLowerCase()}`, title: title.slice(0, 120),
687
+ listFirstLine: head.listFirstLine, typed: head.typed, project, clockMs: info.clockMs, mtimeMs: ref.mtimeMs, birthtimeMs: ref.birthtimeMs,
688
+ created, compressed: ref.compressed, file: ref.file,
689
+ } }
690
+ }
691
+
692
+ private async desktopFlags(file: string): Promise<{ scheduled: boolean; archived: boolean } | null> {
693
+ const st = await fileStat(file)
694
+ if (!st?.isFile) return null
695
+ const known = this.flags.get(file)
696
+ if (known && known.mtimeMs === st.mtimeMs && known.size === st.size) return known
697
+ const read = await readDesktopFlags(file, st.size)
698
+ if (!read) { this.flags.delete(file); return null }
699
+ const flags: DesktopFlags = { mtimeMs: st.mtimeMs, size: st.size, ...read }
700
+ this.flags.set(file, flags)
701
+ return flags
702
+ }
703
+ }
704
+
705
+ function abortError(): Error {
706
+ const error = new Error('cancelled')
707
+ error.name = 'AbortError'
708
+ return error
709
+ }
710
+
711
+ export function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' }
712
+
713
+ let shared: SessionFindCollector | null = null
714
+ /** One collector per process: it owns the memo that makes the second search cheap. */
715
+ export function sharedSessionFindCollector(): SessionFindCollector { return shared ??= new SessionFindCollector() }