@gotcos/glasses-server 6.27.6 → 6.27.7

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,1065 @@
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
+ export function assistantProseFromRecord(obj: Record<string, unknown>): string | null {
152
+ if (obj.type === 'assistant') {
153
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
154
+ const text = message ? payloadText(message) : null
155
+ return text ? proseSnippet(text) || null : null
156
+ }
157
+ if (obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
158
+ const payload = obj.payload as Record<string, unknown>
159
+ if (payload.role === 'assistant' && (payload.type === 'message' || !payload.type)) {
160
+ const text = payloadText(payload)
161
+ return text ? proseSnippet(text) || null : null
162
+ }
163
+ }
164
+ if (obj.role === 'assistant') {
165
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
166
+ const text = message ? payloadText(message) : null
167
+ return text ? proseSnippet(text) || null : null
168
+ }
169
+ return null
170
+ }
171
+
172
+ export function latestAssistantFromWindow(text: string): string {
173
+ let latest = ''
174
+ for (const line of text.split('\n')) {
175
+ const obj = parseJsonLine(line)
176
+ if (!obj) continue
177
+ const prose = assistantProseFromRecord(obj)
178
+ if (prose) latest = prose
179
+ }
180
+ return latest
181
+ }
182
+
183
+ function discussionFields(title: string, firstPrompt: string, latestAssistant: string): {
184
+ first_prompt: string
185
+ discussion_summary: string
186
+ } {
187
+ return {
188
+ first_prompt: firstPrompt,
189
+ discussion_summary: composeDiscussionSummary({ title, firstPrompt, latestAssistant }),
190
+ }
191
+ }
192
+
193
+ function cursorUserTitle(body: string, requireQuery: boolean): string | null {
194
+ if (requireQuery && !body.includes('<user_query>')) return null
195
+ const title = firstLineTitle(body)
196
+ if (!title || isWrapperPrompt(title)) return null
197
+ return title
198
+ }
199
+
200
+ export function payloadText(payload: Record<string, unknown>): string | null {
201
+ const content = payload.content
202
+ if (typeof content === 'string') {
203
+ const trimmed = content.trim()
204
+ return trimmed ? trimmed : null
205
+ }
206
+ if (!Array.isArray(content)) return null
207
+ const parts: string[] = []
208
+ for (const block of content) {
209
+ if (!block || typeof block !== 'object') continue
210
+ const type = String((block as { type?: unknown }).type ?? '')
211
+ if (type === 'tool_result' || type === 'tool_use' || type === 'thinking') continue
212
+ if (type === 'text' || type === 'input_text' || type === 'output_text') {
213
+ const text = String((block as { text?: unknown }).text ?? '').trim()
214
+ if (text) parts.push(text)
215
+ }
216
+ }
217
+ const joined = parts.join('\n\n').trim()
218
+ return joined || null
219
+ }
220
+
221
+ export function isoFromMtime(mtimeMs: number): string {
222
+ return new Date(mtimeMs).toISOString()
223
+ }
224
+
225
+ function row(partial: Omit<AgentSessionRow, 'created' | 'state' | 'pinned'> & {
226
+ created?: string
227
+ state?: AgentSessionRow['state']
228
+ pinned?: boolean
229
+ }): AgentSessionRow {
230
+ return {
231
+ ...partial,
232
+ created: partial.created ?? partial.modified,
233
+ state: partial.state ?? (partial.alive ? 'running' : 'recent'),
234
+ pinned: partial.pinned ?? false,
235
+ }
236
+ }
237
+
238
+ export function idFromCodexFilename(name: string): string | null {
239
+ if (!name.endsWith('.jsonl')) return null
240
+ const parts = name.slice(0, -6).split('-')
241
+ if (parts.length < 5) return null
242
+ const uuid = parts.slice(-5).join('-').toLowerCase()
243
+ return uuid.length >= 36 ? uuid : null
244
+ }
245
+
246
+ export async function dirents(dir: string): Promise<string[]> {
247
+ try {
248
+ return await readdir(dir)
249
+ } catch {
250
+ return []
251
+ }
252
+ }
253
+
254
+ export async function fileStat(path: string): Promise<{ mtimeMs: number; birthtimeMs: number; size: number; isFile: boolean } | null> {
255
+ try {
256
+ const st = await lstat(path)
257
+ return {
258
+ mtimeMs: st.mtimeMs,
259
+ birthtimeMs: st.birthtimeMs || st.mtimeMs,
260
+ size: st.size,
261
+ isFile: st.isFile(),
262
+ }
263
+ } catch {
264
+ return null
265
+ }
266
+ }
267
+
268
+ export async function readWindow(path: string, fromEnd: boolean): Promise<string> {
269
+ const st = await fileStat(path)
270
+ if (!st?.isFile || st.size <= 0) return ''
271
+ let start = 0
272
+ let end = st.size - 1
273
+ if (st.size > HEAD_BYTES) {
274
+ if (fromEnd) start = st.size - HEAD_BYTES
275
+ else end = HEAD_BYTES - 1
276
+ }
277
+ const stream = createReadStream(path, { start, end })
278
+ const chunks: Buffer[] = []
279
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
280
+ return Buffer.concat(chunks).toString('utf8')
281
+ }
282
+
283
+ export function parseJsonLine(line: string): Record<string, unknown> | null {
284
+ try {
285
+ const obj = JSON.parse(line) as unknown
286
+ return obj && typeof obj === 'object' ? obj as Record<string, unknown> : null
287
+ } catch {
288
+ return null
289
+ }
290
+ }
291
+
292
+ export async function lastCustomTitle(path: string): Promise<string | null> {
293
+ const text = await readWindow(path, true)
294
+ let found: string | null = null
295
+ for (const line of text.split('\n')) {
296
+ if (!line.includes('custom-title')) continue
297
+ const obj = parseJsonLine(line)
298
+ if (obj?.type !== 'custom-title') continue
299
+ const title = String(obj.customTitle ?? '').trim()
300
+ if (title) found = title.slice(0, 120)
301
+ }
302
+ return found
303
+ }
304
+
305
+ export async function peekClaudeDiscussion(path: string): Promise<{ customTitle: string | null; latestAssistant: string }> {
306
+ const text = await readWindow(path, true)
307
+ let customTitle: string | null = null
308
+ for (const line of text.split('\n')) {
309
+ if (line.includes('custom-title')) {
310
+ const obj = parseJsonLine(line)
311
+ if (obj?.type === 'custom-title') {
312
+ const title = String(obj.customTitle ?? '').trim()
313
+ if (title) customTitle = title.slice(0, 120)
314
+ }
315
+ }
316
+ }
317
+ return { customTitle, latestAssistant: latestAssistantFromWindow(text) }
318
+ }
319
+
320
+ export async function firstClaudeUserTitle(path: string): Promise<string | null> {
321
+ const text = await readWindow(path, false)
322
+ for (const line of text.split('\n')) {
323
+ const obj = parseJsonLine(line)
324
+ if (!obj || obj.type !== 'user' || obj.toolUseResult || obj.isSidechain === true) continue
325
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
326
+ const body = message ? payloadText(message) : null
327
+ if (!body) continue
328
+ const title = firstLineTitle(body)
329
+ if (title) return title
330
+ }
331
+ return null
332
+ }
333
+
334
+ export function createdFromCodexFilename(name: string): string | null {
335
+ const match = CODEX_ROLLOUT_STAMP.exec(name)
336
+ if (!match) return null
337
+ const stamp = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}`)
338
+ return Number.isFinite(stamp.getTime()) ? stamp.toISOString() : null
339
+ }
340
+
341
+ export async function loadCodexThreadNames(sessionsRoot: string): Promise<Map<string, string>> {
342
+ const names = new Map<string, string>()
343
+ const indexPath = join(sessionsRoot, '..', 'session_index.jsonl')
344
+ try {
345
+ const rl = createInterface({ input: createReadStream(indexPath), crlfDelay: Infinity })
346
+ for await (const line of rl) {
347
+ const obj = parseJsonLine(line)
348
+ if (!obj) continue
349
+ const id = String(obj.id ?? '').trim()
350
+ const name = String(obj.thread_name ?? '').trim()
351
+ if (id && name) names.set(id, name.slice(0, 120))
352
+ }
353
+ } catch {
354
+ return names
355
+ }
356
+ return names
357
+ }
358
+
359
+ export async function loadCodexPinnedIds(sessionsRoot: string): Promise<Set<string>> {
360
+ const pinned = new Set<string>()
361
+ const statePath = join(sessionsRoot, '..', '.codex-global-state.json')
362
+ try {
363
+ const raw = await readFile(statePath, 'utf8')
364
+ const obj = JSON.parse(raw) as { 'pinned-thread-ids'?: unknown }
365
+ const list = obj['pinned-thread-ids']
366
+ if (!Array.isArray(list)) return pinned
367
+ for (const value of list) {
368
+ const id = String(value ?? '').trim().toLowerCase()
369
+ if (id) pinned.add(id)
370
+ }
371
+ } catch {
372
+ return pinned
373
+ }
374
+ return pinned
375
+ }
376
+
377
+ export function normalizeClaudeSessionId(raw: string): string {
378
+ let id = raw.trim().toLowerCase()
379
+ if (id.startsWith('local_')) id = id.slice('local_'.length)
380
+ return id
381
+ }
382
+
383
+ export function peekClaudeDesktopHead(text: string): { title: string; cwd: string } {
384
+ const unescape = (value: string) => value.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
385
+ const title = /"title"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
386
+ const cwd = /"cwd"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
387
+ return {
388
+ title: title ? unescape(title[1]).slice(0, 120) : '',
389
+ cwd: cwd ? unescape(cwd[1]) : '',
390
+ }
391
+ }
392
+
393
+ export async function loadClaudeStarredIds(configPath: string): Promise<Set<string>> {
394
+ const starred = new Set<string>()
395
+ if (!configPath) return starred
396
+ try {
397
+ const obj = JSON.parse(await readFile(configPath, 'utf8')) as {
398
+ preferences?: { epitaxyPrefs?: { 'starred-local-code-sessions'?: unknown } }
399
+ }
400
+ const list = obj.preferences?.epitaxyPrefs?.['starred-local-code-sessions']
401
+ if (!Array.isArray(list)) return starred
402
+ for (const value of list) {
403
+ const id = normalizeClaudeSessionId(String(value ?? ''))
404
+ if (id) starred.add(id)
405
+ }
406
+ } catch {
407
+ return starred
408
+ }
409
+ return starred
410
+ }
411
+
412
+ function parsePinnedComposerList(raw: string): string[] {
413
+ try {
414
+ const parsed = JSON.parse(raw) as unknown
415
+ const list = Array.isArray(parsed)
416
+ ? parsed
417
+ : parsed && typeof parsed === 'object'
418
+ ? (parsed as { composerIds?: unknown; ids?: unknown }).composerIds
419
+ ?? (parsed as { ids?: unknown }).ids
420
+ : null
421
+ if (!Array.isArray(list)) return []
422
+ return list.map(value => String(value ?? '').trim().toLowerCase()).filter(Boolean)
423
+ } catch {
424
+ return []
425
+ }
426
+ }
427
+
428
+ export async function loadCursorPinnedIds(workspaceStorage: string): Promise<Set<string>> {
429
+ const pinned = new Set<string>()
430
+ if (!workspaceStorage) return pinned
431
+ for (const folder of await dirents(workspaceStorage)) {
432
+ const dbPath = join(workspaceStorage, folder, 'state.vscdb')
433
+ const st = await fileStat(dbPath)
434
+ if (!st?.isFile) continue
435
+ try {
436
+ const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
437
+ '-readonly',
438
+ dbPath,
439
+ "SELECT value FROM ItemTable WHERE key = 'cursor/pinnedComposers' LIMIT 1",
440
+ ], { timeout: 2000, maxBuffer: 256 * 1024 })
441
+ for (const id of parsePinnedComposerList(stdout.trim())) pinned.add(id)
442
+ } catch {
443
+ continue
444
+ }
445
+ }
446
+ return pinned
447
+ }
448
+
449
+ export async function findClaudeDesktopFile(sessionsRoot: string, uuid: string): Promise<string | null> {
450
+ if (!sessionsRoot || !uuid) return null
451
+ const name = `local_${uuid}.json`
452
+ for (const account of await dirents(sessionsRoot)) {
453
+ const accountDir = join(sessionsRoot, account)
454
+ const accountSt = await fileStat(accountDir)
455
+ if (!accountSt || accountSt.isFile) continue
456
+ for (const workspace of await dirents(accountDir)) {
457
+ const file = join(accountDir, workspace, name)
458
+ const st = await fileStat(file)
459
+ if (st?.isFile) return file
460
+ }
461
+ }
462
+ return null
463
+ }
464
+
465
+ export function preferCursorCopy(
466
+ next: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
467
+ existing: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
468
+ ): typeof next {
469
+ if (next.project === 'empty-window' && existing.project !== 'empty-window') return existing
470
+ if (existing.project === 'empty-window' && next.project !== 'empty-window') return next
471
+ return next.mtimeMs >= existing.mtimeMs ? next : existing
472
+ }
473
+
474
+ export async function loadCursorComposerNames(dbPath: string): Promise<Map<string, string>> {
475
+ const names = new Map<string, string>()
476
+ if (!dbPath) return names
477
+ const st = await fileStat(dbPath)
478
+ if (!st?.isFile) return names
479
+ try {
480
+ const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
481
+ '-readonly',
482
+ '-json',
483
+ dbPath,
484
+ "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') != ''",
485
+ ], { timeout: 4000, maxBuffer: 8 * 1024 * 1024 })
486
+ const rows = JSON.parse(stdout || '[]') as Array<{ id?: string; composerId?: string; name?: string }>
487
+ for (const row of rows) {
488
+ const id = String(row.id ?? row.composerId ?? '').trim()
489
+ const name = String(row.name ?? '').trim()
490
+ if (id && name) names.set(id, name.slice(0, 120))
491
+ }
492
+ } catch {
493
+ return names
494
+ }
495
+ return names
496
+ }
497
+
498
+ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: string; title: string; subagent: boolean; created: string } | null> {
499
+ const text = await readWindow(path, false)
500
+ let id = ''
501
+ let cwd = ''
502
+ let title = ''
503
+ let created = ''
504
+ let subagent = false
505
+ for (const line of text.split('\n')) {
506
+ const obj = parseJsonLine(line)
507
+ if (!obj) continue
508
+ if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
509
+ const payload = obj.payload as Record<string, unknown>
510
+ id = String(payload.id ?? payload.session_id ?? id)
511
+ cwd = String(payload.cwd ?? cwd)
512
+ subagent = payload.thread_source === 'subagent'
513
+ const nick = String(payload.agent_nickname ?? '').trim()
514
+ if (nick && !title) title = nick
515
+ const stamp = String(payload.timestamp ?? obj.timestamp ?? '').trim()
516
+ if (stamp && !created) created = stamp
517
+ }
518
+ if (!title && obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
519
+ const payload = obj.payload as Record<string, unknown>
520
+ if (payload.type === 'message' && payload.role === 'user') {
521
+ const textBody = payloadText(payload)
522
+ if (textBody && !isWrapperPrompt(textBody)) title = firstLineTitle(textBody)
523
+ }
524
+ }
525
+ if (id && title) break
526
+ }
527
+ return { id, cwd, title, subagent, created }
528
+ }
529
+
530
+ async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
531
+ const text = await readWindow(path, true)
532
+ let lastUser: string | null = null
533
+ for (const line of text.split('\n')) {
534
+ const obj = parseJsonLine(line)
535
+ if (!obj || obj.role !== 'user') continue
536
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
537
+ const body = message ? payloadText(message) : null
538
+ if (!body) continue
539
+ const title = cursorUserTitle(body, true)
540
+ if (title) lastUser = title
541
+ }
542
+ return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
543
+ }
544
+
545
+ async function firstCursorUserTitle(path: string): Promise<string | null> {
546
+ const text = await readWindow(path, false)
547
+ let fallback: string | null = null
548
+ for (const line of text.split('\n')) {
549
+ const obj = parseJsonLine(line)
550
+ if (!obj || obj.role !== 'user') continue
551
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
552
+ const body = message ? payloadText(message) : null
553
+ if (!body) continue
554
+ const query = cursorUserTitle(body, true)
555
+ if (query) return query
556
+ if (!fallback) fallback = cursorUserTitle(body, false)
557
+ }
558
+ return fallback
559
+ }
560
+
561
+ export async function listCodexJsonlFiles(sessionsRoot: string): Promise<string[]> {
562
+ const files: string[] = []
563
+ for (const year of await dirents(sessionsRoot)) {
564
+ const yearDir = join(sessionsRoot, year)
565
+ const yearSt = await fileStat(yearDir)
566
+ if (!yearSt || yearSt.isFile) continue
567
+ for (const month of await dirents(yearDir)) {
568
+ const monthDir = join(yearDir, month)
569
+ const monthSt = await fileStat(monthDir)
570
+ if (!monthSt || monthSt.isFile) continue
571
+ for (const day of await dirents(monthDir)) {
572
+ const dayDir = join(monthDir, day)
573
+ const daySt = await fileStat(dayDir)
574
+ if (!daySt || daySt.isFile) continue
575
+ for (const name of await dirents(dayDir)) {
576
+ if (name.endsWith('.jsonl')) files.push(join(dayDir, name))
577
+ }
578
+ }
579
+ }
580
+ }
581
+ return files
582
+ }
583
+
584
+ export async function listClaudeSessions(
585
+ projectsRoot: string,
586
+ now: Date,
587
+ liveIds: Set<string>,
588
+ cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
589
+ starredIds: ReadonlySet<string> = new Set(),
590
+ desktopSessionsRoot = '',
591
+ ): Promise<AgentSessionRow[]> {
592
+ const seen = new Set<string>()
593
+ const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
594
+ const recentCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
595
+ for (const folder of await dirents(projectsRoot)) {
596
+ const dir = join(projectsRoot, folder)
597
+ const dirSt = await fileStat(dir)
598
+ if (!dirSt || dirSt.isFile) continue
599
+ for (const name of await dirents(dir)) {
600
+ if (!CLAUDE_UUID_JSONL.test(name)) continue
601
+ const native = name.slice(0, -6)
602
+ const shortId = native.slice(0, 8)
603
+ if (liveIds.has(shortId) || liveIds.has(native)) continue
604
+ const file = join(dir, name)
605
+ const st = await fileStat(file)
606
+ if (!st?.isFile) continue
607
+ const pinned = starredIds.has(native.toLowerCase())
608
+ const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
609
+ if (!pinned && !fresh) continue
610
+ const candidate = {
611
+ file,
612
+ native,
613
+ project: workspaceLabel(folder),
614
+ mtimeMs: st.mtimeMs,
615
+ birthtimeMs: st.birthtimeMs,
616
+ }
617
+ seen.add(native.toLowerCase())
618
+ if (pinned) pinnedCandidates.push(candidate)
619
+ else recentCandidates.push(candidate)
620
+ }
621
+ }
622
+ for (const starred of starredIds) {
623
+ if (seen.has(starred) || liveIds.has(starred) || liveIds.has(starred.slice(0, 8))) continue
624
+ const desktop = await findClaudeDesktopFile(desktopSessionsRoot, starred)
625
+ if (!desktop) continue
626
+ const st = await fileStat(desktop)
627
+ if (!st?.isFile) continue
628
+ pinnedCandidates.push({
629
+ file: '',
630
+ native: starred,
631
+ project: '',
632
+ mtimeMs: st.mtimeMs,
633
+ birthtimeMs: st.birthtimeMs,
634
+ desktop,
635
+ })
636
+ seen.add(starred)
637
+ }
638
+ pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
639
+ recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
640
+
641
+ const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
642
+ const rows: AgentSessionRow[] = []
643
+ for (const candidate of candidates) {
644
+ if (rows.length >= Math.max(0, limit)) break
645
+ let title = ''
646
+ let project = candidate.project
647
+ let firstPrompt = ''
648
+ let latestAssistant = ''
649
+ if (candidate.file) {
650
+ const peek = await peekClaudeDiscussion(candidate.file)
651
+ firstPrompt = await firstClaudeUserTitle(candidate.file) ?? ''
652
+ title = peek.customTitle ?? firstPrompt
653
+ latestAssistant = peek.latestAssistant
654
+ }
655
+ if ((!title || !project) && (candidate.desktop || desktopSessionsRoot)) {
656
+ const desktop = candidate.desktop || await findClaudeDesktopFile(desktopSessionsRoot, candidate.native)
657
+ if (desktop) {
658
+ const head = peekClaudeDesktopHead(await readWindow(desktop, false))
659
+ if (!title && head.title) title = head.title
660
+ if (!project && head.cwd) project = workspaceLabel(head.cwd)
661
+ }
662
+ }
663
+ title = title || 'Claude session'
664
+ if (isKeepWarmSessionTitle(title)) continue
665
+ rows.push(row({
666
+ session_id: candidate.native,
667
+ provider: 'claude',
668
+ display_label: title,
669
+ project,
670
+ modified: isoFromMtime(candidate.mtimeMs),
671
+ created: isoFromMtime(candidate.birthtimeMs),
672
+ alive: false,
673
+ pinned: starredIds.has(candidate.native.toLowerCase()),
674
+ ...discussionFields(title, firstPrompt, latestAssistant),
675
+ }))
676
+ }
677
+ return rows
678
+ }
679
+
680
+ return [
681
+ ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
682
+ ...await toRows(recentCandidates, Math.max(0, cap)),
683
+ ]
684
+ }
685
+
686
+ export async function listCodexSessions(
687
+ sessionsRoot: string,
688
+ now: Date,
689
+ cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
690
+ ): Promise<AgentSessionRow[]> {
691
+ const names = await loadCodexThreadNames(sessionsRoot)
692
+ const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
693
+ const pinnedCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
694
+ const recentCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
695
+ for (const file of await listCodexJsonlFiles(sessionsRoot)) {
696
+ const st = await fileStat(file)
697
+ if (!st?.isFile) continue
698
+ const name = file.split('/').pop() || file
699
+ const fileId = idFromCodexFilename(name)
700
+ const pinned = fileId ? pinnedIds.has(fileId) : false
701
+ const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
702
+ if (!pinned && !fresh) continue
703
+ const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
704
+ if (pinned) pinnedCandidates.push(candidate)
705
+ else recentCandidates.push(candidate)
706
+ }
707
+ pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
708
+ recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
709
+
710
+ const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
711
+ const rows: AgentSessionRow[] = []
712
+ for (const candidate of candidates) {
713
+ if (rows.length >= Math.max(0, limit)) break
714
+ const meta = await peekCodexMeta(candidate.file)
715
+ if (!meta || meta.subagent) continue
716
+ const native = meta.id || candidate.name.slice(0, -6)
717
+ const title = names.get(native) || meta.title || 'Codex session'
718
+ if (isKeepWarmSessionTitle(title)) continue
719
+ const created = meta.created || createdFromCodexFilename(candidate.name) || isoFromMtime(candidate.birthtimeMs)
720
+ const latestAssistant = latestAssistantFromWindow(await readWindow(candidate.file, true))
721
+ rows.push(row({
722
+ session_id: native,
723
+ provider: 'codex',
724
+ display_label: title,
725
+ project: workspaceLabel(meta.cwd),
726
+ modified: isoFromMtime(candidate.mtimeMs),
727
+ created,
728
+ alive: false,
729
+ pinned: pinnedIds.has(native.toLowerCase()),
730
+ ...discussionFields(title, meta.title, latestAssistant),
731
+ }))
732
+ }
733
+ return rows
734
+ }
735
+
736
+ const pinnedRows = await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0))
737
+ const recentRows = await toRows(recentCandidates, Math.max(0, cap))
738
+ return [...pinnedRows, ...recentRows]
739
+ }
740
+
741
+ export async function listCursorSessions(
742
+ projectsRoot: string,
743
+ now: Date,
744
+ cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
745
+ composerDb = '',
746
+ pinnedIds: ReadonlySet<string> = new Set(),
747
+ ): Promise<AgentSessionRow[]> {
748
+ const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
749
+ const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
750
+ for (const folder of await dirents(projectsRoot)) {
751
+ if (folder.includes('var-folders') || folder.includes('private-var')) continue
752
+ const transcripts = join(projectsRoot, folder, 'agent-transcripts')
753
+ for (const sessionDir of await dirents(transcripts)) {
754
+ if (sessionDir === 'subagents') continue
755
+ const pinned = pinnedIds.has(sessionDir.toLowerCase())
756
+ if (folder === 'empty-window' && !pinned) continue
757
+ const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
758
+ const st = await fileStat(file)
759
+ if (!st?.isFile) continue
760
+ if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
761
+ const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
762
+ if (!pinned && !fresh) continue
763
+ const next = {
764
+ file,
765
+ sessionDir,
766
+ project: workspaceLabel(folder),
767
+ mtimeMs: st.mtimeMs,
768
+ birthtimeMs: st.birthtimeMs,
769
+ }
770
+ const existing = byId.get(sessionDir)
771
+ byId.set(sessionDir, existing ? preferCursorCopy(next, existing) : next)
772
+ }
773
+ }
774
+ const pinnedCandidates = [...byId.values()].filter(c => pinnedIds.has(c.sessionDir.toLowerCase()))
775
+ const recentCandidates = [...byId.values()].filter(c => !pinnedIds.has(c.sessionDir.toLowerCase()))
776
+ pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
777
+ recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
778
+
779
+ const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
780
+ const rows: AgentSessionRow[] = []
781
+ for (const candidate of candidates) {
782
+ if (rows.length >= Math.max(0, limit)) break
783
+ const peek = await peekCursorDiscussion(candidate.file)
784
+ const firstPrompt = await firstCursorUserTitle(candidate.file) ?? peek.lastUser ?? ''
785
+ const title = composerNames.get(candidate.sessionDir)
786
+ ?? peek.lastUser
787
+ ?? firstPrompt
788
+ ?? 'Cursor session'
789
+ if (isKeepWarmSessionTitle(title)) continue
790
+ const alive = now.getTime() - candidate.mtimeMs < 180_000
791
+ rows.push(row({
792
+ session_id: candidate.sessionDir,
793
+ provider: 'cursor',
794
+ display_label: title,
795
+ project: candidate.project,
796
+ modified: isoFromMtime(candidate.mtimeMs),
797
+ created: isoFromMtime(candidate.birthtimeMs),
798
+ alive,
799
+ state: alive ? 'running' : 'recent',
800
+ pinned: pinnedIds.has(candidate.sessionDir.toLowerCase()),
801
+ ...discussionFields(title, firstPrompt, peek.latestAssistant),
802
+ }))
803
+ }
804
+ return rows
805
+ }
806
+
807
+ return [
808
+ ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
809
+ ...await toRows(recentCandidates, Math.max(0, cap)),
810
+ ]
811
+ }
812
+
813
+ async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots): Promise<AgentSessionRow> {
814
+ if (row.provider !== 'claude') return row
815
+ const found = await findAgentSessionFile('claude', row.session_id, roots)
816
+ if (!found) return row
817
+ const peek = await peekClaudeDiscussion(found)
818
+ const firstPrompt = await firstClaudeUserTitle(found)
819
+ const title = peek.customTitle ?? firstPrompt ?? row.display_label
820
+ const fullId = found.split('/').pop()?.replace(/\.jsonl$/i, '') || row.session_id
821
+ return {
822
+ ...row,
823
+ session_id: fullId,
824
+ display_label: title || row.display_label || 'Claude session',
825
+ ...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
826
+ }
827
+ }
828
+
829
+ function sessionKey(row: AgentSessionRow): string {
830
+ return `${row.provider}:${row.session_id.trim().toLowerCase()}`
831
+ }
832
+
833
+ function preferSession(next: AgentSessionRow, current: AgentSessionRow): AgentSessionRow {
834
+ if (next.alive !== current.alive) return next.alive ? next : current
835
+ if (next.pinned !== current.pinned) return next.pinned ? next : current
836
+ if (next.project === 'empty-window' && current.project !== 'empty-window') return current
837
+ if (current.project === 'empty-window' && next.project !== 'empty-window') return next
838
+ return (next.modified || '') > (current.modified || '') ? next : current
839
+ }
840
+
841
+ function dedupeSessions(rows: AgentSessionRow[]): AgentSessionRow[] {
842
+ const byKey = new Map<string, AgentSessionRow>()
843
+ for (const row of rows) {
844
+ const key = sessionKey(row)
845
+ const existing = byKey.get(key)
846
+ byKey.set(key, existing ? preferSession(row, existing) : row)
847
+ }
848
+ return [...byKey.values()]
849
+ }
850
+
851
+ export async function listAgentSessions(
852
+ roots: AgentSessionRoots,
853
+ now = new Date(),
854
+ live: AgentSessionRow[] = [],
855
+ limit = AGENT_SESSION_LIST_LIMIT,
856
+ sort: AgentSessionSort = 'updated',
857
+ ): Promise<AgentSessionRow[]> {
858
+ const starredIds = await loadClaudeStarredIds(roots.claudeDesktopConfig)
859
+ const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
860
+ const enrichedLive = (await Promise.all(live.map(row => enrichLiveClaude(row, roots))))
861
+ .filter(entry => !isKeepWarmSessionTitle(entry.display_label))
862
+ .map(entry => {
863
+ if (entry.provider === 'claude' && starredIds.has(normalizeClaudeSessionId(entry.session_id))) {
864
+ return { ...entry, pinned: true }
865
+ }
866
+ if (entry.provider === 'cursor' && cursorPinned.has(entry.session_id.toLowerCase())) {
867
+ return { ...entry, pinned: true }
868
+ }
869
+ return entry
870
+ })
871
+ const liveIds = new Set(enrichedLive.flatMap(row => [row.session_id, row.session_id.slice(0, 8)]))
872
+ const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
873
+ let rows = dedupeSessions([
874
+ ...enrichedLive,
875
+ ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions),
876
+ ...await listCodexSessions(roots.codexSessions, now, cap),
877
+ ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned),
878
+ ])
879
+ if (sort === 'opened') {
880
+ rows = rows.filter(entry => {
881
+ if (entry.alive) return true
882
+ const created = Date.parse(entry.created)
883
+ return Number.isFinite(created) && now.getTime() - created <= AGENT_SESSION_MAX_AGE_MS
884
+ })
885
+ // Control Activity → Opened: created desc. Pins stay in the payload
886
+ // when they were opened in-window; they do not cluster at the top.
887
+ rows.sort((a, b) => (b.created || '').localeCompare(a.created || ''))
888
+ } else {
889
+ // Control Activity → Updated: newest write first. Stale pins still
890
+ // appear (any age) but sink by mtime. Glasses has no Pinned clock, so
891
+ // pin-boosting here made the lens show July stars instead of today.
892
+ rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
893
+ }
894
+ return rows.slice(0, limit)
895
+ }
896
+
897
+ export async function findAgentSessionFile(
898
+ provider: AgentProvider,
899
+ sessionId: string,
900
+ roots: AgentSessionRoots,
901
+ now = new Date(),
902
+ ): Promise<string | null> {
903
+ if (!isSafeSessionId(sessionId)) return null
904
+ const needle = sessionId.trim().toLowerCase()
905
+ if (provider === 'claude') {
906
+ for (const folder of await dirents(roots.claudeProjects)) {
907
+ const dir = join(roots.claudeProjects, folder)
908
+ for (const name of await dirents(dir)) {
909
+ if (!name.endsWith('.jsonl')) continue
910
+ const id = name.slice(0, -6).toLowerCase()
911
+ if (id === needle || id.startsWith(needle)) return join(dir, name)
912
+ }
913
+ }
914
+ return null
915
+ }
916
+ if (provider === 'codex') {
917
+ for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
918
+ const name = file.split('/').pop()?.toLowerCase() ?? ''
919
+ if (name.includes(needle)) return file
920
+ }
921
+ return null
922
+ }
923
+ let best: { file: string; mtimeMs: number } | null = null
924
+ for (const folder of await dirents(roots.cursorProjects)) {
925
+ if (isSkippedCursorFolder(folder)) continue
926
+ const file = join(roots.cursorProjects, folder, 'agent-transcripts', needle, `${needle}.jsonl`)
927
+ const st = await fileStat(file)
928
+ if (!st?.isFile) continue
929
+ if (!best || st.mtimeMs >= best.mtimeMs) best = { file, mtimeMs: st.mtimeMs }
930
+ }
931
+ return best?.file ?? null
932
+ }
933
+
934
+ export interface AgentSessionDetail {
935
+ session_id: string
936
+ provider: AgentProvider
937
+ display_label: string
938
+ project: string
939
+ git_branch: string
940
+ first_prompt: string
941
+ discussion_summary: string
942
+ user_message_count: number
943
+ assistant_message_count: number
944
+ omitted_tools: number
945
+ file_size_bytes: number
946
+ }
947
+
948
+ export async function parseAgentSession(provider: AgentProvider, path: string): Promise<AgentSessionDetail> {
949
+ const st = await stat(path)
950
+ let title = ''
951
+ let project = ''
952
+ let gitBranch = ''
953
+ let sessionId = path.split('/').pop()?.replace(/\.jsonl$/, '') || ''
954
+ let firstPrompt = ''
955
+ let latestAssistant = ''
956
+ let userCount = 0
957
+ let assistantCount = 0
958
+ let omittedTools = 0
959
+
960
+ const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity })
961
+ for await (const line of rl) {
962
+ const obj = parseJsonLine(line)
963
+ if (!obj) continue
964
+ if (provider === 'claude') {
965
+ if (typeof obj.sessionId === 'string' && obj.sessionId) sessionId = obj.sessionId
966
+ if (obj.type === 'custom-title' && typeof obj.customTitle === 'string' && obj.customTitle.trim()) {
967
+ title = obj.customTitle.trim()
968
+ continue
969
+ }
970
+ if (obj.isSidechain === true) continue
971
+ if (!project && typeof obj.cwd === 'string') project = workspaceLabel(obj.cwd)
972
+ if (!gitBranch && typeof obj.gitBranch === 'string') gitBranch = obj.gitBranch
973
+ if (obj.type === 'user' && obj.toolUseResult) { omittedTools += 1; continue }
974
+ if (obj.type !== 'user' && obj.type !== 'assistant') continue
975
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
976
+ const text = message ? payloadText(message) : null
977
+ if (!text) {
978
+ if (obj.type === 'assistant') omittedTools += 1
979
+ continue
980
+ }
981
+ if (obj.type === 'user') {
982
+ userCount += 1
983
+ if (!firstPrompt) firstPrompt = firstLineTitle(text)
984
+ if (!title) title = firstLineTitle(text)
985
+ } else {
986
+ assistantCount += 1
987
+ const snippet = proseSnippet(text)
988
+ if (snippet) latestAssistant = snippet
989
+ }
990
+ } else if (provider === 'codex') {
991
+ if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
992
+ const payload = obj.payload as Record<string, unknown>
993
+ if (typeof payload.cwd === 'string') project = workspaceLabel(payload.cwd)
994
+ if (typeof payload.id === 'string' && payload.id) sessionId = payload.id
995
+ const git = payload.git && typeof payload.git === 'object' ? payload.git as Record<string, unknown> : null
996
+ if (git && typeof git.branch === 'string') gitBranch = git.branch
997
+ continue
998
+ }
999
+ if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
1000
+ const payload = obj.payload as Record<string, unknown>
1001
+ const kind = String(payload.type ?? '')
1002
+ if (kind === 'custom_tool_call' || kind === 'custom_tool_call_output' || kind === 'function_call') {
1003
+ omittedTools += 1
1004
+ continue
1005
+ }
1006
+ if (kind !== 'message' || payload.role === 'developer') continue
1007
+ const text = payloadText(payload)
1008
+ if (!text || isWrapperPrompt(text)) continue
1009
+ if (payload.role === 'assistant') {
1010
+ assistantCount += 1
1011
+ const snippet = proseSnippet(text)
1012
+ if (snippet) latestAssistant = snippet
1013
+ } else {
1014
+ userCount += 1
1015
+ if (!firstPrompt) firstPrompt = firstLineTitle(text)
1016
+ if (!title) title = firstLineTitle(text)
1017
+ }
1018
+ } else {
1019
+ if (obj.role !== 'user' && obj.role !== 'assistant') continue
1020
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
1021
+ if (Array.isArray(message?.content) && (message.content as Array<{ type?: string }>).some(b => b?.type === 'tool_use')) {
1022
+ omittedTools += 1
1023
+ }
1024
+ const text = message ? payloadText(message) : null
1025
+ if (!text) continue
1026
+ if (obj.role === 'user') {
1027
+ userCount += 1
1028
+ const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
1029
+ if (query) {
1030
+ title = query
1031
+ if (!firstPrompt) firstPrompt = query
1032
+ }
1033
+ } else {
1034
+ assistantCount += 1
1035
+ const snippet = proseSnippet(text)
1036
+ if (snippet) latestAssistant = snippet
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ if (provider === 'cursor' && !project) {
1042
+ const parts = path.split('/')
1043
+ const encoded = parts[parts.indexOf('agent-transcripts') - 1] || ''
1044
+ project = workspaceLabel(encoded)
1045
+ }
1046
+
1047
+ const display = title || (provider === 'codex' ? 'Codex session' : provider === 'cursor' ? 'Cursor session' : 'Claude session')
1048
+ return {
1049
+ session_id: sessionId,
1050
+ provider,
1051
+ display_label: display,
1052
+ project,
1053
+ git_branch: gitBranch,
1054
+ first_prompt: firstPrompt || title,
1055
+ discussion_summary: composeDiscussionSummary({
1056
+ title: display,
1057
+ firstPrompt: firstPrompt || title,
1058
+ latestAssistant,
1059
+ }),
1060
+ user_message_count: userCount,
1061
+ assistant_message_count: assistantCount,
1062
+ omitted_tools: omittedTools,
1063
+ file_size_bytes: st.size,
1064
+ }
1065
+ }