@gotcos/glasses-server 6.45.0 → 6.45.2

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 CHANGED
@@ -1,3 +1,20 @@
1
+ ## 6.45.2
2
+
3
+ The G2 Sessions list answers in under a second again instead of eleven, and loading it no longer stalls the rest of the server.
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
8
+ - 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.
9
+
10
+ ## 6.45.1
11
+
12
+ Knowledge opens with a bounded graph and supports verified question plans for COS Control 0.5.208.
13
+
14
+ - Expose the initial overview through the authenticated workspace route. Model-assisted graph answers may carry validated anchors, waypoints and traversal results from the configured advanced pipeline. Plans navigate only; they cannot save or activate knowledge.
15
+ - Add paired owner-name setup and editing with conflict detection, durable private profile writes and preservation of existing vocabulary and settings. Display identity does not transfer journal or indexing authority.
16
+ - Report whether retained captures have a saveable transcript, unfinished transcription, or completed audio with no usable speech. Empty session shells are not advertised as recoverable recordings. Audio and late-upload admission retain their existing protections.
17
+
1
18
  ## 6.45.0
2
19
 
3
20
  Versioned local memory and explicit session identity for COS Control 0.5.207.
@@ -3,5 +3,5 @@
3
3
  "protocol": 1,
4
4
  "version": "0.1.0",
5
5
  "file": "cos-memory-runtime-0.1.0.tar.gz",
6
- "sha256": "1f03e8d3bcc878709bae7580a8c685c1c5494839cebd7f2c79248dbc17e4d512"
6
+ "sha256": "5dfe8454f3bad21b0bfd38aafd567990337a7cc58c2bcdc1b2dbb84f8af0ae13"
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.45.0",
4
- "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
3
+ "version": "6.45.2",
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": {
7
7
  "glasses-server": "bin/cli.cjs",
@@ -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<Map<string, ClaudeDesktopHead | null>> {
714
- const aliases = new Map<string, ClaudeDesktopHead | null>()
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
- const head = { ...peekClaudeDesktopHead(await readWindow(file, false)), file, mtimeMs: st.mtimeMs }
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
- const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
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
- const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
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) || []), file])
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 = files.get([...ids][0]) || []
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') {
@@ -990,11 +990,19 @@ export function normalizeSampleKickoff(value: unknown): Record<string, unknown>
990
990
  }
991
991
 
992
992
  /** `graph-ask`: one answer from the graph, bounded. */
