@gotcos/glasses-server 7.0.18 → 7.0.27
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.
- package/CHANGELOG.md +135 -0
- package/package.json +1 -1
- package/server/index.ts +3 -0
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-store.ts +99 -17
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-stream-events.ts +14 -2
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/thread-turn-queue.ts +34 -5
- package/server/web-control/.manifest.json +7 -7
- package/server/web-control/app.css +9 -0
- package/server/web-control/app.js +732 -56
- package/server/web-control/index.html +40 -16
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/today-actions.js +46 -0
- package/server/web-control/panes/work.js +7 -3
- package/server/web-control/theme.js +33 -0
|
@@ -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
|
|
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 (
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
|
1299
|
-
const
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
+
}
|