@gotcos/glasses-server 6.23.1 → 6.24.0

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,88 @@
1
+ ## 6.24.0
2
+
3
+ The Sessions tab stops 404ing, and a read-only presence view of Claude Code
4
+ sessions on this Mac arrives behind a flag.
5
+
6
+ - **`/api/session-index` was never ported into the published package.** The route
7
+ lives in the private app repo and is mounted there; the companion has been calling
8
+ it and getting an Express HTML 404 since the managed-runtime cutover. Same class as
9
+ the stranded voice profiles, the npmignore-excluded speaker model and the stranded
10
+ `.cos-profile.json`.
11
+ - **Ported rather than retired, against the original recommendation.**
12
+ `/api/sessions/recent` looked like a duplicate and is not: it reads an IN-MEMORY map
13
+ on a 24-hour window, carries no `domain` and no `device_id`, gives `lastQuery` where
14
+ the companion wants `first_prompt`, dies on restart, and has no counterpart at all
15
+ for the detail view's `tools_used`, `files_touched`, `git_branch` and token counts.
16
+ `lib/session-cache-writer.ts` already writes the disk cache, so only the reader was
17
+ missing.
18
+ - **Four defects fixed in the port, all measured on the real 37,700-entry cache.**
19
+ An unset `COS_SCRIPTS_DIR` returned `[]` — a 200 that reads as "you have no
20
+ sessions" on every standalone install — now 503 with `reason: pythonBridgeState()`.
21
+ `err.message` leaked filesystem paths; now a generic reason, detail to the log.
22
+ 31.7 MB was parsed synchronously per request, the detail endpoint included, on a
23
+ process that also streams live audio; now async and cached against file identity
24
+ (161ms cold, 15ms warm). And the filename filter matched iCloud sync-conflict
25
+ duplicates: `.session_index_cache_Ukaoma-Mac-Studio 3.json` shared 543 of its 602
26
+ rows with the canonical file, so the merged list served 543 duplicates. Verified on
27
+ real data: 37,700 naive becomes 37,157 served, exactly 543 removed. Filtering ` N`
28
+ filenames would have been wrong in the other direction, since ` 2.json` holds 245
29
+ rows appearing nowhere else, so dedupe is by session_id with newest winning.
30
+ - **New `GET /api/claude-sessions`, off unless `COS_CLAUDE_SESSIONS_ENABLED=1`.**
31
+ It projects another product's 0700 state directory over a socket bound to 0.0.0.0
32
+ behind a private-network allowlist, so in a published package that has to be opt-in.
33
+ Named `claude-sessions` rather than `peers` because COS already overloads "session"
34
+ three ways and the glasses, phone and server are all arguably peers.
35
+ - **Redaction is decided by `nameSource`, and only `derived` passes.** `auto` means an
36
+ LLM wrote the label FROM THE WORK, and `/rename` clears the field entirely, so a
37
+ missing `nameSource` is a renamed session rather than a derived one. Anything but an
38
+ exact `derived` is replaced with the recomputed folder name and flagged
39
+ `nameRedacted`. Echoing `name` while redacting `cwd` would not have been redaction.
40
+ - **Reachability is a conjunction: alive AND a declared socket path AND that file
41
+ present.** Each alone is a false positive. Verified live: the 2.1.222 row reports
42
+ `reachable: false` for having no socket path, and `/tmp/cc-socks` holds an orphaned
43
+ `.sock` whose pid is dead and whose registry file is already gone, because sockets
44
+ are not reaped. Liveness is a signal 0, never inferred from mtime, and EPERM
45
+ resolves to false rather than true.
46
+ - Fields are named explicitly, never spread. The writer can also emit `logPath` (a
47
+ full path), `agent`, `jobId`, `bridgeSessionId` and `parkedJobId`. Verified against
48
+ the live registry: no `/Users/`, no `cc-socks`, no `logPath`, no `cwd` on the wire.
49
+ - Registry reads honor `CLAUDE_CONFIG_DIR`, match `<pid>.json` strictly rather than
50
+ `*.json`, `lstat` to refuse symlinks, and treat an ENOENT mid-read as normal because
51
+ the reaper is actively unlinking.
52
+
53
+ - **Session labels you can actually read, and machine sessions you can hide.** The
54
+ list is only useful if the rows have names. Measured 2026-08-10: `first_prompt` took
55
+ the first user message unconditionally, so a slash-command session was labelled
56
+ `<command-message>cos-glasses</command-message>`, a proxy session was labelled "You
57
+ are the COS Slack Bridge proxy", and everything else fell back to a random slug
58
+ (`crispy-coalescing-salamander`) or the bare UUID. Fixed in the Python indexer with a
59
+ filter, not an LLM: wrappers are stripped, a slash command keeps its name, injected
60
+ persona prompts are rejected. `custom_title` now carries Claude Code's own sidebar
61
+ title where the user set one — `3dc7e253` correctly reads "COS-glasses Server work
62
+ (meetings)". New `display_label` resolves title, then derived label, then short id,
63
+ and falls back for the 37,157 rows written before these fields existed.
64
+ - **`?human=1` hides harness-opened sessions.** 1,045 of 1,210 local sessions are proxy
65
+ calls, readiness probes and hook spawns; roughly 165 were opened by a person, which is
66
+ the order of magnitude Claude Code's own sidebar shows. `machine_spawned` is strict
67
+ `=== true`, so a bad value fails OPEN — losing a session the user had is worse than
68
+ showing a machine one.
69
+ - **`COS_CLAUDE_SESSIONS_SHOW_NAMES=1` opts into real session names.** Redaction stays
70
+ the default so the published package is safe for anyone, but on an owner's own machine
71
+ it deleted the whole value of the view, since the names are how you tell one session
72
+ from another. Deliberately a SEPARATE switch from the enable flag: turning the feature
73
+ on must not silently turn redaction off. Opting in still never exposes a path or an
74
+ unlisted field.
75
+
76
+ **Sending messages from the glasses stays closed, not parked.** COS launches Claude
77
+ with `--dangerously-skip-permissions` in BOTH branches of `claude-permissions.ts:44`,
78
+ so there is no configuration in which an inbound message reaches a receiver that
79
+ would prompt. Combined with the 0.0.0.0 bind, that is a LAN-token-to-RCE path.
80
+
81
+ Coverage: 75 tests across the two routes, 20 mutations all caught. One was an invalid
82
+ experiment first time round — the 500 responder is identical in both handlers, so
83
+ mutating them together looked covered; mutated one at a time the list site was caught
84
+ and the DETAIL site SURVIVED, which found a genuinely untested leak path.
85
+
1
86
  ## 6.23.1