993
- export function normalizeGraphAnswer(value: unknown): { question: string; mode: string; answer: string; elapsed_s: number | null } | null {
993
+ export function normalizeGraphAnswer(value: unknown): { question: string; mode: string; answer: string; elapsed_s: number | null; investigation?: Record<string, unknown> } | null {
994
994
  const s = asRecord(value) ?? {}
995
995
  const answer = typeof s.answer === 'string' ? s.answer.slice(0, 20_000) : null
996
996
  if (answer === null) return null
997
- return { question: stringOrAbsent(s.question, KNOWLEDGE_ASK_MAX_CHARS) ?? '', mode: stringOrAbsent(s.mode, 16) ?? 'hybrid', answer, elapsed_s: typeof s.elapsed_s === 'number' && Number.isFinite(s.elapsed_s) ? s.elapsed_s : null }
997
+ const candidate = asRecord(s.investigation)
998
+ let investigation: Record<string, unknown> | undefined
999
+ if (candidate?.status === 'unavailable') investigation = { status: 'unavailable', message: stringOrAbsent(candidate.message, 500) ?? 'No verified graph plan is available.' }
1000
+ if (candidate?.status === 'ready' && Array.isArray(candidate.nodes) && candidate.nodes.length <= 200 && Array.isArray(candidate.links) && candidate.links.length <= 500 && JSON.stringify(candidate).length <= 500_000) {
1001
+ // Python validates this read-only plan against the policy-filtered graph and generation.
1002
+ // Forward only the canvas contract; it never becomes an arbitrary workspace request.
1003
+ investigation = Object.fromEntries(['status','message','nodes','links','anchors','waypoints','filters','paths','generation','truncated','truncation_reason','scope'].filter(key => candidate[key] !== undefined).map(key => [key, candidate[key]]))
1004
+ }
1005
+ return { question: stringOrAbsent(s.question, KNOWLEDGE_ASK_MAX_CHARS) ?? '', mode: stringOrAbsent(s.mode, 16) ?? 'hybrid', answer, elapsed_s: typeof s.elapsed_s === 'number' && Number.isFinite(s.elapsed_s) ? s.elapsed_s : null, ...(investigation ? { investigation } : {}) }
998
1006
  }
999
1007
 
1000
1008
  export interface IngestProgressItem { id: string; outcome: 'indexed' | 'failed' | 'unknown'; seconds: number | null; reason: string | null }
@@ -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
- try {
226
- dirs = readdirSync(root).filter(name => {
227
- try {
228
- return statSync(join(root, name)).isDirectory()
229
- } catch {
230
- return false
231
- }
232
- })
233
- } catch {
234
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
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
 
@@ -1,14 +1,14 @@
1
1
  // Profile loader — reads user identity from .cos-profile.json (gitignored)
2
2
  // Falls back to generic defaults for users who haven't configured a profile
3
3
 
4
- import { existsSync, readFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
5
5
  import { homedir } from 'node:os'
6
- import { resolve } from 'node:path'
6
+ import { resolve, dirname } from 'node:path'
7
7
  import { atomicWriteFileSync } from './atomic-fs.js'
8
8
 
9
9
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
10
 
11
- const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user'])
11
+ const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user', 'me', 'owner', 'wearer'])
12
12
  const PLACEHOLDER_VOCABULARY = new Set(['nameone', 'nametwo', 'yourcompany', 'productname'])
13
13
  const PLACEHOLDER_CORRECTIONS = new Set(['soundalike\u0000yourname'])
14
14
 
@@ -53,8 +53,9 @@ export function loadProfileObject(): Record<string, unknown> {
53
53
  function loadProfile(): Record<string, unknown> {
54
54
  if (profileCache) return profileCache
55
55
  try {
56
- profileCache = JSON.parse(readFileSync(profilePath(), 'utf-8'))
57
- return profileCache!
56
+ const parsed: unknown = JSON.parse(readFileSync(profilePath(), 'utf-8'))
57
+ profileCache = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}
58
+ return profileCache
58
59
  } catch {
59
60
  profileCache = {}
60
61
  return profileCache
@@ -98,6 +99,29 @@ export function getOwnerName(): string {
98
99
  return !value || PLACEHOLDER_OWNER_NAMES.has(value.toLowerCase()) ? 'User' : value
99
100
  }
100
101
 
102
+ /** Display identity only. Never reassign journal authority or the ingestion-owner Mac. */
103
+ export function setProfileOwnerName(name: unknown, expected: unknown): string {
104
+ if (typeof name !== 'string' || !name.trim() || name.trim().length > 120 || /[\x00-\x1f\x7f]/.test(name) || PLACEHOLDER_OWNER_NAMES.has(name.trim().toLowerCase())) throw new Error('invalid_owner_name')
105
+ if (expected !== null && typeof expected !== 'string') throw new Error('invalid_expected_owner')
106
+ let current: Record<string, unknown> = {}
107
+ const source = profilePath()
108
+ try {
109
+ const parsed: unknown = JSON.parse(readFileSync(source, 'utf8'))
110
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid_profile')
111
+ current = parsed as Record<string, unknown>
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new Error('profile_unreadable')
114
+ }
115
+ const previous = typeof current.owner_name === 'string' ? current.owner_name.trim() : ''
116
+ const actual = !previous || PLACEHOLDER_OWNER_NAMES.has(previous.toLowerCase()) ? null : previous
117
+ if (actual !== expected) throw new Error('owner_changed')
118
+ const target = process.env.COS_PROFILE_PATH?.trim() ? source : homeProfilePath()
119
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
120
+ atomicWriteFileSync(target, JSON.stringify({ ...current, owner_name: name.trim() }, null, 2), { mode: 0o600 })
121
+ clearProfileCache()
122
+ return name.trim()
123
+ }
124
+
101
125
  /** Short speaker label for the glasses wearer, used by diarization to fast-path
102
126
  * the owner's voiceprint. Defaults to 'Me'. Configure via owner_speaker_label. */
103
127
  export function getOwnerSpeakerLabel(): string {
@@ -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 { realOccupancyDirs, realOccupancyProbes } from '../lib/occupancy-probes.js'
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
- const probes = realOccupancyProbes(cosSpawnedPids)
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
- const file = await findAgentSessionFile(provider, threadId, roots)
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
- realOccupancyProbes(cosSpawnedPids),
229
- realOccupancyDirs(),
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(agentSessionRoots(), new Date(), live, limit, sort, dropped)
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,
@@ -329,6 +329,8 @@ healthRouter.get('/health', async (_req, res) => {
329
329
  idleMinutes: item.idleMinutes,
330
330
  capturedMinutes: item.capturedMinutes,
331
331
  chunks: item.chunks,
332
+ transcriptState: item.transcriptState,
333
+ canSave: item.canSave,
332
334
  promotesAt: item.promotesAt,
333
335
  hasDraft: item.draftPath != null,
334
336
  })),
@@ -1,6 +1,9 @@
1
1
  import { Router } from 'express'
2
2
  import { callPython, contextSourceAvailable, pythonBridgeState } from '../lib/python-bridge.js'
3
3
  import { searchMemories } from '../lib/context-library-search.js'
4
+ import { getOwnerName, setProfileOwnerName } from '../lib/profile.js'
5
+ import { resetDecoderCaches } from '../lib/whisper-local.js'
6
+ import { resetVocabEchoCache } from '../lib/hallucination-filter.js'
4
7
 
5
8
  /**
6
9
  * Is there anything to serve — a Python bridge OR plain files on disk?
@@ -54,6 +57,24 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
54
57
  } from '../lib/cos-context-browser.js'
55
58
 
56
59
  export const memoryRouter = Router()
60
+ // Profile setup is available before a memory bridge is configured. API authentication still applies.
61
+ memoryRouter.get('/context/profile/owner', (_req, res) => {
62
+ const name = getOwnerName()
63
+ res.json({ owner_name: name === 'User' ? null : name })
64
+ })
65
+ memoryRouter.post('/context/profile/owner', (req, res) => {
66
+ try {
67
+ const body = req.body as Record<string, unknown>
68
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !['owner_name','expected_owner'].includes(key))) { res.status(400).json({ error: 'invalid_owner_profile' }); return }
69
+ const name = setProfileOwnerName(body.owner_name, body.expected_owner)
70
+ resetDecoderCaches(); resetVocabEchoCache()
71
+ res.json({ owner_name: name })
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : ''
74
+ const reason = ['owner_changed','invalid_owner_name','invalid_expected_owner','profile_unreadable'].includes(message) ? message : 'profile_save_failed'
75
+ res.status(reason === 'owner_changed' ? 409 : reason.startsWith('invalid_') ? 400 : 503).json({ error: reason })
76
+ }
77
+ })
57
78
  memoryRouter.use((req, res, next) => {
58
79
  if (['owner', 'audience', 'authority_host', 'authority_epoch'].some(key => key in req.query || (req.body && key in req.body))) {
59
80
  res.status(400).json({ error: 'caller_authority_forbidden' }); return
@@ -62,7 +83,7 @@ memoryRouter.use((req, res, next) => {
62
83
  })
63
84
  // Additive workspace protocol. The existing /memory array contract remains intact.
64
85
  // Authentication is the instance pairing token; caller fields cannot select an owner.
65
- const WORKSPACE_ACTIONS = new Set(['status', 'graph_expand', 'graph_paths', 'graph_resolve', 'list_explorations', 'get_exploration',
86
+ const WORKSPACE_ACTIONS = new Set(['status', 'graph_overview', 'graph_expand', 'graph_paths', 'graph_resolve', 'list_explorations', 'get_exploration',
66
87
  'save_exploration', 'save_assertion', 'policy_get', 'policy_set', 'memory_page', 'learning_page', 'review_page', 'get_memory', 'review_memory', 'trace_summary', 'source_status', 'refresh_source', 'current_sources', 'identity_split_preview', 'identity_status', 'identity_keep_apart', 'rule_page', 'propose_rule', 'review_rule', 'rollback_rule', 'activate_rule', 'rule_evaluation'])
67
88
  memoryRouter.post('/context/memory/workspace', async (req, res) => {
68
89
  noStore(res)
@@ -463,6 +463,8 @@ export interface StrandedCapture {
463
463
  /** Minutes of audio actually captured before it went quiet. */
464
464
  capturedMinutes: number
465
465
  chunks: number
466
+ transcriptState: 'ready' | 'processing' | 'no_speech'
467
+ canSave: boolean
466
468
  /** When the sweeper will save this on the user's behalf. */
467
469
  promotesAt: string
468
470
  /** Readable draft on disk, once the capture has been stale long enough. */
@@ -485,21 +487,30 @@ export interface StrandedCapture {
485
487
  */
486
488
  export function getStrandedCaptures(now = Date.now()): StrandedCapture[] {
487
489
  const drafts = new Map(listStrandedDrafts(STRANDED_DRAFT_DIR).map(d => [d.sessionId, d]))
488
- return getTranscriptionSessionLiveness(now).staleSessions.map(stale => {
490
+ return getTranscriptionSessionLiveness(now).staleSessions.flatMap(stale => {
489
491
  const session = sessions.get(stale.sessionId)
490
492
  const lastActivityAt = session?.lastActivityAt ?? now - stale.silentForMs
491
493
  const draft = drafts.get(stale.sessionId) ?? null
492
- return {
494
+ const received = session?.receivedIndices ?? []
495
+ // An empty session shell is not evidence that audio was lost. Leave its
496
+ // ledger open so a backgrounded phone can still upload before retention.
497
+ if (!stale.chunks && !received.length && !hasSessionAudio(stale.sessionId)) return []
498
+ const completed = new Set(session?.asrCompletedIndices ?? [])
499
+ const noSpeech = !stale.chunks && received.length > 0 && received.every(index => completed.has(index))
500
+ const transcriptState: StrandedCapture['transcriptState'] = stale.chunks ? 'ready' : noSpeech ? 'no_speech' : 'processing'
501
+ return [{
493
502
  sessionId: stale.sessionId,
494
503
  idleMinutes: Math.round(stale.silentForMs / 60_000),
495
504
  capturedMinutes: Math.round(
496
505
  Math.max(0, lastActivityAt - (session?.startTime ?? lastActivityAt)) / 60_000,
497
506
  ),
498
507
  chunks: stale.chunks,
508
+ transcriptState,
509
+ canSave: transcriptState === 'ready',
499
510
  promotesAt: new Date(lastActivityAt + LOCAL_FIRST_MEETING_IDLE_RETENTION_MS).toISOString(),
500
511
  draftPath: draft?.path ?? null,
501
512
  draftBytes: draft?.bytes ?? null,
502
- }
513
+ }]
503
514
  })
504
515
  }
505
516