@gotcos/glasses-server 6.45.1 → 6.45.3
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 +19 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/agent-session-store.ts +73 -14
- package/server/lib/archive.ts +2 -1
- package/server/lib/conversation.ts +48 -4
- package/server/lib/meeting-batch-progress.ts +68 -14
- package/server/lib/occupancy-probes.ts +278 -1
- package/server/lib/recent-messages.ts +56 -0
- package/server/lib/workspace-skills.ts +209 -0
- package/server/routes/agent-sessions.ts +46 -11
- package/server/routes/archive.ts +3 -0
- package/server/routes/sessions.ts +25 -22
- package/server/routes/skills.ts +13 -0
- package/server/routes/voice.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## 6.45.3
|
|
2
|
+
|
|
3
|
+
The phone can list the skills this Mac will actually run.
|
|
4
|
+
|
|
5
|
+
- `GET /api/skills` returns the slash catalog from the selected workspace (`.agents/skills`, then `.claude/skills`) plus `~/.claude/skills`. Nested agent folders flatten with `:`. Generated `source-command-*` duplicates stay out. Paths never leave the box.
|
|
6
|
+
- COS Glasses 6.9.468 paints that list on Views instead of a hardcoded cheat sheet.
|
|
7
|
+
- A held voice can be heard before it is named. `GET /api/voice/ext-audio` lists `chunkIndices` per held session and `GET /api/voice/ext-audio/:sessionId/sample?chunk=<index>` serves that one chunk (no `chunk` still serves the newest). COS Control's Add-a-voice panel uses it to play portions of a session before naming it (Queen, 2026-09-12: "no way to listen to the voices that are here").
|
|
8
|
+
- Recent is a rolling window. `GET /api/sessions/today/all-messages` keeps its path for COS Control and the phone but now answers with the newest 30 messages (`?limit=` up to 100) across every live session and as many archived days as it takes, deduplicated against their archive mirrors. Messages leave Recent only by ageing out of the window; nothing is hidden by the calendar. Control's Recent view and the phone's history recovery read this without change.
|
|
9
|
+
- Yesterday's turns no longer vanish for a day. The archive mirror ran every 24 h from boot (21:25 on the current uptime), so finished sessions from the previous local day were neither "today" for `GET /api/sessions/today/all-messages` nor in any day archive until the evening; on 2026-09-12 07:00 Control showed no turns while ten from the 11th sat live in `sessions.json`. The mirror now runs at boot, at the next local midnight, and lazily from the today and archive-listing routes, once per local day. `checkYesterdayArchive` keys on the local day like everything else.
|
|
10
|
+
|
|
11
|
+
## 6.45.2
|
|
12
|
+
|
|
13
|
+
The G2 Sessions list answers in under a second again instead of eleven, and loading it no longer stalls the rest of the server.
|
|
14
|
+
|
|
15
|
+
- Memoize Claude Desktop alias heads by file mtime and share one alias map across a list request. `findAgentSessionFile` rebuilt the map from every `local_*.json` (564 files, 119 MB) and stat'ed every transcript on each call, and the list route called it once per live row and once per held thread: sixteen loads per request measured 2026-09-10. It is one load now, and an unchanged file costs one lstat. The finder stats only the transcript it matched.
|
|
16
|
+
- Probe Codex writer locks with one batched, asynchronous `lsof` per list, started alongside the walk and cached for ten seconds, served stale for up to a minute while a refresh runs behind it. The per-lock synchronous probe cost 2.3 s of whole-system descriptor scan per listed Codex row with a lock on disk and blocked the event loop for that long; a `/api/health` poll measured 2.5 s during one list. The attach and turn gates still run the synchronous probe at the moment of the write; the snapshot only feeds the display hint. The detail route reads the same snapshot.
|
|
17
|
+
- Measured on the same Mac, same eight running rows: cold list 2.6 s (was 11.4 to 12.5 s), warm list 0.5 s, detail open 72 ms (was about 650 ms), longest health poll during a list 19 ms (was 2,524 ms). `lsof` exits 1 whenever any requested file has no holder while still listing the others; the batch reads that as the ordinary mixed result rather than a failed probe.
|
|
18
|
+
- Match the resolved spelling of a lock path. `lsof` prints the kernel's path (`/private/var/...` for a `/var/...` lock on macOS, and the target of any symlinked `CODEX_HOME`), so a holder reached through a symlink read as nobody. The attach and turn gates never depended on that reading; the running hint did.
|
|
19
|
+
|
|
1
20
|
## 6.45.1
|
|
2
21
|
|
|
3
22
|
Knowledge opens with a bounded graph and supports verified question plans for COS Control 0.5.208.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.45.
|
|
3
|
+
"version": "6.45.3",
|
|
4
4
|
"description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { liveCuesRouter } from './routes/live-cues.js'
|
|
|
62
62
|
import { tasksRouter } from './routes/tasks.js'
|
|
63
63
|
import { memoryRouter } from './routes/memory.js'
|
|
64
64
|
import { threadsRouter } from './routes/threads.js'
|
|
65
|
+
import { skillsRouter } from './routes/skills.js'
|
|
65
66
|
import { shutdownLiveCues } from './lib/live-cues-engine.js'
|
|
66
67
|
import { prewarmContext } from './lib/context-builder.js'
|
|
67
68
|
import { preWarmCLI } from './lib/claude-bridge.js'
|
|
@@ -671,6 +672,7 @@ app.use('/api', meetingRouter)
|
|
|
671
672
|
app.use('/api', meetingsRouter)
|
|
672
673
|
app.use('/api', memoryRouter)
|
|
673
674
|
app.use('/api', threadsRouter)
|
|
675
|
+
app.use('/api', skillsRouter)
|
|
674
676
|
app.use('/api', openaiKeyRouter)
|
|
675
677
|
// v6.3.0 — Message History, cross-day 'reference message N', and history
|
|
676
678
|
// recovery for public npx users (previously full-COS-server only).
|
|
@@ -709,10 +709,41 @@ export function peekClaudeDesktopHead(text: string): { title: string; cwd: strin
|
|
|
709
709
|
|
|
710
710
|
type ClaudeDesktopHead = ReturnType<typeof peekClaudeDesktopHead> & { file: string; mtimeMs: number }
|
|
711
711
|
|
|
712
|
+
/** The alias map one request shares. Null marks an id whose alias records conflict. */
|
|
713
|
+
export type ClaudeAliasMap = Map<string, ClaudeDesktopHead | null>
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Per-file memo of the parsed desktop head, keyed by path, invalidated by (mtime, size).
|
|
717
|
+
*
|
|
718
|
+
* MEASURED 2026-09-10 on the owner's Mac: 564 `local_*.json` records, 292 of them
|
|
719
|
+
* over the 256 KB head window, 119 MB read and prefix-parsed per load, 635 ms per
|
|
720
|
+
* load — and the list route loaded it SIXTEEN times per request (twice in the walk,
|
|
721
|
+
* once per live row, once per held thread), which was 1.9 GB and ten of the twelve
|
|
722
|
+
* seconds the G2 Sessions page waited. 547 of the 564 files were older than a week
|
|
723
|
+
* and had not changed. A record is re-read only when its stat moves; an unchanged
|
|
724
|
+
* file costs one lstat. Entries for files that vanish are dropped on the next load
|
|
725
|
+
* of their root, so the memo never outgrows the directory it mirrors.
|
|
726
|
+
*
|
|
727
|
+
* `desktopAliasStats` is the test seam: a load that hits the memo reports it there,
|
|
728
|
+
* so "read once per mtime" is asserted by counting reads rather than by timing.
|
|
729
|
+
*/
|
|
730
|
+
const desktopHeadMemo = new Map<string, { mtimeMs: number; size: number; head: ReturnType<typeof peekClaudeDesktopHead> }>()
|
|
731
|
+
export const desktopAliasStats = { loads: 0, reads: 0, memoHits: 0 }
|
|
732
|
+
|
|
733
|
+
/** Tests only: forget every memoized head and zero the counters. */
|
|
734
|
+
export function resetDesktopAliasMemo(): void {
|
|
735
|
+
desktopHeadMemo.clear()
|
|
736
|
+
desktopAliasStats.loads = 0
|
|
737
|
+
desktopAliasStats.reads = 0
|
|
738
|
+
desktopAliasStats.memoHits = 0
|
|
739
|
+
}
|
|
740
|
+
|
|
712
741
|
/** Explicit Desktop→CLI aliases only. A conflicting alias cannot select a transcript. */
|
|
713
|
-
export async function loadClaudeDesktopAliases(root: string): Promise<
|
|
714
|
-
const aliases = new Map
|
|
742
|
+
export async function loadClaudeDesktopAliases(root: string): Promise<ClaudeAliasMap> {
|
|
743
|
+
const aliases: ClaudeAliasMap = new Map()
|
|
715
744
|
if (!root) return aliases
|
|
745
|
+
desktopAliasStats.loads += 1
|
|
746
|
+
const seen = new Set<string>()
|
|
716
747
|
for (const account of await dirents(root)) {
|
|
717
748
|
for (const workspace of await dirents(join(root, account))) {
|
|
718
749
|
const dir = join(root, account, workspace)
|
|
@@ -722,7 +753,18 @@ export async function loadClaudeDesktopAliases(root: string): Promise<Map<string
|
|
|
722
753
|
if (!CLAUDE_UUID_JSONL.test(id + '.jsonl')) continue
|
|
723
754
|
const file = join(dir, name), st = await fileStat(file)
|
|
724
755
|
if (!st?.isFile) continue
|
|
725
|
-
|
|
756
|
+
seen.add(file)
|
|
757
|
+
const memo = desktopHeadMemo.get(file)
|
|
758
|
+
let peeked: ReturnType<typeof peekClaudeDesktopHead>
|
|
759
|
+
if (memo && memo.mtimeMs === st.mtimeMs && memo.size === st.size) {
|
|
760
|
+
peeked = memo.head
|
|
761
|
+
desktopAliasStats.memoHits += 1
|
|
762
|
+
} else {
|
|
763
|
+
peeked = peekClaudeDesktopHead(await readWindow(file, false))
|
|
764
|
+
desktopHeadMemo.set(file, { mtimeMs: st.mtimeMs, size: st.size, head: peeked })
|
|
765
|
+
desktopAliasStats.reads += 1
|
|
766
|
+
}
|
|
767
|
+
const head = { ...peeked, file, mtimeMs: st.mtimeMs }
|
|
726
768
|
const previous = aliases.get(id)
|
|
727
769
|
if (previous === null) continue
|
|
728
770
|
if (previous && previous.cliSessionId !== head.cliSessionId) aliases.set(id, null)
|
|
@@ -730,6 +772,10 @@ export async function loadClaudeDesktopAliases(root: string): Promise<Map<string
|
|
|
730
772
|
}
|
|
731
773
|
}
|
|
732
774
|
}
|
|
775
|
+
const prefix = root.endsWith('/') ? root : root + '/'
|
|
776
|
+
for (const key of desktopHeadMemo.keys()) {
|
|
777
|
+
if (key.startsWith(prefix) && !seen.has(key)) desktopHeadMemo.delete(key)
|
|
778
|
+
}
|
|
733
779
|
return aliases
|
|
734
780
|
}
|
|
735
781
|
|
|
@@ -932,8 +978,9 @@ export async function listClaudeSessions(
|
|
|
932
978
|
starredIds: ReadonlySet<string> = new Set(),
|
|
933
979
|
desktopSessionsRoot = '',
|
|
934
980
|
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
981
|
+
preloadedAliases?: ClaudeAliasMap,
|
|
935
982
|
): Promise<AgentSessionRow[]> {
|
|
936
|
-
const aliases = await loadClaudeDesktopAliases(desktopSessionsRoot)
|
|
983
|
+
const aliases = preloadedAliases ?? await loadClaudeDesktopAliases(desktopSessionsRoot)
|
|
937
984
|
const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
|
|
938
985
|
const canonicalStars = new Set([...starredIds].map(canonical))
|
|
939
986
|
const canonicalLive = new Set([...liveIds].map(canonical))
|
|
@@ -1210,9 +1257,9 @@ export async function listCursorSessions(
|
|
|
1210
1257
|
]
|
|
1211
1258
|
}
|
|
1212
1259
|
|
|
1213
|
-
async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots): Promise<AgentSessionRow> {
|
|
1260
|
+
async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots, aliases?: ClaudeAliasMap): Promise<AgentSessionRow> {
|
|
1214
1261
|
if (row.provider !== 'claude') return row
|
|
1215
|
-
const found = await findAgentSessionFile('claude', row.session_id, roots)
|
|
1262
|
+
const found = await findAgentSessionFile('claude', row.session_id, roots, new Date(), aliases)
|
|
1216
1263
|
if (!found) return row
|
|
1217
1264
|
const peek = await peekClaudeDiscussion(found)
|
|
1218
1265
|
const firstPrompt = await firstClaudeUserTitle(found)
|
|
@@ -1275,14 +1322,17 @@ export async function listAgentSessions(
|
|
|
1275
1322
|
limit = AGENT_SESSION_LIST_LIMIT,
|
|
1276
1323
|
sort: AgentSessionSort = 'updated',
|
|
1277
1324
|
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
1325
|
+
preloadedAliases?: ClaudeAliasMap,
|
|
1278
1326
|
): Promise<AgentSessionRow[]> {
|
|
1279
1327
|
const originalStars = await loadClaudeStarredIds(roots.claudeDesktopConfig)
|
|
1280
|
-
|
|
1328
|
+
// ONE alias load for the whole walk. Before this the walk loaded it here AND
|
|
1329
|
+
// inside listClaudeSessions AND once per live row through the finder.
|
|
1330
|
+
const aliases = preloadedAliases ?? await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
1281
1331
|
const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
|
|
1282
1332
|
const starredIds = new Set([...originalStars].map(canonical))
|
|
1283
1333
|
live = live.map(row => row.provider === 'claude' ? { ...row, session_id: canonical(normalizeClaudeSessionId(row.session_id)) } : row)
|
|
1284
1334
|
const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
|
|
1285
|
-
const enrichedLive = (await Promise.all(live.map(row => enrichLiveClaude(row, roots))))
|
|
1335
|
+
const enrichedLive = (await Promise.all(live.map(row => enrichLiveClaude(row, roots, aliases))))
|
|
1286
1336
|
.filter(entry => !isKeepWarmSessionTitle(entry.display_label))
|
|
1287
1337
|
.map(entry => {
|
|
1288
1338
|
if (entry.provider === 'claude' && starredIds.has(normalizeClaudeSessionId(entry.session_id))) {
|
|
@@ -1297,7 +1347,7 @@ export async function listAgentSessions(
|
|
|
1297
1347
|
const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
|
|
1298
1348
|
let rows = dedupeSessions([
|
|
1299
1349
|
...enrichedLive,
|
|
1300
|
-
...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped),
|
|
1350
|
+
...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped, aliases),
|
|
1301
1351
|
...await listCodexSessions(roots.codexSessions, now, cap, dropped),
|
|
1302
1352
|
...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped),
|
|
1303
1353
|
])
|
|
@@ -1326,20 +1376,26 @@ export async function findAgentSessionFile(
|
|
|
1326
1376
|
sessionId: string,
|
|
1327
1377
|
roots: AgentSessionRoots,
|
|
1328
1378
|
now = new Date(),
|
|
1379
|
+
preloadedAliases?: ClaudeAliasMap,
|
|
1329
1380
|
): Promise<string | null> {
|
|
1330
1381
|
if (!isSafeSessionId(sessionId)) return null
|
|
1331
1382
|
const needle = sessionId.trim().toLowerCase()
|
|
1332
1383
|
if (provider === 'claude') {
|
|
1333
|
-
|
|
1384
|
+
// A caller that resolves several ids in one request (the list route: every
|
|
1385
|
+
// live row, then every held thread) hands the map in; each call used to
|
|
1386
|
+
// rebuild it from disk, which is the 16-loads-per-request the memo above
|
|
1387
|
+
// describes. Absent, the memo still makes a rebuild cheap.
|
|
1388
|
+
const aliases = preloadedAliases ?? await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
1389
|
+
// Names only. This used to lstat every transcript on this Mac (1,697 of
|
|
1390
|
+
// them) to keep directories out of the candidate set, on every call. The
|
|
1391
|
+
// isFile check now runs on the one id that matched, below.
|
|
1334
1392
|
const files = new Map<string, string[]>()
|
|
1335
1393
|
for (const folder of await dirents(roots.claudeProjects)) {
|
|
1336
1394
|
const dir = join(roots.claudeProjects, folder)
|
|
1337
1395
|
for (const name of await dirents(dir)) {
|
|
1338
1396
|
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
1339
|
-
const file = join(dir, name)
|
|
1340
|
-
if (!(await fileStat(file))?.isFile) continue
|
|
1341
1397
|
const id = name.slice(0, -6).toLowerCase()
|
|
1342
|
-
files.set(id, [...(files.get(id) || []),
|
|
1398
|
+
files.set(id, [...(files.get(id) || []), join(dir, name)])
|
|
1343
1399
|
}
|
|
1344
1400
|
}
|
|
1345
1401
|
const exact = aliases.has(needle) || files.has(needle)
|
|
@@ -1351,7 +1407,10 @@ export async function findAgentSessionFile(
|
|
|
1351
1407
|
}
|
|
1352
1408
|
for (const id of files.keys()) if (exact ? id === needle : id.startsWith(needle)) ids.add(id)
|
|
1353
1409
|
if (ids.size !== 1) return null
|
|
1354
|
-
const matches
|
|
1410
|
+
const matches: string[] = []
|
|
1411
|
+
for (const file of files.get([...ids][0]) || []) {
|
|
1412
|
+
if ((await fileStat(file))?.isFile) matches.push(file)
|
|
1413
|
+
}
|
|
1355
1414
|
return matches.length === 1 ? matches[0] : null
|
|
1356
1415
|
}
|
|
1357
1416
|
if (provider === 'codex') {
|
package/server/lib/archive.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Each day's archive contains one or more "chats" (split by context breaks)
|
|
4
4
|
// Summaries are generated via `claude -p --model sonnet`, budget-capped per day.
|
|
5
5
|
|
|
6
|
+
import { localDay } from './local-day.js'
|
|
6
7
|
import { chmodSync, mkdirSync, readdirSync } from 'node:fs'
|
|
7
8
|
import { resolve, dirname } from 'node:path'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
@@ -549,7 +550,7 @@ export function getArchiveDayMessages(
|
|
|
549
550
|
|
|
550
551
|
/** Check if yesterday needs archiving (handles overnight server restarts) */
|
|
551
552
|
export function checkYesterdayArchive(): void {
|
|
552
|
-
const yesterday =
|
|
553
|
+
const yesterday = localDay(Date.now() - 86_400_000)
|
|
553
554
|
const existing = loadArchive(yesterday)
|
|
554
555
|
if (!existing) {
|
|
555
556
|
// No yesterday archive exists — but we can't archive sessions that are already expired
|
|
@@ -288,11 +288,55 @@ async function runDailyArchiveMirror(): Promise<void> {
|
|
|
288
288
|
if (mirrored > 0) updateGlassesSessionCache()
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// 6.45.3 — the mirror runs at every LOCAL DAY ROLLOVER, not only every 24 h from
|
|
292
|
+
// boot. With a server started at 21:24 the interval landed at 21:25 each evening,
|
|
293
|
+
// so a whole day's finished sessions sat in a blind spot until then: not "today"
|
|
294
|
+
// for `GET /api/sessions/today/all-messages`, not yet in any day archive. On
|
|
295
|
+
// 2026-09-12 07:00 Control showed "No turns today" (true) and no archive for
|
|
296
|
+
// 2026-09-11 (ten turns, #125 to #134, still live in sessions.json). Three
|
|
297
|
+
// triggers now: boot, a timer aimed at the next local midnight, and a lazy check
|
|
298
|
+
// from the routes that read the day views, so yesterday appears on the first
|
|
299
|
+
// read after midnight. Serialized: one run at a time, once per local day.
|
|
300
|
+
let lastMirrorDay = ''
|
|
301
|
+
let mirrorInFlight: Promise<void> | null = null
|
|
302
|
+
|
|
303
|
+
/** Milliseconds until thirty seconds past the next local midnight; never less than a second. */
|
|
304
|
+
export function msUntilNextLocalDay(now = Date.now()): number {
|
|
305
|
+
const d = new Date(now)
|
|
306
|
+
const next = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 30)
|
|
307
|
+
return Math.max(1_000, next.getTime() - now)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Mirror prior-day sessions once per local day. Returns true when a run happened. */
|
|
311
|
+
export async function ensureArchiveMirrorForDay(now = Date.now()): Promise<boolean> {
|
|
312
|
+
const today = localDay(now)
|
|
313
|
+
if (lastMirrorDay === today) return false
|
|
314
|
+
if (!mirrorInFlight) {
|
|
315
|
+
mirrorInFlight = runDailyArchiveMirror()
|
|
316
|
+
.then(() => { lastMirrorDay = today })
|
|
317
|
+
.finally(() => { mirrorInFlight = null })
|
|
318
|
+
}
|
|
319
|
+
await mirrorInFlight
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Test seam: forget the last mirrored day. */
|
|
324
|
+
export function __resetArchiveMirrorForTests(): void {
|
|
325
|
+
lastMirrorDay = ''
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scheduleArchiveMirrorAtNextLocalDay(): void {
|
|
329
|
+
const timer = setTimeout(() => {
|
|
330
|
+
ensureArchiveMirrorForDay()
|
|
331
|
+
.catch(err => console.error('[conversation] mirror rollover error:', err))
|
|
332
|
+
.finally(scheduleArchiveMirrorAtNextLocalDay)
|
|
333
|
+
}, msUntilNextLocalDay())
|
|
334
|
+
timer.unref?.()
|
|
335
|
+
}
|
|
336
|
+
|
|
291
337
|
// Fire-and-forget at boot so module load isn't blocked on disk + LLM fallback I/O.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
runDailyArchiveMirror().catch(err => console.error('[conversation] mirror interval error:', err))
|
|
295
|
-
}, 24 * 60 * 60_000)
|
|
338
|
+
ensureArchiveMirrorForDay().catch(err => console.error('[conversation] mirror boot error:', err))
|
|
339
|
+
scheduleArchiveMirrorAtNextLocalDay()
|
|
296
340
|
|
|
297
341
|
// Track whether session is brand new (for first-query notification)
|
|
298
342
|
const newSessions = new Set<string>()
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
6
6
|
import { basename, join } from 'node:path'
|
|
7
7
|
import { dataPath } from './data-dir.js'
|
|
8
|
+
import { MeetingFinalizationJobStore, type MeetingFinalizationJob } from './meeting-finalization-jobs.js'
|
|
8
9
|
import { listActiveRecoveries } from './unsaved-audio-quarantine.js'
|
|
9
10
|
|
|
10
11
|
export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
|
|
@@ -211,27 +212,69 @@ function markerFresh(dir: string, maxAgeMs = 15 * 60_000): boolean {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
export interface MeetingSyncSnapshotOptions {
|
|
216
|
+
/** Test seam. Production health omits this and reads the durable job store
|
|
217
|
+
* when `root` is the live pending-batch directory. Custom roots stay empty
|
|
218
|
+
* unless the caller injects jobs, so unit tests cannot inherit live handoff. */
|
|
219
|
+
finalizationJobs?: MeetingFinalizationJob[]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function listFinalizationJobsForSnapshot(
|
|
223
|
+
root: string,
|
|
224
|
+
options?: MeetingSyncSnapshotOptions,
|
|
225
|
+
): MeetingFinalizationJob[] {
|
|
226
|
+
if (options && 'finalizationJobs' in options) return options.finalizationJobs ?? []
|
|
227
|
+
if (root !== pendingBatchRoot()) return []
|
|
228
|
+
try {
|
|
229
|
+
return new MeetingFinalizationJobStore().list()
|
|
230
|
+
} catch {
|
|
231
|
+
return []
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function finalizationSyncRow(job: MeetingFinalizationJob): MeetingSyncMeeting {
|
|
236
|
+
const phase: MeetingSyncMeeting['phase'] = job.phase === 'ops_pending'
|
|
237
|
+
? 'persisting'
|
|
238
|
+
: job.phase === 'batch_pending'
|
|
239
|
+
? 'hq_polish'
|
|
240
|
+
: 'queued'
|
|
241
|
+
const label = job.phase === 'ops_pending'
|
|
242
|
+
? 'Saving to meeting library · do not update/restart'
|
|
243
|
+
: job.phase === 'batch_pending'
|
|
244
|
+
? 'HQ polish · pending handoff'
|
|
245
|
+
: 'Finishing capture · do not update/restart'
|
|
246
|
+
return {
|
|
247
|
+
meetingId: job.sessionId,
|
|
248
|
+
phase,
|
|
249
|
+
percent: null,
|
|
250
|
+
segmentsDone: null,
|
|
251
|
+
segmentsTotal: null,
|
|
252
|
+
chunkFiles: 0,
|
|
253
|
+
updatedAt: job.updatedAt,
|
|
254
|
+
label,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
214
258
|
/** Snapshot of pending HQ polish work for /api/health and COS Control. */
|
|
215
259
|
export function getMeetingSyncSnapshot(
|
|
216
260
|
root: string = pendingBatchRoot(),
|
|
261
|
+
options?: MeetingSyncSnapshotOptions,
|
|
217
262
|
): MeetingSyncSnapshot {
|
|
218
263
|
const meetings: MeetingSyncMeeting[] = []
|
|
219
264
|
const retained: MeetingSyncRetainedMeeting[] = []
|
|
220
|
-
if (!existsSync(root)) {
|
|
221
|
-
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
|
|
222
|
-
}
|
|
223
|
-
|
|
224
265
|
let dirs: string[] = []
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
266
|
+
if (existsSync(root)) {
|
|
267
|
+
try {
|
|
268
|
+
dirs = readdirSync(root).filter(name => {
|
|
269
|
+
try {
|
|
270
|
+
return statSync(join(root, name)).isDirectory()
|
|
271
|
+
} catch {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
} catch {
|
|
276
|
+
dirs = []
|
|
277
|
+
}
|
|
235
278
|
}
|
|
236
279
|
|
|
237
280
|
for (const name of dirs) {
|
|
@@ -341,6 +384,17 @@ export function getMeetingSyncSnapshot(
|
|
|
341
384
|
})
|
|
342
385
|
}
|
|
343
386
|
|
|
387
|
+
// After HQ polish clears pending-batch progress, the durable finalizer still
|
|
388
|
+
// holds meeting_batch_finalization while it writes the library copy. Control
|
|
389
|
+
// then showed Meeting sync Idle with restart locked (2026-09-09 local 6.45.1
|
|
390
|
+
// rollout). Same contract as active recoveries: this window is active work.
|
|
391
|
+
const seen = new Set(meetings.map(meeting => meeting.meetingId))
|
|
392
|
+
for (const job of listFinalizationJobsForSnapshot(root, options)) {
|
|
393
|
+
if (seen.has(job.sessionId)) continue
|
|
394
|
+
seen.add(job.sessionId)
|
|
395
|
+
meetings.push(finalizationSyncRow(job))
|
|
396
|
+
}
|
|
397
|
+
|
|
344
398
|
if (meetings.length === 0) {
|
|
345
399
|
const label = retained.length > 0
|
|
346
400
|
? `Idle · ${retained.length} retained batch${retained.length === 1 ? '' : 'es'}`
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
// That is the intended chain: detector_unavailable is for "no such install",
|
|
35
35
|
// probe_failed is for "the mechanism exists and broke".
|
|
36
36
|
|
|
37
|
-
import { execFileSync } from 'node:child_process'
|
|
37
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
38
|
+
import { readdir } from 'node:fs/promises'
|
|
38
39
|
import {
|
|
39
40
|
closeSync,
|
|
40
41
|
constants as fsConstants,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
openSync,
|
|
45
46
|
readFileSync,
|
|
46
47
|
readdirSync,
|
|
48
|
+
realpathSync,
|
|
47
49
|
statSync,
|
|
48
50
|
} from 'node:fs'
|
|
49
51
|
import { homedir } from 'node:os'
|
|
@@ -358,6 +360,7 @@ export function lockHolders(path: string): number[] {
|
|
|
358
360
|
// `--` terminates option parsing so a path can never be read as a flag; `-w`
|
|
359
361
|
// suppresses mount-point warnings that would otherwise make a successful probe
|
|
360
362
|
// look like the failure case below.
|
|
363
|
+
lockSnapshotStats.single += 1
|
|
361
364
|
return interpretLockHolders(runProbe(LSOF_BIN, ['-w', '-t', '--', path], LOCK_PROBE_TIMEOUT_MS), path)
|
|
362
365
|
}
|
|
363
366
|
|
|
@@ -407,6 +410,280 @@ export function interpretLockHolders(out: ProbeOutcome, path: string): number[]
|
|
|
407
410
|
return [...pids]
|
|
408
411
|
}
|
|
409
412
|
|
|
413
|
+
// ------------------------------------------------------------- batched lock probe
|
|
414
|
+
//
|
|
415
|
+
// MEASURED 2026-09-10. One `lsof -t` on one lock is a whole-system descriptor scan:
|
|
416
|
+
// 2.25 to 2.35 s on this Mac across three runs, and `lockHolders` runs it with
|
|
417
|
+
// `execFileSync`, so every listed Codex row that still has a writer lock on disk
|
|
418
|
+
// froze the ENTIRE glasses server for that long — a `/api/health` poll measured
|
|
419
|
+
// 2.52 s during one list request. Seven locks sat in `thread-writer-locks`, all held
|
|
420
|
+
// open by the Codex app. Passing all seven paths to a single lsof cost 3.07 s, one
|
|
421
|
+
// scan instead of seven, and the async form leaves the event loop free.
|
|
422
|
+
//
|
|
423
|
+
// THIS IS FOR THE DISPLAY HINT ONLY. `lockHolders` above is unchanged and is what
|
|
424
|
+
// the write gate (`threadOccupancy` at attach and turn time) keeps calling: a fresh
|
|
425
|
+
// synchronous probe at the moment of the write. A snapshot can be up to
|
|
426
|
+
// LOCK_SNAPSHOT_TTL_MS stale, which is fine for a badge and not for a gate.
|
|
427
|
+
//
|
|
428
|
+
// Doubt stays doubt. A path lsof named in a status error is checked with our own
|
|
429
|
+
// lstat exactly as the single probe does: absent means no holders, anything else is
|
|
430
|
+
// recorded as doubt and the wrapper THROWS for that path, so the scan marks the
|
|
431
|
+
// thread degraded rather than free.
|
|
432
|
+
|
|
433
|
+
/** How long a lock snapshot is FRESH: a list inside this window spawns nothing. */
|
|
434
|
+
export const LOCK_SNAPSHOT_TTL_MS = 10_000
|
|
435
|
+
/**
|
|
436
|
+
* How long a STALE snapshot may still answer while a refresh runs behind it. A
|
|
437
|
+
* reopen between 10 s and 60 s after the last probe gets the old badge at once and
|
|
438
|
+
* the new one on the next poll; past 60 s the list waits for a fresh probe. The
|
|
439
|
+
* held-lock badge changes on the scale of minutes (a Codex window opening or
|
|
440
|
+
* closing), so a minute of lag on a display hint is the whole price.
|
|
441
|
+
*/
|
|
442
|
+
export const LOCK_SNAPSHOT_MAX_AGE_MS = 60_000
|
|
443
|
+
|
|
444
|
+
export interface LockHolderSnapshot {
|
|
445
|
+
/** Locks directory the snapshot describes. */
|
|
446
|
+
dir: string
|
|
447
|
+
/** Epoch ms the probe finished. */
|
|
448
|
+
at: number
|
|
449
|
+
/** Holders per lock path. A path present with [] was probed and nobody holds it. */
|
|
450
|
+
holders: Map<string, number[]>
|
|
451
|
+
/** Paths the probe could not settle, with the reason. */
|
|
452
|
+
doubt: Map<string, string>
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Counters for tests. `probes` is batched lsof runs; `single` is the synchronous
|
|
457
|
+
* per-lock `lockHolders` runs, which a list served from a snapshot must never make.
|
|
458
|
+
*/
|
|
459
|
+
export const lockSnapshotStats = { probes: 0, single: 0 }
|
|
460
|
+
|
|
461
|
+
function refuseUnlessLockPath(path: string): void {
|
|
462
|
+
if (typeof path !== 'string' || path.length === 0 || path.includes('\0')) {
|
|
463
|
+
throw new Error('batchLockHolders: refusing an unusable path')
|
|
464
|
+
}
|
|
465
|
+
const name = basename(path)
|
|
466
|
+
if (!name.endsWith('.lock') || !NATIVE_THREAD_ID_RE.test(name.slice(0, -'.lock'.length))) {
|
|
467
|
+
throw new Error('batchLockHolders: refusing a path that is not a <native-thread-id>.lock')
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Turn one batched `lsof -F pn` run into holders per requested path, or throw when
|
|
473
|
+
* the run as a whole cannot be trusted.
|
|
474
|
+
*
|
|
475
|
+
* Field output is one record per line: `p<pid>` opens a process, then `f<fd>` and
|
|
476
|
+
* `n<path>` pairs follow for each descriptor. Paths lsof was not asked about are
|
|
477
|
+
* ignored. A requested path missing from stdout is unheld unless stderr carries a
|
|
478
|
+
* `status error on <path>` for it, in which case our own lstat decides between
|
|
479
|
+
* absent (no holders) and doubt.
|
|
480
|
+
*/
|
|
481
|
+
/**
|
|
482
|
+
* The spellings under which lsof may report a requested lock.
|
|
483
|
+
*
|
|
484
|
+
* lsof prints the kernel's resolved path, not the caller's: a lock reached through
|
|
485
|
+
* macOS's `/var` symlink comes back as `/private/var/...`, and a symlinked
|
|
486
|
+
* CODEX_HOME does the same. Requested paths keep the caller's spelling as the map
|
|
487
|
+
* key; the match accepts either spelling. Unresolvable paths keep only the literal.
|
|
488
|
+
*/
|
|
489
|
+
function lsofPathSpellings(path: string): string[] {
|
|
490
|
+
try {
|
|
491
|
+
const resolved = realpathSync.native(path)
|
|
492
|
+
return resolved === path ? [path] : [path, resolved]
|
|
493
|
+
} catch {
|
|
494
|
+
return [path]
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function interpretBatchLockHolders(out: ProbeOutcome, paths: readonly string[]): { holders: Map<string, number[]>; doubt: Map<string, string> } {
|
|
499
|
+
const holders = new Map<string, number[]>()
|
|
500
|
+
const doubt = new Map<string, string>()
|
|
501
|
+
const wanted = new Set(paths)
|
|
502
|
+
const requestedBySpelling = new Map<string, string[]>()
|
|
503
|
+
for (const path of wanted) {
|
|
504
|
+
for (const spelling of lsofPathSpellings(path)) {
|
|
505
|
+
const list = requestedBySpelling.get(spelling) ?? []
|
|
506
|
+
list.push(path)
|
|
507
|
+
requestedBySpelling.set(spelling, list)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (!out.ok) {
|
|
511
|
+
if (out.spawnError !== null) throw new Error(`batchLockHolders: lsof unavailable (${out.spawnError})`)
|
|
512
|
+
if (out.killed) throw new Error('batchLockHolders: lsof timed out')
|
|
513
|
+
// MEASURED: lsof exits 1 whenever ANY requested file has no holder, while
|
|
514
|
+
// still printing the holders of the others. So exit 1 is the ordinary mixed
|
|
515
|
+
// reading for a batch and its stdout is parsed like a clean run; empty
|
|
516
|
+
// stdout on exit 1 is "nobody holds any of them". Only another status is a
|
|
517
|
+
// run this reading cannot trust.
|
|
518
|
+
if (out.status !== 1) {
|
|
519
|
+
throw new Error(`batchLockHolders: lsof failed (status=${out.status}) ${out.stderr.trim().slice(0, 160)}`)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const pidsByPath = new Map<string, Set<number>>()
|
|
523
|
+
let pid: number | null = null
|
|
524
|
+
for (const raw of out.stdout.split('\n')) {
|
|
525
|
+
const line = raw.trimEnd()
|
|
526
|
+
if (line.length === 0) continue
|
|
527
|
+
const tag = line[0], value = line.slice(1)
|
|
528
|
+
if (tag === 'p') {
|
|
529
|
+
if (!/^\d+$/.test(value)) throw new Error('batchLockHolders: unrecognised lsof output')
|
|
530
|
+
const parsed = Number(value)
|
|
531
|
+
if (!isProbablePid(parsed)) throw new Error('batchLockHolders: lsof reported an implausible pid')
|
|
532
|
+
pid = parsed
|
|
533
|
+
} else if (tag === 'n') {
|
|
534
|
+
if (pid === null) throw new Error('batchLockHolders: unrecognised lsof output')
|
|
535
|
+
const requested = requestedBySpelling.get(value)
|
|
536
|
+
if (!requested) continue
|
|
537
|
+
for (const path of requested) {
|
|
538
|
+
const set = pidsByPath.get(path) ?? new Set<number>()
|
|
539
|
+
set.add(pid)
|
|
540
|
+
pidsByPath.set(path, set)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// `f` and any other field is descriptor detail this reading does not use.
|
|
544
|
+
}
|
|
545
|
+
for (const path of wanted) {
|
|
546
|
+
const pids = pidsByPath.get(path)
|
|
547
|
+
if (pids) {
|
|
548
|
+
holders.set(path, [...pids])
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
if (out.stderr.includes(`status error on ${path}:`)) {
|
|
552
|
+
if (presence(path) === 'absent') holders.set(path, [])
|
|
553
|
+
else doubt.set(path, 'lsof could not inspect the lock')
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
holders.set(path, [])
|
|
557
|
+
}
|
|
558
|
+
return { holders, doubt }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function runProbeAsync(bin: string, args: string[], timeoutMs: number): Promise<ProbeOutcome> {
|
|
562
|
+
return new Promise(resolve => {
|
|
563
|
+
execFile(bin, args, {
|
|
564
|
+
encoding: 'utf8',
|
|
565
|
+
timeout: timeoutMs,
|
|
566
|
+
killSignal: 'SIGKILL',
|
|
567
|
+
maxBuffer: PROBE_MAX_BUFFER,
|
|
568
|
+
env: probeEnv(),
|
|
569
|
+
}, (error: any, stdout, stderr) => {
|
|
570
|
+
if (!error) {
|
|
571
|
+
resolve({ ok: true, stdout: String(stdout), stderr: String(stderr ?? ''), status: 0, killed: false, spawnError: null })
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
const spawned = typeof error?.status === 'number' || typeof error?.code === 'number' || typeof error?.signal === 'string'
|
|
575
|
+
resolve({
|
|
576
|
+
ok: false,
|
|
577
|
+
stdout: String(error?.stdout ?? stdout ?? ''),
|
|
578
|
+
stderr: String(error?.stderr ?? stderr ?? ''),
|
|
579
|
+
status: typeof error?.code === 'number' ? error.code : (typeof error?.status === 'number' ? error.status : null),
|
|
580
|
+
killed: Boolean(error?.killed) || Boolean(error?.signal),
|
|
581
|
+
spawnError: spawned ? null : String(error?.code ?? error?.message ?? 'spawn failed'),
|
|
582
|
+
})
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Holders for many locks in ONE lsof, off the event loop. Empty input spawns nothing.
|
|
589
|
+
* Throws when the run cannot be trusted; per-path doubt is returned, not thrown.
|
|
590
|
+
*/
|
|
591
|
+
export async function batchLockHolders(paths: readonly string[], timeoutMs = LOCK_PROBE_TIMEOUT_MS): Promise<{ holders: Map<string, number[]>; doubt: Map<string, string> }> {
|
|
592
|
+
const unique = [...new Set(paths)]
|
|
593
|
+
for (const path of unique) refuseUnlessLockPath(path)
|
|
594
|
+
if (unique.length === 0) return { holders: new Map(), doubt: new Map() }
|
|
595
|
+
lockSnapshotStats.probes += 1
|
|
596
|
+
const out = await runProbeAsync(LSOF_BIN, ['-w', '-F', 'pn', '--', ...unique], timeoutMs)
|
|
597
|
+
return interpretBatchLockHolders(out, unique)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
let lockSnapshot: LockHolderSnapshot | null = null
|
|
601
|
+
let lockSnapshotInFlight: { dir: string; promise: Promise<LockHolderSnapshot> } | null = null
|
|
602
|
+
|
|
603
|
+
/** Tests only: drop the cached snapshot and zero the probe counter. */
|
|
604
|
+
export function resetLockSnapshot(): void {
|
|
605
|
+
lockSnapshot = null
|
|
606
|
+
lockSnapshotInFlight = null
|
|
607
|
+
lockSnapshotStats.probes = 0
|
|
608
|
+
lockSnapshotStats.single = 0
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** The cached snapshot for this locks dir if it is not past MAX_AGE, else null. No I/O. */
|
|
612
|
+
export function peekCodexLockSnapshot(locksDir: string, now = Date.now()): LockHolderSnapshot | null {
|
|
613
|
+
if (!lockSnapshot || lockSnapshot.dir !== locksDir) return null
|
|
614
|
+
return now - lockSnapshot.at <= LOCK_SNAPSHOT_MAX_AGE_MS ? lockSnapshot : null
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Every lock in the directory, probed in one lsof, cached for LOCK_SNAPSHOT_TTL_MS.
|
|
619
|
+
*
|
|
620
|
+
* Reads the directory itself rather than taking the caller's thread ids, so it can be
|
|
621
|
+
* started before the session walk knows which rows it will list and run alongside
|
|
622
|
+
* it. Concurrent callers share one in-flight probe. A missing or unreadable
|
|
623
|
+
* directory yields an EMPTY snapshot: the scan's `fileExists` check already skips a
|
|
624
|
+
* lock that is not there, and a lock it does find that the snapshot does not cover
|
|
625
|
+
* falls back to the synchronous probe in `withLockSnapshot`.
|
|
626
|
+
*/
|
|
627
|
+
export async function codexLockSnapshot(locksDir: string, now = Date.now()): Promise<LockHolderSnapshot> {
|
|
628
|
+
const cached = peekCodexLockSnapshot(locksDir, now)
|
|
629
|
+
if (cached && now - cached.at <= LOCK_SNAPSHOT_TTL_MS) return cached
|
|
630
|
+
const inFlight = lockSnapshotInFlight && lockSnapshotInFlight.dir === locksDir ? lockSnapshotInFlight.promise : null
|
|
631
|
+
// Stale but inside MAX_AGE: answer now, refresh behind the answer.
|
|
632
|
+
if (cached) {
|
|
633
|
+
if (!inFlight) void startLockProbe(locksDir, now).catch(() => { /* the next caller retries */ })
|
|
634
|
+
return cached
|
|
635
|
+
}
|
|
636
|
+
if (inFlight) return inFlight
|
|
637
|
+
return startLockProbe(locksDir, now)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function startLockProbe(locksDir: string, now: number): Promise<LockHolderSnapshot> {
|
|
641
|
+
const promise = (async () => {
|
|
642
|
+
let names: string[] = []
|
|
643
|
+
try {
|
|
644
|
+
names = await readdir(locksDir)
|
|
645
|
+
} catch {
|
|
646
|
+
names = []
|
|
647
|
+
}
|
|
648
|
+
const paths = names
|
|
649
|
+
.filter(name => name.endsWith('.lock') && NATIVE_THREAD_ID_RE.test(name.slice(0, -'.lock'.length)))
|
|
650
|
+
.map(name => join(locksDir, name))
|
|
651
|
+
const result = await batchLockHolders(paths)
|
|
652
|
+
// Aged from the moment the probe was ASKED for, not from when lsof finished:
|
|
653
|
+
// a reading is only as fresh as its start, and the caller's clock is the
|
|
654
|
+
// test seam.
|
|
655
|
+
const snapshot: LockHolderSnapshot = { dir: locksDir, at: now, holders: result.holders, doubt: result.doubt }
|
|
656
|
+
lockSnapshot = snapshot
|
|
657
|
+
return snapshot
|
|
658
|
+
})()
|
|
659
|
+
lockSnapshotInFlight = { dir: locksDir, promise }
|
|
660
|
+
try {
|
|
661
|
+
return await promise
|
|
662
|
+
} finally {
|
|
663
|
+
if (lockSnapshotInFlight?.promise === promise) lockSnapshotInFlight = null
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* A probe set whose `lockHolders` answers from the snapshot for the paths it covers.
|
|
669
|
+
*
|
|
670
|
+
* A covered path answers synchronously with no spawn; a path in `doubt` THROWS, which
|
|
671
|
+
* is the same signal the live probe sends and lands the thread on degraded; a path
|
|
672
|
+
* the snapshot never saw (a lock created after the directory was read) goes to the
|
|
673
|
+
* wrapped probe, so the fallback is the exact behaviour that shipped before.
|
|
674
|
+
*/
|
|
675
|
+
export function withLockSnapshot(probes: OccupancyProbes, snapshot: LockHolderSnapshot): OccupancyProbes {
|
|
676
|
+
return {
|
|
677
|
+
...probes,
|
|
678
|
+
lockHolders: (path: string) => {
|
|
679
|
+
const doubt = snapshot.doubt.get(path)
|
|
680
|
+
if (doubt !== undefined) throw new Error(`lockHolders: ${doubt}`)
|
|
681
|
+
const held = snapshot.holders.get(path)
|
|
682
|
+
return held !== undefined ? [...held] : probes.lockHolders(path)
|
|
683
|
+
},
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
410
687
|
/** Supplies the pid -> process-start map that is the only route to a self-owned verdict. */
|
|
411
688
|
export type SpawnLedgerAccessor = () => ReadonlyMap<number, number>
|
|
412
689
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 6.45.3 — "Recent" is a rolling window of the newest messages, not a calendar day.
|
|
2
|
+
//
|
|
3
|
+
// Miles, 2026-09-12: "The recent messages should just be a rolling count of 30,
|
|
4
|
+
// where, as messages age out of that top 30, they're no longer present in the
|
|
5
|
+
// recent tab. At any rate, we should never end up in a situation where messages
|
|
6
|
+
// are hidden." The old view was keyed on the local day, so at 07:00 the answer
|
|
7
|
+
// was empty while ten turns from the previous evening sat in the live store.
|
|
8
|
+
//
|
|
9
|
+
// The window is assembled from every live session (era-filtered) and then from
|
|
10
|
+
// archived days newest-first, only as many days as it takes to fill the window.
|
|
11
|
+
// Live copies and their archive mirrors are the same turn: dedup on
|
|
12
|
+
// `sessionId|timestamp`, the key the day view has used since the NTP-skew fix.
|
|
13
|
+
|
|
14
|
+
export interface RecentMessageCandidate {
|
|
15
|
+
sessionId?: string
|
|
16
|
+
timestamp: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RECENT_MESSAGES_DEFAULT_LIMIT = 30
|
|
20
|
+
export const RECENT_MESSAGES_MAX_LIMIT = 100
|
|
21
|
+
|
|
22
|
+
export function recentMessagesLimit(raw: unknown): number {
|
|
23
|
+
const n = typeof raw === 'string' ? Number.parseInt(raw, 10) : typeof raw === 'number' ? raw : NaN
|
|
24
|
+
if (!Number.isFinite(n) || n < 1) return RECENT_MESSAGES_DEFAULT_LIMIT
|
|
25
|
+
return Math.min(RECENT_MESSAGES_MAX_LIMIT, Math.floor(n))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The newest `limit` messages, chronological (oldest first, like the day view).
|
|
30
|
+
* `archiveDaysNewestFirst` is consulted lazily, one day at a time, and only
|
|
31
|
+
* while the window is not yet full.
|
|
32
|
+
*/
|
|
33
|
+
export function selectRecentMessages<T extends RecentMessageCandidate>(
|
|
34
|
+
live: T[],
|
|
35
|
+
archiveDaysNewestFirst: Iterable<() => T[]>,
|
|
36
|
+
limit: number,
|
|
37
|
+
): T[] {
|
|
38
|
+
const seen = new Set<string>()
|
|
39
|
+
const keyOf = (m: RecentMessageCandidate) => `${m.sessionId ?? ''}|${m.timestamp}`
|
|
40
|
+
const candidates: T[] = []
|
|
41
|
+
const take = (rows: T[]) => {
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
const key = keyOf(row)
|
|
44
|
+
if (seen.has(key)) continue
|
|
45
|
+
seen.add(key)
|
|
46
|
+
candidates.push(row)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
take(live)
|
|
50
|
+
for (const readDay of archiveDaysNewestFirst) {
|
|
51
|
+
if (candidates.length >= limit) break
|
|
52
|
+
take(readDay())
|
|
53
|
+
}
|
|
54
|
+
candidates.sort((a, b) => a.timestamp - b.timestamp)
|
|
55
|
+
return candidates.length > limit ? candidates.slice(candidates.length - limit) : candidates
|
|
56
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Catalog of skills the glasses agent can actually run: the selected COS
|
|
2
|
+
// workspace plus the user's global Claude skills. Labels only — never paths.
|
|
3
|
+
//
|
|
4
|
+
// Walk matches skill_sync.py: `.agents/skills` is canonical (nested folders
|
|
5
|
+
// flatten with `:`), `.claude/skills` is a one-level mirror, `source-command-*`
|
|
6
|
+
// is excluded as a generated duplicate. User skills live in `~/.claude/skills`.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { basename, join, relative } from 'node:path'
|
|
11
|
+
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
12
|
+
import { resolveProviderWorkDir } from './launch-dir.js'
|
|
13
|
+
|
|
14
|
+
export const SKILLS_CATALOG_SCHEMA = 1
|
|
15
|
+
export const SKILLS_CATALOG_MAX = 200
|
|
16
|
+
const SKILL_FILE_MAX_BYTES = 8_192
|
|
17
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
|
18
|
+
const EXCLUDE_PREFIXES = ['source-command-']
|
|
19
|
+
|
|
20
|
+
export interface WorkspaceSkill {
|
|
21
|
+
name: string
|
|
22
|
+
slash: string
|
|
23
|
+
description: string
|
|
24
|
+
group: string
|
|
25
|
+
where: 'workspace' | 'user'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkspaceSkillsCatalog {
|
|
29
|
+
schemaVersion: number
|
|
30
|
+
skills: WorkspaceSkill[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseSkillFrontmatter(text: string): Record<string, string> {
|
|
34
|
+
const src = text.replace(/^\uFEFF/, '')
|
|
35
|
+
if (!src.startsWith('---')) return {}
|
|
36
|
+
const end = src.indexOf('\n---', 3)
|
|
37
|
+
if (end < 0) return {}
|
|
38
|
+
const block = src.slice(3, end).replace(/^\r?\n/, '')
|
|
39
|
+
const out: Record<string, string> = {}
|
|
40
|
+
const lines = block.split(/\r?\n/)
|
|
41
|
+
let i = 0
|
|
42
|
+
while (i < lines.length) {
|
|
43
|
+
const line = lines[i]
|
|
44
|
+
const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
|
|
45
|
+
if (!match) { i += 1; continue }
|
|
46
|
+
const key = match[1]
|
|
47
|
+
let raw = match[2].trim()
|
|
48
|
+
if (raw === '>' || raw === '>-' || raw === '|' || raw === '|-') {
|
|
49
|
+
const parts: string[] = []
|
|
50
|
+
i += 1
|
|
51
|
+
while (i < lines.length && /^\s+\S/.test(lines[i]) && !/^[A-Za-z_][\w-]*:/.test(lines[i])) {
|
|
52
|
+
parts.push(lines[i].trim())
|
|
53
|
+
i += 1
|
|
54
|
+
}
|
|
55
|
+
out[key] = unquote(parts.join(' '))
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
out[key] = unquote(raw)
|
|
59
|
+
i += 1
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unquote(value: string): string {
|
|
65
|
+
const trimmed = value.trim()
|
|
66
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
67
|
+
return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'").trim()
|
|
68
|
+
}
|
|
69
|
+
return trimmed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function excludedName(name: string): boolean {
|
|
73
|
+
return EXCLUDE_PREFIXES.some(prefix => name === prefix.slice(0, -1) || name.startsWith(prefix))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function skillNameFromRel(rel: string): string | null {
|
|
77
|
+
const parts = rel.split(/[/\\]/).filter(part => part && part !== '.')
|
|
78
|
+
if (parts.length === 0 || parts.some(part => part.startsWith('.') || excludedName(part))) return null
|
|
79
|
+
const name = parts.join(':')
|
|
80
|
+
return SKILL_NAME_RE.test(name) ? name : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readSkillFile(path: string): { name?: string; description: string } | null {
|
|
84
|
+
let stat
|
|
85
|
+
try { stat = statSync(path) } catch { return null }
|
|
86
|
+
if (!stat.isFile() || stat.size <= 0) return null
|
|
87
|
+
const bytes = Math.min(stat.size, SKILL_FILE_MAX_BYTES)
|
|
88
|
+
let text: string
|
|
89
|
+
try {
|
|
90
|
+
text = readFileSync(path, { encoding: 'utf8' }).slice(0, bytes)
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
const meta = parseSkillFrontmatter(text)
|
|
95
|
+
const description = (meta.description || '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
96
|
+
const name = meta.name && SKILL_NAME_RE.test(meta.name) && !excludedName(meta.name) ? meta.name : undefined
|
|
97
|
+
return { name, description }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkImmediateSkills(root: string): Array<{ rel: string; path: string }> {
|
|
101
|
+
let entries
|
|
102
|
+
try { entries = readdirSync(root, { withFileTypes: true }) } catch { return [] }
|
|
103
|
+
const found: Array<{ rel: string; path: string }> = []
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
106
|
+
const path = join(root, entry.name, 'SKILL.md')
|
|
107
|
+
if (existsSync(path)) found.push({ rel: entry.name, path })
|
|
108
|
+
}
|
|
109
|
+
return found
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function walkNestedSkills(root: string): Array<{ rel: string; path: string }> {
|
|
113
|
+
const found: Array<{ rel: string; path: string }> = []
|
|
114
|
+
const stack = [root]
|
|
115
|
+
while (stack.length) {
|
|
116
|
+
const dir = stack.pop()!
|
|
117
|
+
let entries
|
|
118
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
121
|
+
const full = join(dir, entry.name)
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
stack.push(full)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (entry.isFile() && entry.name === 'SKILL.md') {
|
|
127
|
+
const rel = relative(root, dir)
|
|
128
|
+
if (rel && rel !== '.') found.push({ rel, path: full })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return found
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function groupFor(name: string, where: WorkspaceSkill['where']): string {
|
|
136
|
+
const colon = name.indexOf(':')
|
|
137
|
+
if (colon > 0) return name.slice(0, colon).replace(/[-_]/g, ' ').toUpperCase()
|
|
138
|
+
return where === 'user' ? 'USER' : 'WORKSPACE'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function addSkill(
|
|
142
|
+
seen: Map<string, WorkspaceSkill>,
|
|
143
|
+
rel: string,
|
|
144
|
+
path: string,
|
|
145
|
+
where: WorkspaceSkill['where'],
|
|
146
|
+
): void {
|
|
147
|
+
if (seen.size >= SKILLS_CATALOG_MAX) return
|
|
148
|
+
const fromDir = skillNameFromRel(rel)
|
|
149
|
+
if (!fromDir) return
|
|
150
|
+
const parsed = readSkillFile(path)
|
|
151
|
+
if (!parsed) return
|
|
152
|
+
// Directory name is the loader identity. Frontmatter `name` is a label; if it
|
|
153
|
+
// disagrees with the folder (common on generated mirrors), keep the folder.
|
|
154
|
+
const name = parsed.name && basename(rel) === parsed.name ? parsed.name : fromDir
|
|
155
|
+
if (seen.has(name) || excludedName(name)) return
|
|
156
|
+
seen.set(name, {
|
|
157
|
+
name,
|
|
158
|
+
slash: `/${name}`,
|
|
159
|
+
description: parsed.description,
|
|
160
|
+
group: groupFor(name, where),
|
|
161
|
+
where,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function listWorkspaceSkills(options: {
|
|
166
|
+
workDir?: string
|
|
167
|
+
home?: string
|
|
168
|
+
} = {}): WorkspaceSkill[] {
|
|
169
|
+
const workDir = options.workDir ?? resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR })
|
|
170
|
+
const home = options.home ?? homedir()
|
|
171
|
+
const seen = new Map<string, WorkspaceSkill>()
|
|
172
|
+
|
|
173
|
+
if (workDir) {
|
|
174
|
+
const agents = join(workDir, '.agents', 'skills')
|
|
175
|
+
if (existsSync(agents)) {
|
|
176
|
+
for (const skill of walkNestedSkills(agents)) addSkill(seen, skill.rel, skill.path, 'workspace')
|
|
177
|
+
}
|
|
178
|
+
const claude = join(workDir, '.claude', 'skills')
|
|
179
|
+
if (existsSync(claude)) {
|
|
180
|
+
for (const skill of walkImmediateSkills(claude)) addSkill(seen, skill.rel, skill.path, 'workspace')
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const userClaude = join(home, '.claude', 'skills')
|
|
185
|
+
if (existsSync(userClaude)) {
|
|
186
|
+
for (const skill of walkImmediateSkills(userClaude)) addSkill(seen, skill.rel, skill.path, 'user')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [...seen.values()].sort((a, b) => {
|
|
190
|
+
if (a.group !== b.group) {
|
|
191
|
+
if (a.group === 'WORKSPACE') return -1
|
|
192
|
+
if (b.group === 'WORKSPACE') return 1
|
|
193
|
+
if (a.group === 'USER') return 1
|
|
194
|
+
if (b.group === 'USER') return -1
|
|
195
|
+
return a.group.localeCompare(b.group)
|
|
196
|
+
}
|
|
197
|
+
return a.name.localeCompare(b.name)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function workspaceSkillsCatalog(options?: {
|
|
202
|
+
workDir?: string
|
|
203
|
+
home?: string
|
|
204
|
+
}): WorkspaceSkillsCatalog {
|
|
205
|
+
return {
|
|
206
|
+
schemaVersion: SKILLS_CATALOG_SCHEMA,
|
|
207
|
+
skills: listWorkspaceSkills(options),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -26,9 +26,12 @@ import {
|
|
|
26
26
|
findAgentSessionFile,
|
|
27
27
|
listAgentSessions,
|
|
28
28
|
emptySessionListDropped,
|
|
29
|
+
loadClaudeDesktopAliases,
|
|
29
30
|
loadCursorComposerNames,
|
|
30
31
|
parseAgentSession,
|
|
31
32
|
type AgentProvider,
|
|
33
|
+
type AgentSessionRoots,
|
|
34
|
+
type ClaudeAliasMap,
|
|
32
35
|
type AgentSessionRow,
|
|
33
36
|
type AgentSessionSort,
|
|
34
37
|
} from '../lib/agent-session-store.js'
|
|
@@ -43,7 +46,15 @@ import {
|
|
|
43
46
|
type OccupiedScan,
|
|
44
47
|
type OccupiedThread,
|
|
45
48
|
} from '../lib/occupied-threads.js'
|
|
46
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
codexLockSnapshot,
|
|
51
|
+
peekCodexLockSnapshot,
|
|
52
|
+
realOccupancyDirs,
|
|
53
|
+
realOccupancyProbes,
|
|
54
|
+
withLockSnapshot,
|
|
55
|
+
type LockHolderSnapshot,
|
|
56
|
+
} from '../lib/occupancy-probes.js'
|
|
57
|
+
import type { OccupancyDirs } from '../lib/thread-occupancy.js'
|
|
47
58
|
import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
48
59
|
|
|
49
60
|
export const agentSessionsRouter = Router()
|
|
@@ -86,12 +97,17 @@ function toSearchHit(row: AgentSessionSearchHit) {
|
|
|
86
97
|
* seconds or minutes before the user acts and a desktop session opened in that gap
|
|
87
98
|
* is exactly the race the per-write probe exists to catch.
|
|
88
99
|
*/
|
|
89
|
-
function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
|
|
100
|
+
function runningThreads(rows: readonly AgentSessionRow[], dirs: OccupancyDirs, snapshot: LockHolderSnapshot | null): OccupiedScan {
|
|
90
101
|
try {
|
|
91
|
-
const dirs = realOccupancyDirs()
|
|
92
102
|
// The spawn ledger is what lets a turn COS itself queued read as ours rather
|
|
93
103
|
// than as a foreign desktop window holding the thread.
|
|
94
|
-
|
|
104
|
+
//
|
|
105
|
+
// Codex lock holders come from ONE batched lsof taken for the whole request
|
|
106
|
+
// (see `codexLockSnapshot`), not one 2.3 s synchronous scan per listed row.
|
|
107
|
+
// With no snapshot — the batch failed — the wrapped probe runs exactly as
|
|
108
|
+
// before, so the worst case is the old cost, never a wrong verdict.
|
|
109
|
+
const base = realOccupancyProbes(cosSpawnedPids)
|
|
110
|
+
const probes = snapshot ? withLockSnapshot(base, snapshot) : base
|
|
95
111
|
const byProvider = new Map<string, string[]>()
|
|
96
112
|
for (const row of rows) {
|
|
97
113
|
if (row.provider !== 'claude' && row.provider !== 'codex') continue
|
|
@@ -137,13 +153,14 @@ function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
|
|
|
137
153
|
async function transcriptMtimes(
|
|
138
154
|
scan: OccupiedScan,
|
|
139
155
|
rows: readonly AgentSessionRow[],
|
|
156
|
+
roots: AgentSessionRoots,
|
|
157
|
+
aliases: ClaudeAliasMap,
|
|
140
158
|
): Promise<Map<string, number | null>> {
|
|
141
159
|
const mtimes = new Map<string, number | null>()
|
|
142
160
|
if (scan.occupied.size === 0) return mtimes
|
|
143
161
|
|
|
144
162
|
const providerById = new Map<string, AgentProvider>()
|
|
145
163
|
for (const row of rows) providerById.set(row.session_id, row.provider)
|
|
146
|
-
const roots = agentSessionRoots()
|
|
147
164
|
|
|
148
165
|
await Promise.all([...scan.occupied.keys()].map(async threadId => {
|
|
149
166
|
try {
|
|
@@ -155,7 +172,8 @@ async function transcriptMtimes(
|
|
|
155
172
|
mtimes.set(threadId, null)
|
|
156
173
|
return
|
|
157
174
|
}
|
|
158
|
-
|
|
175
|
+
// The shared alias map: this used to rebuild it from 564 files per held thread.
|
|
176
|
+
const file = await findAgentSessionFile(provider, threadId, roots, new Date(), aliases)
|
|
159
177
|
mtimes.set(threadId, file ? (await stat(file)).mtimeMs : null)
|
|
160
178
|
} catch {
|
|
161
179
|
mtimes.set(threadId, null)
|
|
@@ -222,11 +240,16 @@ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: nu
|
|
|
222
240
|
}
|
|
223
241
|
if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
|
|
224
242
|
try {
|
|
243
|
+
const dirs = realOccupancyDirs()
|
|
244
|
+
// A detail open inside LOCK_SNAPSHOT_TTL_MS of a list answers from that list's
|
|
245
|
+
// snapshot; otherwise the single synchronous probe runs as it always has.
|
|
246
|
+
const snapshot = peekCodexLockSnapshot(dirs.codexLocksDir)
|
|
247
|
+
const base = realOccupancyProbes(cosSpawnedPids)
|
|
225
248
|
const scan = occupiedThreads(
|
|
226
249
|
provider,
|
|
227
250
|
[threadId],
|
|
228
|
-
|
|
229
|
-
|
|
251
|
+
snapshot ? withLockSnapshot(base, snapshot) : base,
|
|
252
|
+
dirs,
|
|
230
253
|
)
|
|
231
254
|
return withActiveRecently(scan, new Map([[threadId, mtimeMs]]), Date.now())
|
|
232
255
|
} catch (error) {
|
|
@@ -281,14 +304,26 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
281
304
|
try {
|
|
282
305
|
const limit = boundedInteger(req.query.limit, AGENT_SESSION_LIST_LIMIT, 1, AGENT_SESSION_LIST_MAX)
|
|
283
306
|
const sort = asSort(req.query.sort)
|
|
307
|
+
const roots = agentSessionRoots()
|
|
308
|
+
const dirs = realOccupancyDirs()
|
|
309
|
+
// The lock probe is a 2-3 s lsof that needs only the locks directory, so it
|
|
310
|
+
// starts NOW and runs under the walk instead of after it. A failed batch is
|
|
311
|
+
// logged and the scan below falls back to the per-lock probe.
|
|
312
|
+
const snapshotPromise: Promise<LockHolderSnapshot | null> = codexLockSnapshot(dirs.codexLocksDir).catch(error => {
|
|
313
|
+
console.error(`[agent-sessions] lock snapshot failed: ${error instanceof Error ? error.message : error}`)
|
|
314
|
+
return null
|
|
315
|
+
})
|
|
316
|
+
// ONE alias load per request, shared by the walk, every live row and every
|
|
317
|
+
// held thread. Measured 2026-09-10: sixteen loads of 119 MB before this line.
|
|
318
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
284
319
|
const live = await liveClaudeRows()
|
|
285
320
|
const dropped = emptySessionListDropped()
|
|
286
|
-
const sessions = await listAgentSessions(
|
|
287
|
-
const scan = runningThreads(sessions)
|
|
321
|
+
const sessions = await listAgentSessions(roots, new Date(), live, limit, sort, dropped, aliases)
|
|
322
|
+
const scan = runningThreads(sessions, dirs, await snapshotPromise)
|
|
288
323
|
// Freshness is layered on AFTER occupancy, and only over what occupancy
|
|
289
324
|
// found. Cursor has no process occupancy, so the list does not invent a
|
|
290
325
|
// working hint from jsonl mtime — detail still can, from the file it stat'ed.
|
|
291
|
-
const running = withActiveRecently(scan, await transcriptMtimes(scan, sessions), Date.now())
|
|
326
|
+
const running = withActiveRecently(scan, await transcriptMtimes(scan, sessions, roots, aliases), Date.now())
|
|
292
327
|
res.json({
|
|
293
328
|
sessions: sessions.map(row => withRunning(toEntry(row), running)),
|
|
294
329
|
total: sessions.length,
|
package/server/routes/archive.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Router } from 'express'
|
|
3
3
|
import { listArchiveDateStrings, archiveDir, archiveIndexPath, loadArchive, getArchiveChats, getArchiveDayMessages, appendToArchive } from '../lib/archive.js'
|
|
4
4
|
import { getArchiveChatMessagesNumbered } from './message-ref.js'
|
|
5
|
+
import { ensureArchiveMirrorForDay } from '../lib/conversation.js'
|
|
5
6
|
import { searchArchive, MAX_LIMIT, DEFAULT_LIMIT } from '../lib/archive-search.js'
|
|
6
7
|
import { refreshArchiveIndex } from '../lib/archive-index.js'
|
|
7
8
|
import { getActiveSessions } from '../lib/conversation.js'
|
|
@@ -24,6 +25,8 @@ archiveRouter.param('date', (req, res, next, date) => {
|
|
|
24
25
|
|
|
25
26
|
// GET /api/archive — list all archive dates with summaries
|
|
26
27
|
archiveRouter.get('/archive', async (_req, res) => {
|
|
28
|
+
// 6.45.3 — the listing files yesterday's finished sessions before it answers.
|
|
29
|
+
await ensureArchiveMirrorForDay().catch(() => {})
|
|
27
30
|
// Index-backed. The previous implementation parsed every day file to reach four
|
|
28
31
|
// summary fields; see archive-index.ts for the measurements that killed it.
|
|
29
32
|
const { entries, rebuilt, fromCache } = await refreshArchiveIndex(archiveDir(), archiveIndexPath())
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { Router } from 'express'
|
|
3
3
|
import { readFileSync } from 'fs'
|
|
4
4
|
import { join } from 'path'
|
|
5
|
-
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel } from '../lib/conversation.js'
|
|
5
|
+
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel, ensureArchiveMirrorForDay } from '../lib/conversation.js'
|
|
6
6
|
import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
|
|
7
|
-
import { getArchiveDayMessages } from '../lib/archive.js'
|
|
7
|
+
import { getArchiveDayMessages, listArchiveDateStrings } from '../lib/archive.js'
|
|
8
|
+
import { recentMessagesLimit, selectRecentMessages } from '../lib/recent-messages.js'
|
|
8
9
|
import { localDay } from '../lib/local-day.js'
|
|
9
10
|
import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
|
|
10
11
|
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
@@ -282,16 +283,23 @@ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
|
|
|
282
283
|
res.json({ chats })
|
|
283
284
|
})
|
|
284
285
|
|
|
285
|
-
// GET /api/sessions/today/all-messages —
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
286
|
+
// GET /api/sessions/today/all-messages — the newest messages, live + archived, one window.
|
|
287
|
+
// 6.45.3 — the path keeps its name for the two clients that call it (COS Control's
|
|
288
|
+
// Recent view, the phone's history recovery) but the view is a ROLLING WINDOW of the
|
|
289
|
+
// newest `limit` (default 30, `?limit=` up to 100) across every live session and as many
|
|
290
|
+
// archived days as it takes, not a calendar day. Dedup key is `sessionId|timestamp`
|
|
291
|
+
// (was bare timestamp, which collided on NTP skew or same-ms adds); `sessionId` is
|
|
292
|
+
// always known for live exchanges; archive messages fall back to the archived chat's
|
|
293
|
+
// sessionId via getArchiveDayMessages. `date` stays in the response for compatibility.
|
|
294
|
+
sessionsRouter.get('/sessions/today/all-messages', async (req, res) => {
|
|
295
|
+
// 6.45.3 — first read after midnight files yesterday before answering.
|
|
296
|
+
await ensureArchiveMirrorForDay().catch(() => {})
|
|
290
297
|
const todayDate = localDay()
|
|
298
|
+
const limit = recentMessagesLimit(req.query.limit)
|
|
291
299
|
const activeEra = currentMessageEraState()
|
|
292
300
|
const era = activeEra.era
|
|
293
301
|
|
|
294
|
-
const
|
|
302
|
+
const archivedDay = (date: string) => getArchiveDayMessages(date)
|
|
295
303
|
.filter(m => exchangeBelongsToEra(m, era))
|
|
296
304
|
.map(m => {
|
|
297
305
|
const globalMsgNum = m.globalMsgNum ?? m.no
|
|
@@ -323,8 +331,7 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
323
331
|
}> = []
|
|
324
332
|
const liveSessions = getActiveSessions()
|
|
325
333
|
for (const session of liveSessions) {
|
|
326
|
-
|
|
327
|
-
if (sessionDay !== todayDate) continue
|
|
334
|
+
// Every live session, whatever day it last spoke: the window decides, not the calendar.
|
|
328
335
|
for (let i = 0; i < session.exchanges.length; i++) {
|
|
329
336
|
const ex = session.exchanges[i]
|
|
330
337
|
if (ex.role === 'user') {
|
|
@@ -358,17 +365,13 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
358
365
|
}
|
|
359
366
|
}
|
|
360
367
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
seen.add(k)
|
|
369
|
-
return true
|
|
370
|
-
})
|
|
371
|
-
.sort((a, b) => a.timestamp - b.timestamp)
|
|
368
|
+
// Newest `limit` across live sessions and archived days (newest day first, read
|
|
369
|
+
// only while the window is short), dedup by (sessionId, timestamp), chronological.
|
|
370
|
+
const merged = selectRecentMessages(
|
|
371
|
+
liveMessages as Array<(typeof liveMessages)[number] | ReturnType<typeof archivedDay>[number]>,
|
|
372
|
+
listArchiveDateStrings().map(date => () => archivedDay(date)),
|
|
373
|
+
limit,
|
|
374
|
+
)
|
|
372
375
|
|
|
373
|
-
res.json({ messages: merged, date: todayDate })
|
|
376
|
+
res.json({ messages: merged, date: todayDate, window: { kind: 'recent', limit } })
|
|
374
377
|
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// GET /api/skills — slash commands the glasses agent can run from this Mac.
|
|
2
|
+
// Workspace `.agents/skills` (canonical) + `.claude/skills` + `~/.claude/skills`.
|
|
3
|
+
// Authenticated by the /api token middleware. Paths never leave the box.
|
|
4
|
+
|
|
5
|
+
import { Router } from 'express'
|
|
6
|
+
import { workspaceSkillsCatalog } from '../lib/workspace-skills.js'
|
|
7
|
+
|
|
8
|
+
export const skillsRouter = Router()
|
|
9
|
+
|
|
10
|
+
skillsRouter.get('/skills', (_req, res) => {
|
|
11
|
+
res.set('Cache-Control', 'private, no-store')
|
|
12
|
+
res.json(workspaceSkillsCatalog())
|
|
13
|
+
})
|
package/server/routes/voice.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { dataPath } from '../lib/data-dir.js'
|
|
|
13
13
|
import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
|
|
14
14
|
import { trainingSourceFor } from '../lib/training-audio-provenance.js'
|
|
15
15
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
16
|
+
import { extAudioChunkPath, listExtAudioChunks } from '../lib/meeting-audio-archive.js'
|
|
16
17
|
import { getVoiceDirectorySnapshot, invalidateVoiceDirectory } from '../lib/voice-directory.js'
|
|
17
18
|
import { greedyDiversitySelect } from '../lib/voice-enrolment-selection.js'
|
|
18
19
|
import { fanOutSpeakerRename, type SpeakerRenameFanOut } from '../lib/speaker-rename-fanout.js'
|
|
@@ -336,6 +337,10 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
|
|
|
336
337
|
chunks: wavFiles.length,
|
|
337
338
|
ageHours: parseFloat(ageHours),
|
|
338
339
|
expiresIn: `${Math.max(0, 72 - parseFloat(ageHours)).toFixed(1)}h`,
|
|
340
|
+
// 6.45.3 — the chunk indices a reviewer can ask to hear (`?chunk=` on
|
|
341
|
+
// the sample route). Queen, 2026-09-12: the Add-a-voice panel let her
|
|
342
|
+
// name a session but not listen to it; naming is a guess without this.
|
|
343
|
+
chunkIndices: listExtAudioChunks(d.name),
|
|
339
344
|
}
|
|
340
345
|
}).filter(s => s.chunks > 0)
|
|
341
346
|
|
|
@@ -496,6 +501,8 @@ voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
|
|
|
496
501
|
})
|
|
497
502
|
|
|
498
503
|
// GET /api/voice/ext-audio/:sessionId/sample — hear an unidentified voice.
|
|
504
|
+
// 6.45.3 — `?chunk=<index>` picks one held chunk (indices come from the listing's
|
|
505
|
+
// `chunkIndices`); without it the newest chunk is served, as before.
|
|
499
506
|
voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
|
|
500
507
|
res.set('Cache-Control', 'private, no-store')
|
|
501
508
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -504,6 +511,21 @@ voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
|
|
|
504
511
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
505
512
|
return
|
|
506
513
|
}
|
|
514
|
+
const chunkRaw = req.query.chunk
|
|
515
|
+
if (chunkRaw !== undefined) {
|
|
516
|
+
const chunkIndex = Number.parseInt(String(chunkRaw), 10)
|
|
517
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
518
|
+
res.status(400).json({ error: 'Invalid chunk', reason: 'invalid_chunk' })
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const chunkWav = extAudioChunkPath(sessionId, chunkIndex)
|
|
522
|
+
if (!chunkWav) {
|
|
523
|
+
res.status(404).json({ error: 'No ext-audio retained for that chunk', reason: 'no_ext_audio_chunk' })
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
sendAudioFile(res, chunkWav)
|
|
527
|
+
return
|
|
528
|
+
}
|
|
507
529
|
const wav = existsSync(dirPath) ? newestWav(dirPath) : null
|
|
508
530
|
if (!wav) {
|
|
509
531
|
res.status(404).json({
|