2
87
 
3
88
  Closes the hole 6.23.0 left open, plus a lockfile version that 6.23.0 shipped out of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.23.1",
3
+ "version": "6.24.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -17,6 +17,8 @@ import { diagRouter } from './routes/diag.js'
17
17
  import { queryRouter } from './routes/query.js'
18
18
  import { providerProofRouter } from './routes/provider-proof.js'
19
19
  import { transcribeRouter } from './routes/transcribe.js'
20
+ import { sessionIndexRouter } from './routes/session-index.js'
21
+ import { claudeSessionsRouter } from './routes/claude-sessions.js'
20
22
  import { displayRouter } from './routes/display.js'
21
23
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
22
24
  import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.js'
@@ -231,6 +233,12 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
231
233
  app.use('/api', queryRouter)
232
234
  app.use('/api', providerProofRouter)
233
235
  app.use('/api', transcribeRouter)
236
+ // Ported from cos-glasses-app in 6.24.0. The companion's Sessions tab has been
237
+ // calling this and getting a 404 since the managed-runtime cutover.
238
+ app.use('/api', sessionIndexRouter)
239
+ // Presence view of Claude Code sessions on this Mac. Dark unless
240
+ // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
241
+ app.use('/api', claudeSessionsRouter)
234
242
  app.use('/api', displayRouter)
235
243
  app.use('/api', transcribeStreamRouter)
236
244
  app.use('/api', meetingRouter)
