@gotcos/glasses-server 7.0.18 → 7.0.29

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.
@@ -35,6 +35,16 @@ export interface AgentSessionListDropped {
35
35
  age: number
36
36
  limit: number
37
37
  oversized: number
38
+ /** Step 2a (Q1 FIX 1): kept rows (COS pins, folder members, COS-started sessions) past KEPT_LIST_MAX. Present only
39
+ * when something was dropped, so an older reader of `dropped` sees the shape it knows. */
40
+ kept?: number
41
+ }
42
+
43
+ /** Step 2a (Q1 FIX 1): the most kept rows one list answer carries, on top of `limit`. Native pins are not counted
44
+ * here; they stay inside `limit` as before. */
45
+ export const KEPT_LIST_MAX = 40
46
+ function countKeptOverflow(dropped: AgentSessionListDropped, n: number): void {
47
+ if (n > 0) dropped.kept = (dropped.kept ?? 0) + n
38
48
  }
39
49
 
40
50
  export function emptySessionListDropped(): AgentSessionListDropped {
@@ -1058,6 +1068,11 @@ export async function listClaudeSessions(
1058
1068
  desktopSessionsRoot = '',
1059
1069
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1060
1070
  preloadedAliases?: ClaudeAliasMap,
1071
+ /** Step 2a: canonical ids the COS organizer keeps (COS pins, folder members). Listed at any age and outside `cap`,
1072
+ * like a native pin, but `pinned` stays the native star only. */
1073
+ keep: ReadonlySet<string> = new Set(),
1074
+ /** Which kept ids win when KEPT_LIST_MAX must choose: lower first (0 COS pins and folders, 1 COS-started). */
1075
+ keepRank: (id: string) => number = () => 0,
1061
1076
  ): Promise<AgentSessionRow[]> {
1062
1077
  const aliases = preloadedAliases ?? await loadClaudeDesktopAliases(desktopSessionsRoot)
1063
1078
  const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
@@ -1072,6 +1087,7 @@ export async function listClaudeSessions(
1072
1087
  const seen = new Set<string>()
1073
1088
  const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
1074
1089
  const recentCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
1090
+ const keptCandidates: typeof recentCandidates = []
1075
1091
  for (const folder of await dirents(projectsRoot)) {
1076
1092
  const dir = join(projectsRoot, folder)
1077
1093
  const dirSt = await fileStat(dir)
@@ -1084,7 +1100,9 @@ export async function listClaudeSessions(
1084
1100
  const file = join(dir, name)
1085
1101
  const st = await fileStat(file)
1086
1102
  if (!st?.isFile) continue
1087
- const pinned = canonicalStars.has(native.toLowerCase())
1103
+ const starred = canonicalStars.has(native.toLowerCase())
1104
+ const kept = !starred && keep.has(native.toLowerCase())
1105
+ const pinned = starred || kept
1088
1106
  const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
1089
1107
  if (!pinned && !fresh) {
1090
1108
  dropped.age += 1
@@ -1099,17 +1117,18 @@ export async function listClaudeSessions(
1099
1117
  desktop: desktopByCli.get(native)?.file,
1100
1118
  }
1101
1119
  seen.add(native.toLowerCase())
1102
- if (pinned) pinnedCandidates.push(candidate)
1120
+ if (starred) pinnedCandidates.push(candidate)
1121
+ else if (kept) keptCandidates.push(candidate)
1103
1122
  else recentCandidates.push(candidate)
1104
1123
  }
1105
1124
  }
1106
- for (const starred of canonicalStars) {
1125
+ for (const starred of new Set([...canonicalStars, ...keep])) {
1107
1126
  if (seen.has(starred) || canonicalLive.has(starred) || canonicalLive.has(starred.slice(0, 8))) continue
1108
1127
  const desktop = desktopByCli.get(starred)?.file
1109
1128
  if (!desktop) continue
1110
1129
  const st = await fileStat(desktop)
1111
1130
  if (!st?.isFile) continue
1112
- pinnedCandidates.push({
1131
+ ;(canonicalStars.has(starred) ? pinnedCandidates : keptCandidates).push({
1113
1132
  file: '',
1114
1133
  native: starred,
1115
1134
  project: '',
@@ -1120,6 +1139,7 @@ export async function listClaudeSessions(
1120
1139
  seen.add(starred)
1121
1140
  }
1122
1141
  pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1142
+ keptCandidates.sort((a, b) => (keepRank(a.native.toLowerCase()) - keepRank(b.native.toLowerCase())) || b.mtimeMs - a.mtimeMs)
1123
1143
  recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1124
1144
 
1125
1145
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
@@ -1166,8 +1186,13 @@ export async function listClaudeSessions(
1166
1186
  return rows
1167
1187
  }
1168
1188
 
1189
+ const keptLimitBefore = dropped.limit
1190
+ const keptRows = await toRows(keptCandidates, KEPT_LIST_MAX)
1191
+ countKeptOverflow(dropped, dropped.limit - keptLimitBefore)
1192
+ dropped.limit = keptLimitBefore
1169
1193
  return [
1170
1194
  ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
1195
+ ...keptRows,
1171
1196
  ...await toRows(recentCandidates, Math.max(0, cap)),
1172
1197
  ]
1173
1198
  }
@@ -1177,27 +1202,36 @@ export async function listCodexSessions(
1177
1202
  now: Date,
1178
1203
  cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
1179
1204
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1205
+ /** Step 2a: thread ids the COS organizer keeps; see listClaudeSessions. */
1206
+ keep: ReadonlySet<string> = new Set(),
1207
+ keepRank: (id: string) => number = () => 0,
1180
1208
  ): Promise<AgentSessionRow[]> {
1181
1209
  const names = await loadCodexThreadNames(sessionsRoot)
1182
1210
  const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
1183
1211
  const pinnedCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
1184
1212
  const recentCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
1213
+ const keptCandidates: typeof recentCandidates = []
1185
1214
  for (const file of await listCodexJsonlFiles(sessionsRoot)) {
1186
1215
  const st = await fileStat(file)
1187
1216
  if (!st?.isFile) continue
1188
1217
  const name = file.split('/').pop() || file
1189
1218
  const fileId = idFromCodexFilename(name)
1190
- const pinned = fileId ? pinnedIds.has(fileId) : false
1219
+ const nativePin = fileId ? pinnedIds.has(fileId) : false
1220
+ const kept = !nativePin && (fileId ? keep.has(fileId) : false)
1221
+ const pinned = nativePin || kept
1191
1222
  const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
1192
1223
  if (!pinned && !fresh) {
1193
1224
  dropped.age += 1
1194
1225
  continue
1195
1226
  }
1196
1227
  const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
1197
- if (pinned) pinnedCandidates.push(candidate)
1228
+ if (nativePin) pinnedCandidates.push(candidate)
1229
+ else if (kept) keptCandidates.push(candidate)
1198
1230
  else recentCandidates.push(candidate)
1199
1231
  }
1200
1232
  pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1233
+ const codexRank = (c: { name: string }) => keepRank(idFromCodexFilename(c.name) ?? '')
1234
+ keptCandidates.sort((a, b) => (codexRank(a) - codexRank(b)) || b.mtimeMs - a.mtimeMs)
1201
1235
  recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1202
1236
 
1203
1237
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
@@ -1251,8 +1285,12 @@ export async function listCodexSessions(
1251
1285
  }
1252
1286
 
1253
1287
  const pinnedRows = await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0))
1288
+ const keptLimitBefore = dropped.limit
1289
+ const keptRows = await toRows(keptCandidates, KEPT_LIST_MAX)
1290
+ countKeptOverflow(dropped, dropped.limit - keptLimitBefore)
1291
+ dropped.limit = keptLimitBefore
1254
1292
  const recentRows = await toRows(recentCandidates, Math.max(0, cap))
1255
- return [...pinnedRows, ...recentRows]
1293
+ return [...pinnedRows, ...keptRows, ...recentRows]
1256
1294
  }
1257
1295
 
1258
1296
  export async function listCursorSessions(
@@ -1262,15 +1300,19 @@ export async function listCursorSessions(
1262
1300
  composerDb = '',
1263
1301
  pinnedIds: ReadonlySet<string> = new Set(),
1264
1302
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1303
+ /** Step 2a: composer ids the COS organizer keeps; see listClaudeSessions. */
1304
+ keep: ReadonlySet<string> = new Set(),
1305
+ keepRank: (id: string) => number = () => 0,
1265
1306
  ): Promise<AgentSessionRow[]> {
1266
1307
  const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
1308
+ const retained = (id: string) => pinnedIds.has(id.toLowerCase()) || keep.has(id.toLowerCase())
1267
1309
  const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
1268
1310
  for (const folder of await dirents(projectsRoot)) {
1269
1311
  if (folder.includes('var-folders') || folder.includes('private-var')) continue
1270
1312
  const transcripts = join(projectsRoot, folder, 'agent-transcripts')
1271
1313
  for (const sessionDir of await dirents(transcripts)) {
1272
1314
  if (sessionDir === 'subagents') continue
1273
- const pinned = pinnedIds.has(sessionDir.toLowerCase())
1315
+ const pinned = retained(sessionDir)
1274
1316
  if (folder === 'empty-window' && !pinned) continue
1275
1317
  const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
1276
1318
  const st = await fileStat(file)
@@ -1295,9 +1337,12 @@ export async function listCursorSessions(
1295
1337
  byId.set(sessionDir, existing ? preferCursorCopy(next, existing) : next)
1296
1338
  }
1297
1339
  }
1298
- const pinnedCandidates = [...byId.values()].filter(c => pinnedIds.has(c.sessionDir.toLowerCase()))
1299
- const recentCandidates = [...byId.values()].filter(c => !pinnedIds.has(c.sessionDir.toLowerCase()))
1340
+ const nativePin = (id: string) => pinnedIds.has(id.toLowerCase())
1341
+ const pinnedCandidates = [...byId.values()].filter(c => nativePin(c.sessionDir))
1342
+ const keptCandidates = [...byId.values()].filter(c => !nativePin(c.sessionDir) && retained(c.sessionDir))
1343
+ const recentCandidates = [...byId.values()].filter(c => !retained(c.sessionDir))
1300
1344
  pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1345
+ keptCandidates.sort((a, b) => (keepRank(a.sessionDir.toLowerCase()) - keepRank(b.sessionDir.toLowerCase())) || b.mtimeMs - a.mtimeMs)
1301
1346
  recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1302
1347
 
1303
1348
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
@@ -1334,8 +1379,13 @@ export async function listCursorSessions(
1334
1379
  return rows
1335
1380
  }
1336
1381
 
1382
+ const keptLimitBefore = dropped.limit
1383
+ const keptRows = await toRows(keptCandidates, KEPT_LIST_MAX)
1384
+ countKeptOverflow(dropped, dropped.limit - keptLimitBefore)
1385
+ dropped.limit = keptLimitBefore
1337
1386
  return [
1338
1387
  ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
1388
+ ...keptRows,
1339
1389
  ...await toRows(recentCandidates, Math.max(0, cap)),
1340
1390
  ]
1341
1391
  }
@@ -1407,6 +1457,12 @@ export async function listAgentSessions(
1407
1457
  sort: AgentSessionSort = 'updated',
1408
1458
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1409
1459
  preloadedAliases?: ClaudeAliasMap,
1460
+ /** Step 2a: canonical ids per provider the COS organizer keeps (COS pins, folder members). Kept rows are listed at
1461
+ * any age, outside the per-provider cap AND outside `limit`. Absent: exactly today's list. */
1462
+ keep: Partial<Record<AgentProvider, ReadonlySet<string>>> = {},
1463
+ /** Step 2a (Miles, 2026-11-10): sessions a COS job started in the last 7 days. Kept like `keep`, ranked after it when
1464
+ * the KEPT_LIST_MAX ceiling has to choose. */
1465
+ started: Partial<Record<AgentProvider, ReadonlySet<string>>> = {},
1410
1466
  ): Promise<AgentSessionRow[]> {
1411
1467
  const originalStars = await loadClaudeStarredIds(roots.claudeDesktopConfig)
1412
1468
  // ONE alias load for the whole walk. Before this the walk loaded it here AND
@@ -1429,15 +1485,26 @@ export async function listAgentSessions(
1429
1485
  })
1430
1486
  const liveIds = new Set(enrichedLive.flatMap(row => [row.session_id, row.session_id.slice(0, 8)]))
1431
1487
  const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
1488
+ // Kept Claude ids are canonical already (the organizer route canonicalizes on write); map again in case a Desktop
1489
+ // id reached the store from an older path.
1490
+ const keptClaude = new Set([...(keep.claude ?? [])].map(id => canonical(id).toLowerCase()))
1491
+ const startedClaude = new Set([...(started.claude ?? [])].map(id => canonical(id).toLowerCase()))
1492
+ const inSet = (row: AgentSessionRow, claude: Set<string>, other: Partial<Record<AgentProvider, ReadonlySet<string>>>) =>
1493
+ row.provider === 'claude' ? claude.has(row.session_id.toLowerCase()) : (other[row.provider]?.has(row.session_id.toLowerCase()) ?? false)
1494
+ /** 0: a COS pin or folder member; 1: COS-started; null: not kept. */
1495
+ const keptTier = (row: AgentSessionRow): 0 | 1 | null => inSet(row, keptClaude, keep) ? 0 : inSet(row, startedClaude, started) ? 1 : null
1496
+ const isKept = (row: AgentSessionRow) => keptTier(row) !== null
1497
+ const union = (provider: Exclude<AgentProvider, 'claude'>) => new Set([...(keep[provider] ?? []), ...(started[provider] ?? [])])
1498
+ const keptForWalk = { claude: new Set([...keptClaude, ...startedClaude]), codex: union('codex'), cursor: union('cursor') }
1432
1499
  let rows = dedupeSessions([
1433
1500
  ...enrichedLive,
1434
- ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped, aliases),
1435
- ...(await Promise.all(codexSessionRootsOf(roots).map(root => listCodexSessions(root, now, cap, dropped)))).flat(),
1436
- ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped),
1501
+ ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped, aliases, keptForWalk.claude, id => keptClaude.has(id) ? 0 : 1),
1502
+ ...(await Promise.all(codexSessionRootsOf(roots).map(root => listCodexSessions(root, now, cap, dropped, keptForWalk.codex, id => keep.codex?.has(id) ? 0 : 1)))).flat(),
1503
+ ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped, keptForWalk.cursor, id => keep.cursor?.has(id) ? 0 : 1),
1437
1504
  ])
1438
1505
  if (sort === 'opened') {
1439
1506
  rows = rows.filter(entry => {
1440
- if (entry.alive) return true
1507
+ if (entry.alive || isKept(entry)) return true
1441
1508
  const created = Date.parse(entry.created)
1442
1509
  return Number.isFinite(created) && now.getTime() - created <= AGENT_SESSION_MAX_AGE_MS
1443
1510
  })
@@ -1450,9 +1517,24 @@ export async function listAgentSessions(
1450
1517
  // pin-boosting here made the lens show July stars instead of today.
1451
1518
  rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
1452
1519
  }
1453
- const extras = Math.max(0, rows.length - Math.max(0, limit))
1454
- if (extras) dropped.limit += extras
1455
- return rows.slice(0, limit)
1520
+ // Step 2a: kept rows are not cut by `limit` but by their own ceiling, KEPT_LIST_MAX (Q1 FIX 1): COS pins and folder
1521
+ // members first, then COS-started sessions, newest first in each. They keep their place in the sort order.
1522
+ const max = Math.max(0, limit)
1523
+ const keptRanked = rows.filter(isKept)
1524
+ .sort((a, b) => (keptTier(a)! - keptTier(b)!) || (b.modified || '').localeCompare(a.modified || ''))
1525
+ const keptAllowed = new Set(keptRanked.slice(0, KEPT_LIST_MAX))
1526
+ countKeptOverflow(dropped, keptRanked.length - keptAllowed.size)
1527
+ let taken = 0
1528
+ let cut = 0
1529
+ const out = rows.filter(entry => {
1530
+ if (isKept(entry)) return keptAllowed.has(entry)
1531
+ taken += 1
1532
+ if (taken <= max) return true
1533
+ cut += 1
1534
+ return false
1535
+ })
1536
+ if (cut) dropped.limit += cut
1537
+ return out
1456
1538
  }
1457
1539
 
1458
1540
  /**
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Sessions a COS job started in the last 7 days (Miles, 2026-11-10 23:16, via the lead: "Yes, keep for 7 days").
3
+ *
4
+ * The 0b spike found that a session started from Work or Ask drops out of GET /api/agent-sessions within hours on a
5
+ * busy Mac (the per-agent cap of 20 showed only the newest 4.2 hours of Claude sessions). The list keeps these, like a
6
+ * COS pin, inside the same bounded keep ceiling (agent-session-store.ts KEPT_LIST_MAX).
7
+ *
8
+ * Read from the query-job journal (data/query-jobs/<YYYY-MM-DD>.jsonl): each job's `running` or `completed` record
9
+ * names its provider and native session (`cliSessionId` for Claude and Cursor, `codexThreadId` for Codex). Only ids,
10
+ * times and the request's kind are read, never prompts or answers.
11
+ *
12
+ * Only jobs a PERSON started count (Q1, 2026-11-10 23:3x). Every job records a session id (54 of 54 in the 8 days
13
+ * measured), so the job's `accepted` record decides. It is an ALLOWLIST (Q1's nit, lead 23:44), so a new kind of
14
+ * background job fails safe: a job counts only when its request has no `origin` (Ask from phone, lens or web, Work
15
+ * starts, Continue) or `origin.kind === 'task'` (a dispatched task), and no `maintenanceWorkflow`. That leaves out
16
+ * the scheduler's routines (the morning brief: 6 of 54), meeting consolidation (2 of 54) and any kind added later.
17
+ * A session id whose job's request record is not in the window is left out too (only a job that ran for more than
18
+ * a day; failing safe). Each day file is memoized on mtime and size, and a file that keeps
19
+ * changing (a job writing its deltas) is re-read at most every 30 seconds, so the 5-second list poll stays cheap.
20
+ */
21
+ import { readdir, readFile, stat } from 'node:fs/promises'
22
+ import { join } from 'node:path'
23
+ import { dataPath } from './data-dir.js'
24
+
25
+ export const COS_STARTED_WINDOW_MS = 7 * 24 * 3600 * 1000
26
+ export const COS_STARTED_REREAD_MS = 30_000
27
+ type Provider = 'claude' | 'codex' | 'cursor'
28
+ interface Seen { provider: Provider; id: string; at: number; jobId: string }
29
+ interface DayMemo { mtimeMs: number; size: number; readAt: number; seen: Seen[]; person: string[] }
30
+
31
+ const memo = new Map<string, DayMemo>()
32
+ export const cosStartedStats = { reads: 0 }
33
+ export function __resetCosStartedForTests(): void { memo.clear(); cosStartedStats.reads = 0 }
34
+
35
+ const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
36
+ const SAFE_ID = /^[A-Za-z0-9-]{8,64}$/
37
+
38
+ /** True only for a request a person started: no origin, or a dispatched task; never a maintenance workflow. */
39
+ export function isPersonJobRequest(request: unknown): boolean {
40
+ if (!request || typeof request !== 'object') return false
41
+ const r = request as { origin?: unknown; maintenanceWorkflow?: unknown }
42
+ if (r.maintenanceWorkflow != null) return false
43
+ if (r.origin == null) return true
44
+ return typeof r.origin === 'object' && (r.origin as { kind?: unknown }).kind === 'task'
45
+ }
46
+
47
+ function idsIn(text: string): { seen: Seen[]; person: string[] } {
48
+ const out: Seen[] = []
49
+ const person: string[] = []
50
+ for (const line of text.split('\n')) {
51
+ const hasRequest = line.includes('"request"')
52
+ if (!hasRequest && !line.includes('SessionId') && !line.includes('ThreadId')) continue
53
+ let record: any
54
+ try { record = JSON.parse(line) } catch { continue }
55
+ const jobId = typeof record?.jobId === 'string' ? record.jobId : ''
56
+ if (hasRequest && jobId && isPersonJobRequest(record?.request)) person.push(jobId)
57
+ const patch = record?.patch
58
+ if (!patch || typeof patch !== 'object') continue
59
+ const provider = patch.provider
60
+ const at = Date.parse(record.persistedAt ?? '')
61
+ const id = provider === 'codex' ? patch.codexThreadId : patch.cliSessionId
62
+ if ((provider === 'claude' || provider === 'codex' || provider === 'cursor') && typeof id === 'string' && SAFE_ID.test(id)) {
63
+ out.push({ provider, id: id.toLowerCase(), at: Number.isFinite(at) ? at : 0, jobId })
64
+ }
65
+ }
66
+ return { seen: out, person }
67
+ }
68
+
69
+ /** Native session ids per provider that a COS job started within `windowMs` of `now`. Never throws. */
70
+ export async function cosStartedSessionIds(now = Date.now(), dir = dataPath('query-jobs'), windowMs = COS_STARTED_WINDOW_MS): Promise<Record<Provider, Set<string>>> {
71
+ const result: Record<Provider, Set<string>> = { claude: new Set(), codex: new Set(), cursor: new Set() }
72
+ let names: string[]
73
+ try { names = await readdir(dir) } catch { return result }
74
+ // One day of slack: a job's day file is its own partition day, and the window is measured from its record time.
75
+ const oldestDay = new Date(now - windowMs - 24 * 3600 * 1000).toISOString().slice(0, 10)
76
+ const memos: DayMemo[] = []
77
+ for (const name of names) {
78
+ const day = DAY_FILE.exec(name)?.[1]
79
+ if (!day || day < oldestDay) continue
80
+ const file = join(dir, name)
81
+ try {
82
+ const st = await stat(file)
83
+ if (!st.isFile()) continue
84
+ let m = memo.get(file)
85
+ const changed = !m || m.mtimeMs !== st.mtimeMs || m.size !== st.size
86
+ if (changed && (!m || now - m.readAt >= COS_STARTED_REREAD_MS)) {
87
+ cosStartedStats.reads++
88
+ m = { mtimeMs: st.mtimeMs, size: st.size, readAt: now, ...idsIn(await readFile(file, 'utf8')) }
89
+ memo.set(file, m)
90
+ }
91
+ if (m) memos.push(m)
92
+ } catch { /* a day file that cannot be read adds nothing */ }
93
+ }
94
+ // A job's request and its session id can sit in different day files, so person-started jobs are gathered first.
95
+ const person = new Set(memos.flatMap(m => m.person))
96
+ for (const m of memos) {
97
+ for (const s of m.seen) if (now - s.at <= windowMs && person.has(s.jobId)) result[s.provider].add(s.id)
98
+ }
99
+ for (const key of memo.keys()) if (!names.includes(key.split('/').pop() || '')) memo.delete(key)
100
+ return result
101
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `reported_model` on LIST rows without the list ever waiting on a model read (step 2a, S1_CONTRACT_step2a.md B3).
3
+ *
4
+ * The 6.62.0 QA rule W11 (routes/agent-sessions.ts) keeps model reads off the list path: a list of 60 rows must not
5
+ * hold a tail read per row. So list rows carry the model ONLY from this memo. After a list answers, `warmListModels`
6
+ * reads the rows the memo missed in the background: at most 4 at a time, Claude and Codex only, and a file that keeps
7
+ * changing is re-read at most once a minute (a Codex posture scan can be long). The value then appears on a later poll.
8
+ *
9
+ * The value and format are exactly the detail route's (session-continue-facts.ts): Claude a bare slug, Codex
10
+ * `<model> <effort>`. Cursor rows are not warmed here (their model sits in Cursor's database; the detail route has it).
11
+ */
12
+ import { stat } from 'node:fs/promises'
13
+ import { codexReportedModel, readCodexSessionPosture } from './codex-session-posture.js'
14
+ import { readClaudeReportedModel, sanitizeReportedModel } from './session-continue-facts.js'
15
+
16
+ export const LIST_MODEL_WARM_CONCURRENCY = 4
17
+ export const LIST_MODEL_REREAD_MS = 60_000
18
+ const MEMO_MAX = 2_000
19
+
20
+ interface Memo { mtimeMs: number; size: number; readAt: number; model: string | null }
21
+ const memo = new Map<string, Memo>()
22
+ const inflight = new Set<string>()
23
+ let running = 0
24
+ const queue: Array<() => Promise<void>> = []
25
+ export const listModelStats = { reads: 0 }
26
+
27
+ type Provider = 'claude' | 'codex' | 'cursor'
28
+ const keyOf = (provider: Provider, file: string) => `${provider}:${file}`
29
+
30
+ /** The memo's model for this file, or undefined. Never reads the file's contents. A value read within the last
31
+ * minute is served even if the file has moved on since (a busy session's model rarely changes mid-minute). */
32
+ export function cachedListModel(provider: Provider, file: string | undefined, fileMtimeMs: number | null, now = Date.now()): string | undefined {
33
+ if (!file) return undefined
34
+ const m = memo.get(keyOf(provider, file))
35
+ if (!m || m.model === null) return undefined
36
+ // `modified` on a row is an ISO time (whole milliseconds); the memo holds the stat's fractional mtime.
37
+ if (fileMtimeMs === null || Math.floor(m.mtimeMs) === Math.floor(fileMtimeMs) || now - m.readAt < LIST_MODEL_REREAD_MS) return m.model
38
+ return undefined
39
+ }
40
+
41
+ async function readModel(provider: Provider, file: string): Promise<string | null> {
42
+ listModelStats.reads++
43
+ if (provider === 'claude') return readClaudeReportedModel(file)
44
+ if (provider === 'codex') return sanitizeReportedModel(codexReportedModel(await readCodexSessionPosture(file)))
45
+ return null
46
+ }
47
+
48
+ function pump(): void {
49
+ while (running < LIST_MODEL_WARM_CONCURRENCY && queue.length) {
50
+ const job = queue.shift()!
51
+ running++
52
+ void job().finally(() => { running--; pump() })
53
+ }
54
+ }
55
+
56
+ /** Queue background reads for the rows the memo misses. Returns at once; never throws. */
57
+ export function warmListModels(rows: ReadonlyArray<{ provider: Provider; file?: string }>, now = Date.now()): void {
58
+ for (const row of rows) {
59
+ if (row.provider === 'cursor' || !row.file) continue
60
+ const key = keyOf(row.provider, row.file)
61
+ if (inflight.has(key)) continue
62
+ const m = memo.get(key)
63
+ if (m && now - m.readAt < LIST_MODEL_REREAD_MS) continue
64
+ inflight.add(key)
65
+ const { provider, file } = row
66
+ queue.push(async () => {
67
+ try {
68
+ const st = await stat(file)
69
+ const prev = memo.get(key)
70
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) { prev.readAt = Date.now(); return }
71
+ const model = await readModel(provider, file)
72
+ memo.delete(key)
73
+ memo.set(key, { mtimeMs: st.mtimeMs, size: st.size, readAt: Date.now(), model })
74
+ while (memo.size > MEMO_MAX) memo.delete(memo.keys().next().value!)
75
+ } catch { /* unknown stays unknown */ } finally { inflight.delete(key) }
76
+ })
77
+ }
78
+ pump()
79
+ }
80
+
81
+ /** Tests only. */
82
+ export async function __drainListModelWarmForTests(): Promise<void> {
83
+ while (running > 0 || queue.length > 0) await new Promise(resolve => setTimeout(resolve, 5))
84
+ }
85
+ export function __resetListModelMemoForTests(): void { memo.clear(); inflight.clear(); queue.length = 0; listModelStats.reads = 0 }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Each agent's efforts for /api/models (Sessions chat-style layout, step 2a; S1_CONTRACT_step2a.md section C).
3
+ *
4
+ * One source for every picker, web and Mac: ghost report item 6 found the model and effort lists hard-coded in seven
5
+ * places. The values are exactly what POST /api/query-jobs accepts as `effort` (EFFORT_OPTIONS; anything else is
6
+ * dropped by the job route today, ghost item 5), and each entry says what the agent actually runs with it, from the
7
+ * same mapping functions the bridges call. Cursor's bridge takes no effort, so its list is empty.
8
+ */
9
+ import {
10
+ DEFAULT_EFFORT,
11
+ EFFORT_OPTIONS,
12
+ effortLabel,
13
+ resolveCliEffortFlag,
14
+ resolveCodexReasoningEffort,
15
+ type EffortPreference,
16
+ } from '../../shared/model-preference.js'
17
+ import { resolveOllamaThink } from './ollama-bridge.js'
18
+
19
+ export interface EffortChoice { value: EffortPreference; label: string; runs: string }
20
+ export interface ModelEfforts {
21
+ default: EffortPreference
22
+ byAgent: { claude: EffortChoice[]; codex: EffortChoice[]; ollama: EffortChoice[]; cursor: EffortChoice[] }
23
+ }
24
+
25
+ export function modelEfforts(env: NodeJS.ProcessEnv = process.env): ModelEfforts {
26
+ const choices = (runs: (effort: EffortPreference) => string): EffortChoice[] =>
27
+ EFFORT_OPTIONS.map(value => ({ value, label: effortLabel(value), runs: runs(value) }))
28
+ return {
29
+ default: DEFAULT_EFFORT,
30
+ byAgent: {
31
+ // `--effort`; Ultracode is a COS choice that runs Claude at xhigh.
32
+ claude: choices(e => resolveCliEffortFlag(e)),
33
+ // `model_reasoning_effort`.
34
+ codex: choices(e => resolveCodexReasoningEffort(e)),
35
+ // Ollama's `think` (COS_OLLAMA_THINK, when set, pins it whatever the effort).
36
+ ollama: choices(e => { const think = resolveOllamaThink(env.COS_OLLAMA_THINK, e); return think === false ? 'off' : think === true ? 'on' : think }),
37
+ cursor: [],
38
+ },
39
+ }
40
+ }