@@ -0,0 +1,185 @@
1
+ // Who else is alive on this Mac right now.
2
+ //
3
+ // Claude Code 2.1.224+ writes one JSON file per session into
4
+ // `<CLAUDE_CONFIG_DIR|~/.claude>/sessions/<pid>.json` and binds a Unix socket at
5
+ // `/tmp/cc-socks/<pid>.sock`. This reads that directory to answer one question the
6
+ // COS session index cannot: which processes are running, and which can be reached.
7
+ //
8
+ // PRESENCE, NOT MEMORY. The COS session index is durable by design and knows what
9
+ // was discussed and which business domain it belongs to. This registry is ephemeral
10
+ // by design — actively reaped on exit and again at next startup — and knows only
11
+ // where a process runs and whether it is busy. They are different layers and
12
+ // merging them into one list would lose both meanings.
13
+ //
14
+ // FOUR THINGS THAT LOOK TRUE AND ARE NOT, all probed on this machine 2026-08-10:
15
+ //
16
+ // 1. Recency is not liveness. Compute it with a signal 0, never infer it from
17
+ // mtime.
18
+ // 2. A socket file is not liveness. `/tmp/cc-socks` had an orphaned `.sock` whose
19
+ // PID was dead and whose registry file no longer existed. Sockets are not
20
+ // reaped. Reachability needs alive AND a declared socket path AND that file
21
+ // present.
22
+ // 3. A registry file outliving its process is the EXCEPTION. An earlier draft of
23
+ // this work claimed the two newest files were dead and built a rule on it; on
24
+ // re-probe fifteen minutes later all files mapped to live PIDs and the two
25
+ // called dead no longer existed. Reaping is aggressive, so an "ended sessions"
26
+ // section will be empty almost always. Design for that empty state.
27
+ // 4. `name` is not safe to echo. `nameSource: 'auto'` means an LLM wrote the label
28
+ // FROM THE WORK, so it can carry conversation content, and `/rename` clears
29
+ // nameSource entirely. Only `derived` is a function of the folder name.
30
+
31
+ /** Fields this module will read. Everything else in the file is ignored. */
32
+ export interface RawClaudeSession {
33
+ pid?: unknown
34
+ sessionId?: unknown
35
+ cwd?: unknown
36
+ startedAt?: unknown
37
+ version?: unknown
38
+ kind?: unknown
39
+ entrypoint?: unknown
40
+ messagingSocketPath?: unknown
41
+ name?: unknown
42
+ nameSource?: unknown
43
+ status?: unknown
44
+ updatedAt?: unknown
45
+ waitingFor?: unknown
46
+ }
47
+
48
+ export interface ClaudePeer {
49
+ /** Short form of sessionId. The full UUID is not needed to render a list. */
50
+ id: string
51
+ name: string
52
+ /** True when `name` was replaced because the original could carry work content. */
53
+ nameRedacted: boolean
54
+ workspace: string
55
+ entrypoint: string
56
+ kind: string
57
+ version: string
58
+ alive: boolean
59
+ reachable: boolean
60
+ status: string | null
61
+ waitingFor: string | null
62
+ lastActiveAt: number | null
63
+ startedAt: number | null
64
+ }
65
+
66
+ export interface PeerProbes {
67
+ /** signal-0 liveness. EPERM (another user) must resolve to false, not true. */
68
+ isAlive: (pid: number) => boolean
69
+ /** Does the declared socket file exist right now? */
70
+ socketExists: (path: string) => boolean
71
+ }
72
+
73
+ /** A registry filename is exactly `<pid>.json`. Not `*.json`. */
74
+ export const REGISTRY_FILENAME = /^\d+\.json$/
75
+
76
+ function str(value: unknown): string | null {
77
+ return typeof value === 'string' && value.length > 0 ? value : null
78
+ }
79
+
80
+ function millis(value: unknown): number | null {
81
+ const parsed = Number(value)
82
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
83
+ }
84
+
85
+ /** Last path segment of a cwd, with no separators and no full path. */
86
+ export function workspaceFromCwd(cwd: string | null): string {
87
+ if (!cwd) return 'unknown'
88
+ const parts = cwd.split('/').filter(Boolean)
89
+ return parts.length > 0 ? parts[parts.length - 1]! : 'unknown'
90
+ }
91
+
92
+ /**
93
+ * The label Claude derives itself: `<folder>-<suffix>`.
94
+ *
95
+ * Recomputing the derived form is what lets a `user` or `auto` name be replaced with
96
+ * something that carries no conversation content while still being recognizable.
97
+ */
98
+ export function derivedName(cwd: string | null): string {
99
+ return workspaceFromCwd(cwd)
100
+ }
101
+
102
+ /**
103
+ * Is this name safe to return?
104
+ *
105
+ * ONLY `derived`. `auto` is LLM-written from the work; `user` is arbitrary text; and
106
+ * `/rename` deletes nameSource, so a missing value means a renamed session, not a
107
+ * derived one. Anything but an exact `derived` gets the recomputed folder name.
108
+ */
109
+ export function nameIsSafe(nameSource: unknown): boolean {
110
+ return nameSource === 'derived'
111
+ }
112
+
113
+ /**
114
+ * Project one registry entry to the wire shape, or null if it is not usable.
115
+ *
116
+ * NEVER spreads the parsed object. The writer can also emit `logPath` (a full
117
+ * filesystem path), `agent`, `jobId`, `bridgeSessionId` and `parkedJobId`, none of
118
+ * which belong on a lens. Every field below is named explicitly.
119
+ */
120
+ export function toPeer(
121
+ raw: RawClaudeSession,
122
+ probes: PeerProbes,
123
+ fallbackMtimeMs: number | null = null,
124
+ /** Owner opt-in. Default false keeps the published package safe by default. */
125
+ showNames = false,
126
+ ): ClaudePeer | null {
127
+ const pid = Number(raw.pid)
128
+ if (!Number.isInteger(pid) || pid <= 0) return null
129
+ const sessionId = str(raw.sessionId)
130
+ if (!sessionId) return null
131
+
132
+ const cwd = str(raw.cwd)
133
+ const alive = probes.isAlive(pid)
134
+ const socketPath = str(raw.messagingSocketPath)
135
+ // Reachability is a conjunction. Any one of these alone is a false positive:
136
+ // an alive process on an old build has no socket, and a socket file outlives
137
+ // its process because nothing reaps /tmp/cc-socks.
138
+ const reachable = alive && socketPath !== null && probes.socketExists(socketPath)
139
+
140
+ // An explicit owner opt-in makes ANY name showable; otherwise only `derived`.
141
+ const safe = showNames || nameIsSafe(raw.nameSource)
142
+ const rawName = str(raw.name)
143
+ return {
144
+ id: sessionId.slice(0, 8),
145
+ name: safe && rawName ? rawName : derivedName(cwd),
146
+ nameRedacted: !(safe && rawName),
147
+ workspace: workspaceFromCwd(cwd),
148
+ entrypoint: str(raw.entrypoint) ?? 'unknown',
149
+ kind: str(raw.kind) ?? 'unknown',
150
+ version: str(raw.version) ?? 'unknown',
151
+ alive,
152
+ reachable,
153
+ status: str(raw.status),
154
+ waitingFor: str(raw.waitingFor),
155
+ lastActiveAt: millis(raw.updatedAt) ?? fallbackMtimeMs ?? millis(raw.startedAt),
156
+ startedAt: millis(raw.startedAt),
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Alive first, then most recently active.
162
+ *
163
+ * Alive-first matters because the list is a presence view: a dead row is history and
164
+ * must never outrank something running.
165
+ */
166
+ export function sortPeers(peers: ClaudePeer[]): ClaudePeer[] {
167
+ return [...peers].sort((a, b) => {
168
+ if (a.alive !== b.alive) return a.alive ? -1 : 1
169
+ return (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0)
170
+ })
171
+ }
172
+
173
+ export interface PeerCounts {
174
+ alive: number
175
+ reachable: number
176
+ stale: number
177
+ }
178
+
179
+ export function countPeers(peers: readonly ClaudePeer[]): PeerCounts {
180
+ return {
181
+ alive: peers.filter(p => p.alive).length,
182
+ reachable: peers.filter(p => p.reachable).length,
183
+ stale: peers.filter(p => !p.alive).length,
184
+ }
185
+ }
@@ -0,0 +1,162 @@
1
+ // GET /api/claude-sessions — read-only presence view of Claude Code sessions.
2
+ //
3
+ // Named `claude-sessions` rather than `peers` deliberately. COS already overloads
4
+ // "session" three ways — meeting recording sessions, COS conversation sessions, and
5
+ // now Claude OS processes — and in a system where the glasses, the phone and the
6
+ // server are all arguably peers, `/api/peers` would be ambiguous in exactly the
7
+ // place clarity matters.
8
+ //
9
+ // OFF BY DEFAULT. This reads another product's private state directory (mode 0700)
10
+ // and serves a projection of it over a socket that binds 0.0.0.0 behind a
11
+ // private-network allowlist. In a published npm package that has to be opt-in, so it
12
+ // stays dark until COS_CLAUDE_SESSIONS_ENABLED is set.
13
+ //
14
+ // The redaction rules and the reachability conjunction live in
15
+ // lib/claude-session-registry.ts, with the evidence for each. This file is the fs
16
+ // and process plumbing only.
17
+
18
+ import { Router } from 'express'
19
+ import { existsSync } from 'node:fs'
20
+ import { lstat, readdir, readFile, stat } from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import { join, resolve } from 'node:path'
23
+ import {
24
+ REGISTRY_FILENAME,
25
+ countPeers,
26
+ sortPeers,
27
+ toPeer,
28
+ type ClaudePeer,
29
+ type PeerProbes,
30
+ type RawClaudeSession,
31
+ } from '../lib/claude-session-registry.js'
32
+
33
+ export const claudeSessionsRouter = Router()
34
+
35
+ /** Guard against a directory someone has filled with junk. */
36
+ const MAX_REGISTRY_FILES = 500
37
+
38
+ export function claudeSessionsEnabled(): boolean {
39
+ return process.env.COS_CLAUDE_SESSIONS_ENABLED === '1'
40
+ }
41
+
42
+ /**
43
+ * Show real session names instead of the recomputed folder name?
44
+ *
45
+ * Default OFF, so the published package is safe for anyone: a `user` or `auto` name
46
+ * describes the WORK ("Kevin/Miles grievance analysis"), and this socket binds
47
+ * 0.0.0.0 behind a private-network allowlist, so anyone on the LAN holding the token
48
+ * would read it off the wire.
49
+ *
50
+ * On an owner's own machine that redaction deletes the entire value of the view —
51
+ * the names are how you tell one session from another — so it is a deliberate opt-in
52
+ * rather than a hardcoded policy. Separate from the ENABLED flag: turning the feature
53
+ * on should not silently also turn off redaction.
54
+ */
55
+ export function claudeSessionNamesVisible(): boolean {
56
+ return process.env.COS_CLAUDE_SESSIONS_SHOW_NAMES === '1'
57
+ }
58
+
59
+ /**
60
+ * Where the registry lives.
61
+ *
62
+ * `COS_CLAUDE_SESSIONS_DIR` first because it is both the override for a non-standard
63
+ * install AND the test seam — `homedir()` is not mockable, so without an env hook the
64
+ * only testable path would be the real one. Then CLAUDE_CONFIG_DIR, which real
65
+ * installs do set; hardcoding ~/.claude breaks those.
66
+ */
67
+ export function claudeSessionsDir(): string {
68
+ const explicit = process.env.COS_CLAUDE_SESSIONS_DIR
69
+ if (explicit) return resolve(explicit)
70
+ const configDir = process.env.CLAUDE_CONFIG_DIR
71
+ return join(configDir ? resolve(configDir) : join(homedir(), '.claude'), 'sessions')
72
+ }
73
+
74
+ const realProbes: PeerProbes = {
75
+ isAlive: pid => {
76
+ try {
77
+ process.kill(pid, 0)
78
+ return true
79
+ } catch (error: any) {
80
+ // ESRCH: gone. EPERM: alive but owned by another user, which is NOT reachable
81
+ // from here and must not be reported as ours.
82
+ return false
83
+ }
84
+ },
85
+ socketExists: path => {
86
+ try { return existsSync(path) } catch { return false }
87
+ },
88
+ }
89
+
90
+ export async function readClaudePeers(
91
+ dir: string,
92
+ probes: PeerProbes = realProbes,
93
+ showNames = claudeSessionNamesVisible(),
94
+ ): Promise<ClaudePeer[]> {
95
+ let names: string[]
96
+ try {
97
+ names = await readdir(dir)
98
+ } catch {
99
+ // No directory means Claude Code has never run here, or the path is wrong.
100
+ // An empty presence list is the honest answer; the route reports `enabled`
101
+ // separately so this cannot be mistaken for "the feature is off".
102
+ return []
103
+ }
104
+ const peers: ClaudePeer[] = []
105
+ for (const name of names.filter(n => REGISTRY_FILENAME.test(n)).slice(0, MAX_REGISTRY_FILES)) {
106
+ const full = join(dir, name)
107
+ try {
108
+ // lstat, not stat: a symlink here would let anything on the filesystem be read
109
+ // and projected onto the lens.
110
+ const link = await lstat(full)
111
+ if (!link.isFile()) continue
112
+ const raw = JSON.parse(await readFile(full, 'utf-8')) as RawClaudeSession
113
+ // mtime is the fallback for lastActiveAt only, never for liveness.
114
+ let mtimeMs: number | null = null
115
+ try { mtimeMs = (await stat(full)).mtimeMs } catch { /* raced the reaper */ }
116
+ const peer = toPeer(raw, probes, mtimeMs, showNames)
117
+ if (peer) peers.push(peer)
118
+ } catch {
119
+ // ENOENT between readdir and read is NORMAL here — the reaper is actively
120
+ // unlinking these — and a torn read is expected because writes are
121
+ // read-modify-write. Neither is worth surfacing.
122
+ continue
123
+ }
124
+ }
125
+ return sortPeers(peers)
126
+ }
127
+
128
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
129
+ const parsed = Number(value)
130
+ if (!Number.isFinite(parsed)) return fallback
131
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
132
+ }
133
+
134
+ claudeSessionsRouter.get('/claude-sessions', async (req, res) => {
135
+ res.set('Cache-Control', 'private, no-store')
136
+ if (!claudeSessionsEnabled()) {
137
+ // 200, not 503. The feature being switched off is a normal configuration, not a
138
+ // fault, and the companion needs to tell those apart to decide whether to render
139
+ // the section at all.
140
+ res.json({
141
+ peers: [],
142
+ counts: { alive: 0, reachable: 0, stale: 0 },
143
+ enabled: false,
144
+ reason: 'disabled',
145
+ generatedAt: Date.now(),
146
+ })
147
+ return
148
+ }
149
+ try {
150
+ const limit = boundedInteger(req.query.limit, 30, 1, 100)
151
+ const peers = await readClaudePeers(claudeSessionsDir())
152
+ res.json({
153
+ peers: peers.slice(0, limit),
154
+ counts: countPeers(peers),
155
+ enabled: true,
156
+ generatedAt: Date.now(),
157
+ })
158
+ } catch (error) {
159
+ console.error(`[claude-sessions] read failed: ${error instanceof Error ? error.message : error}`)
160
+ res.status(500).json({ error: 'Failed to read Claude session registry', reason: 'registry_read_failed' })
161
+ }
162
+ })
@@ -0,0 +1,282 @@
1
+ // GET /api/session-index — COS conversation sessions, compact list
2
+ // GET /api/session-index/:id — one session, full detail
3
+ //
4
+ // PORTED, NOT COPIED. This route lived only in the private cos-glasses-app repo
5
+ // and was never carried into the published package, so the companion's Sessions
6
+ // tab has been calling a 404 since the managed-runtime cutover — the same class of
7
+ // gap as the stranded voice profiles, the npmignore-excluded speaker model, and the
8
+ // stranded .cos-profile.json.
9
+ //
10
+ // WHY PORT RATHER THAN RETIRE. `/api/sessions/recent` looks like a duplicate and is
11
+ // not. It returns {id, exchangeCount, lastActivity, createdAt, modelPreference,
12
+ // lastQuery} from an IN-MEMORY map on a 24-hour window, so it carries no `domain`,
13
+ // no `device_id`, gives `lastQuery` where the companion wants `first_prompt`, dies
14
+ // on restart, and has no counterpart at all for the detail view's `tools_used`,
15
+ // `files_touched`, `git_branch` and token counts. The cache this route reads is
16
+ // disk-backed and already WRITTEN by the published package
17
+ // (lib/session-cache-writer.ts), so only the reader was missing.
18
+ //
19
+ // FOUR DEFECTS IN THE ORIGINAL, all fixed here and all measured on real data
20
+ // (37,700 servable entries across 5 cache files on this machine):
21
+ //
22
+ // 1. An unset COS_SCRIPTS_DIR returned `[]`, so a standalone npm install answered
23
+ // 200 with an empty list — indistinguishable from "you have no sessions". It is
24
+ // now 503 with `reason: pythonBridgeState()`, matching routes/memory.ts.
25
+ // 2. `res.status(500).json({ error: err.message })` leaked filesystem paths. No
26
+ // other router in this repo does that.
27
+ // 3. Synchronous `readdirSync`/`readFileSync` of 31.7 MB on EVERY request, the
28
+ // detail endpoint included, which parsed all of it to find one row. This
29
+ // process also streams live audio, so that blocked the event loop. Now async
30
+ // and cached against file identity.
31
+ // 4. The filename filter matched iCloud sync-conflict duplicates. On this machine
32
+ // `.session_index_cache_Ukaoma-Mac-Studio 3.json` shares 543 of its 602 rows
33
+ // with the canonical file, so the merged list served 543 duplicates. Filtering
34
+ // ` N` filenames out would be wrong in the other direction: ` 2.json` holds 245
35
+ // rows that appear nowhere else. Dedupe therefore happens by session_id, newest
36
+ // wins, and every file is still read.
37
+
38
+ import { Router } from 'express'
39
+ import { readdir, readFile, stat } from 'node:fs/promises'
40
+ import { join } from 'node:path'
41
+ import { COS_SCRIPTS_DIR, pythonBridgeState } from '../lib/python-bridge.js'
42
+
43
+ export const sessionIndexRouter = Router()
44
+
45
+ const CACHE_PREFIX = '.session_index_cache_'
46
+ /** Guard against a pathological cache file. 37,700 entries measured; 250k is slack. */
47
+ const MAX_ENTRIES = 250_000
48
+
49
+ export interface SessionEntry {
50
+ session_id: string
51
+ /** Claude Code's own sidebar title when the user set one, else ''. */
52
+ custom_title: string
53
+ /** custom_title, else the first real user message, else the short id. */
54
+ display_label: string
55
+ /** True when the harness opened this session rather than a person. */
56
+ machine_spawned: boolean
57
+ glasses_session_id: string
58
+ slug: string
59
+ created: string
60
+ modified: string
61
+ duration_minutes: number
62
+ message_count: number
63
+ first_prompt: string
64
+ domain: string
65
+ device_id: string
66
+ }
67
+
68
+ interface RawEntry extends Record<string, unknown> {
69
+ session_id: string
70
+ slug: string
71
+ }
72
+
73
+ /**
74
+ * Parse-cache keyed on the identity of the files themselves.
75
+ *
76
+ * 31.7 MB of JSON is too much to re-parse per request, and this data changes only
77
+ * when the Python side rewrites a cache file. The key is every file's name, size and
78
+ * mtime, so any write invalidates it without a timer and without serving staleness.
79
+ */
80
+ let parseCache: { key: string; entries: RawEntry[] } | null = null
81
+
82
+ async function cacheFileIdentity(dir: string): Promise<{ key: string; files: string[] }> {
83
+ const names = (await readdir(dir))
84
+ .filter(f => f.startsWith(CACHE_PREFIX) && f.endsWith('.json') && !f.endsWith('.lock'))
85
+ .sort()
86
+ const parts: string[] = []
87
+ const files: string[] = []
88
+ for (const name of names) {
89
+ try {
90
+ const st = await stat(join(dir, name))
91
+ if (!st.isFile()) continue
92
+ parts.push(`${name}:${st.size}:${st.mtimeMs}`)
93
+ files.push(name)
94
+ } catch { /* vanished between readdir and stat: normal, skip it */ }
95
+ }
96
+ return { key: parts.join('|'), files }
97
+ }
98
+
99
+ /**
100
+ * Every entry across every cache file, deduplicated by session_id.
101
+ *
102
+ * Newest wins, measured by `modified` and then `_file_mtime`. Without this the
103
+ * iCloud duplicate described in the header serves the same session twice.
104
+ */
105
+ async function readAllSessionCaches(dir: string): Promise<RawEntry[]> {
106
+ const { key, files } = await cacheFileIdentity(dir)
107
+ if (parseCache && parseCache.key === key) return parseCache.entries
108
+
109
+ const byId = new Map<string, RawEntry>()
110
+ for (const name of files) {
111
+ let parsed: unknown
112
+ try {
113
+ parsed = JSON.parse(await readFile(join(dir, name), 'utf-8'))
114
+ } catch {
115
+ // Unreadable or torn mid-write. One bad file must not empty the whole list.
116
+ continue
117
+ }
118
+ const sessions = (parsed as { sessions?: unknown })?.sessions
119
+ if (!sessions || typeof sessions !== 'object') continue
120
+ for (const value of Object.values(sessions as Record<string, unknown>)) {
121
+ if (!value || typeof value !== 'object') continue
122
+ const entry = value as RawEntry
123
+ if (typeof entry.session_id !== 'string' || !entry.session_id) continue
124
+ if (typeof entry.slug !== 'string' || !entry.slug) continue
125
+ const existing = byId.get(entry.session_id)
126
+ if (!existing || freshness(entry) > freshness(existing)) byId.set(entry.session_id, entry)
127
+ if (byId.size >= MAX_ENTRIES) break
128
+ }
129
+ }
130
+ const entries = [...byId.values()]
131
+ parseCache = { key, entries }
132
+ return entries
133
+ }
134
+
135
+ /** Sort key for "which copy of this session is the real one". */
136
+ function freshness(entry: RawEntry): number {
137
+ const modified = typeof entry.modified === 'string' ? Date.parse(entry.modified) : Number.NaN
138
+ if (Number.isFinite(modified)) return modified
139
+ const fileMtime = Number(entry._file_mtime)
140
+ return Number.isFinite(fileMtime) ? fileMtime : 0
141
+ }
142
+
143
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
144
+ const parsed = Number(value)
145
+ if (!Number.isFinite(parsed)) return fallback
146
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
147
+ }
148
+
149
+ function str(value: unknown, fallback = ''): string {
150
+ return typeof value === 'string' ? value : fallback
151
+ }
152
+
153
+ function num(value: unknown): number {
154
+ const parsed = Number(value)
155
+ return Number.isFinite(parsed) ? parsed : 0
156
+ }
157
+
158
+ /** Unset COS_SCRIPTS_DIR means standalone: say so, never answer with an empty list. */
159
+ function unavailable(res: import('express').Response): void {
160
+ res.status(503).json({
161
+ available: false,
162
+ reason: pythonBridgeState(),
163
+ sessions: [],
164
+ total: 0,
165
+ devices: [],
166
+ })
167
+ }
168
+
169
+ sessionIndexRouter.get('/session-index', async (req, res) => {
170
+ if (!COS_SCRIPTS_DIR) { unavailable(res); return }
171
+ try {
172
+ const limit = boundedInteger(req.query.limit, 30, 1, 50)
173
+ const domainFilter = str(req.query.domain, 'all') || 'all'
174
+ const deviceFilter = str(req.query.device, 'all') || 'all'
175
+ const sidFilter = str(req.query.sid)
176
+ // 1,045 of 1,210 local sessions are proxy calls, readiness probes and hook
177
+ // spawns. A list of all of them is unbrowsable, which is why Claude Code's own
178
+ // sidebar shows roughly the 165 that a person actually opened.
179
+ const humanOnly = str(req.query.human) === '1'
180
+
181
+ const all = await readAllSessionCaches(COS_SCRIPTS_DIR)
182
+ const sessions: SessionEntry[] = []
183
+ const devices = new Set<string>()
184
+
185
+ for (const entry of all) {
186
+ const domain = str(entry.domain, 'unknown') || 'unknown'
187
+ const deviceId = str(entry.device_id, 'unknown') || 'unknown'
188
+ const glassesId = str(entry.glasses_session_id)
189
+ if (domainFilter !== 'all' && domain !== domainFilter) continue
190
+ if (deviceFilter !== 'all' && deviceId !== deviceFilter) continue
191
+ if (sidFilter && !glassesId.startsWith(sidFilter)) continue
192
+ if (humanOnly && entry.machine_spawned === true) continue
193
+
194
+ // Collected from the FILTERED set on purpose, so the device picker offers only
195
+ // devices that can actually produce rows under the current filters.
196
+ if (deviceId !== 'unknown') devices.add(deviceId)
197
+
198
+ sessions.push({
199
+ session_id: entry.session_id,
200
+ glasses_session_id: glassesId,
201
+ slug: entry.slug,
202
+ created: str(entry.created),
203
+ modified: str(entry.modified),
204
+ duration_minutes: num(entry.duration_minutes),
205
+ message_count: num(entry.message_count),
206
+ first_prompt: str(entry.first_prompt),
207
+ custom_title: str(entry.custom_title),
208
+ // Older cache rows predate these fields, so fall back rather than emitting
209
+ // an empty label for every session written before this release.
210
+ display_label: str(entry.display_label) || str(entry.custom_title)
211
+ || str(entry.first_prompt) || str(entry.slug) || entry.session_id.slice(0, 8),
212
+ machine_spawned: entry.machine_spawned === true,
213
+ domain,
214
+ device_id: deviceId,
215
+ })
216
+ }
217
+
218
+ sessions.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
219
+ res.set('Cache-Control', 'private, no-store')
220
+ res.json({ sessions: sessions.slice(0, limit), total: sessions.length, devices: [...devices] })
221
+ } catch (error) {
222
+ // Detail to the operator's log, never to the client: err.message here is a
223
+ // filesystem path.
224
+ console.error(`[session-index] list failed: ${error instanceof Error ? error.message : error}`)
225
+ res.status(500).json({ error: 'Failed to read session index', reason: 'session_index_read_failed' })
226
+ }
227
+ })
228
+
229
+ sessionIndexRouter.get('/session-index/:session_id', async (req, res) => {
230
+ if (!COS_SCRIPTS_DIR) { unavailable(res); return }
231
+ try {
232
+ const wanted = String(req.params.session_id ?? '')
233
+ if (!wanted) {
234
+ res.status(400).json({ error: 'session_id required', reason: 'missing_session_id' })
235
+ return
236
+ }
237
+ const all = await readAllSessionCaches(COS_SCRIPTS_DIR)
238
+ // Exact id, then the original glasses UUID, then a UUID prefix — the companion
239
+ // links by whichever it happens to hold.
240
+ const entry = all.find(e => e.session_id === wanted)
241
+ ?? all.find(e => str(e.glasses_session_id) === wanted)
242
+ ?? all.find(e => wanted.length >= 6 && str(e.glasses_session_id).startsWith(wanted))
243
+ if (!entry) {
244
+ res.status(404).json({ error: 'Session not found', reason: 'session_not_found' })
245
+ return
246
+ }
247
+ res.set('Cache-Control', 'private, no-store')
248
+ res.json({
249
+ session_id: entry.session_id,
250
+ glasses_session_id: str(entry.glasses_session_id),
251
+ slug: str(entry.slug),
252
+ created: str(entry.created),
253
+ modified: str(entry.modified),
254
+ duration_minutes: num(entry.duration_minutes),
255
+ user_message_count: num(entry.user_message_count),
256
+ assistant_message_count: num(entry.assistant_message_count),
257
+ message_count: num(entry.message_count),
258
+ first_prompt: str(entry.first_prompt),
259
+ custom_title: str(entry.custom_title),
260
+ display_label: str(entry.display_label) || str(entry.custom_title)
261
+ || str(entry.first_prompt) || str(entry.slug) || entry.session_id.slice(0, 8),
262
+ machine_spawned: entry.machine_spawned === true,
263
+ tools_used: (entry.tools_used && typeof entry.tools_used === 'object') ? entry.tools_used : {},
264
+ files_touched: Array.isArray(entry.files_touched) ? entry.files_touched : [],
265
+ domain: str(entry.domain, 'unknown') || 'unknown',
266
+ git_branch: str(entry.git_branch, 'unknown') || 'unknown',
267
+ has_subagents: entry.has_subagents === true,
268
+ total_input_tokens: num(entry.total_input_tokens),
269
+ total_output_tokens: num(entry.total_output_tokens),
270
+ file_size_bytes: num(entry.file_size_bytes),
271
+ device_id: str(entry.device_id, 'unknown') || 'unknown',
272
+ })
273
+ } catch (error) {
274
+ console.error(`[session-index] detail failed: ${error instanceof Error ? error.message : error}`)
275
+ res.status(500).json({ error: 'Failed to read session index', reason: 'session_index_read_failed' })
276
+ }
277
+ })
278
+
279
+ /** Test seam: drop the parse cache between cases. */
280
+ export function __resetSessionIndexCache(): void {
281
+ parseCache = null
282
+ }