@gotcos/glasses-server 6.23.1 → 6.24.1

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,105 @@
1
+ ## 6.24.1
2
+
3
+ Surfaces `project` on session rows, so the list can group the way Claude Code's own
4
+ sidebar does.
5
+
6
+ - The indexer was scanning ONE project directory. `PROJECTS_DIR` was hardcoded to
7
+ MU-Chief-Staff, so 17 of 18 project dirs — `cos-glasses-app`, `cos-glasses-server`,
8
+ the 119 projects, the COS examples — were never indexed at all. That is why the
9
+ Sessions list could never match the sidebar: entire projects were absent, not just
10
+ mislabelled. Fixed in session_indexer.py (COS repo); 1,212 sessions now carry a
11
+ project where 1,000 were visible before.
12
+ - `project` comes from the `cwd` on the session records, not from decoding the
13
+ directory name. Claude Code replaces path separators with dashes and a real dash is
14
+ indistinguishable from a separator afterwards, so
15
+ `-Users-ukaoma-Documents-GitHub-Ukaoma-Chief-Of-Staff-MU-Chief-Staff` decoded to
16
+ "Ukaoma-Chief-Of-Staff-MU-Chief-Staff" where the sidebar says "MU-Chief-Staff".
17
+
18
+ ## 6.24.0
19
+
20
+ The Sessions tab stops 404ing, and a read-only presence view of Claude Code
21
+ sessions on this Mac arrives behind a flag.
22
+
23
+ - **`/api/session-index` was never ported into the published package.** The route
24
+ lives in the private app repo and is mounted there; the companion has been calling
25
+ it and getting an Express HTML 404 since the managed-runtime cutover. Same class as
26
+ the stranded voice profiles, the npmignore-excluded speaker model and the stranded
27
+ `.cos-profile.json`.
28
+ - **Ported rather than retired, against the original recommendation.**
29
+ `/api/sessions/recent` looked like a duplicate and is not: it reads an IN-MEMORY map
30
+ on a 24-hour window, carries no `domain` and no `device_id`, gives `lastQuery` where
31
+ the companion wants `first_prompt`, dies on restart, and has no counterpart at all
32
+ for the detail view's `tools_used`, `files_touched`, `git_branch` and token counts.
33
+ `lib/session-cache-writer.ts` already writes the disk cache, so only the reader was
34
+ missing.
35
+ - **Four defects fixed in the port, all measured on the real 37,700-entry cache.**
36
+ An unset `COS_SCRIPTS_DIR` returned `[]` — a 200 that reads as "you have no
37
+ sessions" on every standalone install — now 503 with `reason: pythonBridgeState()`.
38
+ `err.message` leaked filesystem paths; now a generic reason, detail to the log.
39
+ 31.7 MB was parsed synchronously per request, the detail endpoint included, on a
40
+ process that also streams live audio; now async and cached against file identity
41
+ (161ms cold, 15ms warm). And the filename filter matched iCloud sync-conflict
42
+ duplicates: `.session_index_cache_Ukaoma-Mac-Studio 3.json` shared 543 of its 602
43
+ rows with the canonical file, so the merged list served 543 duplicates. Verified on
44
+ real data: 37,700 naive becomes 37,157 served, exactly 543 removed. Filtering ` N`
45
+ filenames would have been wrong in the other direction, since ` 2.json` holds 245
46
+ rows appearing nowhere else, so dedupe is by session_id with newest winning.
47
+ - **New `GET /api/claude-sessions`, off unless `COS_CLAUDE_SESSIONS_ENABLED=1`.**
48
+ It projects another product's 0700 state directory over a socket bound to 0.0.0.0
49
+ behind a private-network allowlist, so in a published package that has to be opt-in.
50
+ Named `claude-sessions` rather than `peers` because COS already overloads "session"
51
+ three ways and the glasses, phone and server are all arguably peers.
52
+ - **Redaction is decided by `nameSource`, and only `derived` passes.** `auto` means an
53
+ LLM wrote the label FROM THE WORK, and `/rename` clears the field entirely, so a
54
+ missing `nameSource` is a renamed session rather than a derived one. Anything but an
55
+ exact `derived` is replaced with the recomputed folder name and flagged
56
+ `nameRedacted`. Echoing `name` while redacting `cwd` would not have been redaction.
57
+ - **Reachability is a conjunction: alive AND a declared socket path AND that file
58
+ present.** Each alone is a false positive. Verified live: the 2.1.222 row reports
59
+ `reachable: false` for having no socket path, and `/tmp/cc-socks` holds an orphaned
60
+ `.sock` whose pid is dead and whose registry file is already gone, because sockets
61
+ are not reaped. Liveness is a signal 0, never inferred from mtime, and EPERM
62
+ resolves to false rather than true.
63
+ - Fields are named explicitly, never spread. The writer can also emit `logPath` (a
64
+ full path), `agent`, `jobId`, `bridgeSessionId` and `parkedJobId`. Verified against
65
+ the live registry: no `/Users/`, no `cc-socks`, no `logPath`, no `cwd` on the wire.
66
+ - Registry reads honor `CLAUDE_CONFIG_DIR`, match `<pid>.json` strictly rather than
67
+ `*.json`, `lstat` to refuse symlinks, and treat an ENOENT mid-read as normal because
68
+ the reaper is actively unlinking.
69
+
70
+ - **Session labels you can actually read, and machine sessions you can hide.** The
71
+ list is only useful if the rows have names. Measured 2026-08-10: `first_prompt` took
72
+ the first user message unconditionally, so a slash-command session was labelled
73
+ `<command-message>cos-glasses</command-message>`, a proxy session was labelled "You
74
+ are the COS Slack Bridge proxy", and everything else fell back to a random slug
75
+ (`crispy-coalescing-salamander`) or the bare UUID. Fixed in the Python indexer with a
76
+ filter, not an LLM: wrappers are stripped, a slash command keeps its name, injected
77
+ persona prompts are rejected. `custom_title` now carries Claude Code's own sidebar
78
+ title where the user set one — `3dc7e253` correctly reads "COS-glasses Server work
79
+ (meetings)". New `display_label` resolves title, then derived label, then short id,
80
+ and falls back for the 37,157 rows written before these fields existed.
81
+ - **`?human=1` hides harness-opened sessions.** 1,045 of 1,210 local sessions are proxy
82
+ calls, readiness probes and hook spawns; roughly 165 were opened by a person, which is
83
+ the order of magnitude Claude Code's own sidebar shows. `machine_spawned` is strict
84
+ `=== true`, so a bad value fails OPEN — losing a session the user had is worse than
85
+ showing a machine one.
86
+ - **`COS_CLAUDE_SESSIONS_SHOW_NAMES=1` opts into real session names.** Redaction stays
87
+ the default so the published package is safe for anyone, but on an owner's own machine
88
+ it deleted the whole value of the view, since the names are how you tell one session
89
+ from another. Deliberately a SEPARATE switch from the enable flag: turning the feature
90
+ on must not silently turn redaction off. Opting in still never exposes a path or an
91
+ unlisted field.
92
+
93
+ **Sending messages from the glasses stays closed, not parked.** COS launches Claude
94
+ with `--dangerously-skip-permissions` in BOTH branches of `claude-permissions.ts:44`,
95
+ so there is no configuration in which an inbound message reaches a receiver that
96
+ would prompt. Combined with the 0.0.0.0 bind, that is a LAN-token-to-RCE path.
97
+
98
+ Coverage: 75 tests across the two routes, 20 mutations all caught. One was an invalid
99
+ experiment first time round — the 500 responder is identical in both handlers, so
100
+ mutating them together looked covered; mutated one at a time the list site was caught
101
+ and the DETAIL site SURVIVED, which found a genuinely untested leak path.
102
+
1
103
  ## 6.23.1
2
104
 
3
105
  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.1",
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,286 @@
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
+ /** Project name, matching the group heading in Claude Code's own sidebar. */
58
+ project: string
59
+ glasses_session_id: string
60
+ slug: string
61
+ created: string
62
+ modified: string
63
+ duration_minutes: number
64
+ message_count: number
65
+ first_prompt: string
66
+ domain: string
67
+ device_id: string
68
+ }
69
+
70
+ interface RawEntry extends Record<string, unknown> {
71
+ session_id: string
72
+ slug: string
73
+ }
74
+
75
+ /**
76
+ * Parse-cache keyed on the identity of the files themselves.
77
+ *
78
+ * 31.7 MB of JSON is too much to re-parse per request, and this data changes only
79
+ * when the Python side rewrites a cache file. The key is every file's name, size and
80
+ * mtime, so any write invalidates it without a timer and without serving staleness.
81
+ */
82
+ let parseCache: { key: string; entries: RawEntry[] } | null = null
83
+
84
+ async function cacheFileIdentity(dir: string): Promise<{ key: string; files: string[] }> {
85
+ const names = (await readdir(dir))
86
+ .filter(f => f.startsWith(CACHE_PREFIX) && f.endsWith('.json') && !f.endsWith('.lock'))
87
+ .sort()
88
+ const parts: string[] = []
89
+ const files: string[] = []
90
+ for (const name of names) {
91
+ try {
92
+ const st = await stat(join(dir, name))
93
+ if (!st.isFile()) continue
94
+ parts.push(`${name}:${st.size}:${st.mtimeMs}`)
95
+ files.push(name)
96
+ } catch { /* vanished between readdir and stat: normal, skip it */ }
97
+ }
98
+ return { key: parts.join('|'), files }
99
+ }
100
+
101
+ /**
102
+ * Every entry across every cache file, deduplicated by session_id.
103
+ *
104
+ * Newest wins, measured by `modified` and then `_file_mtime`. Without this the
105
+ * iCloud duplicate described in the header serves the same session twice.
106
+ */
107
+ async function readAllSessionCaches(dir: string): Promise<RawEntry[]> {
108
+ const { key, files } = await cacheFileIdentity(dir)
109
+ if (parseCache && parseCache.key === key) return parseCache.entries
110
+
111
+ const byId = new Map<string, RawEntry>()
112
+ for (const name of files) {
113
+ let parsed: unknown
114
+ try {
115
+ parsed = JSON.parse(await readFile(join(dir, name), 'utf-8'))
116
+ } catch {
117
+ // Unreadable or torn mid-write. One bad file must not empty the whole list.
118
+ continue
119
+ }
120
+ const sessions = (parsed as { sessions?: unknown })?.sessions
121
+ if (!sessions || typeof sessions !== 'object') continue
122
+ for (const value of Object.values(sessions as Record<string, unknown>)) {
123
+ if (!value || typeof value !== 'object') continue
124
+ const entry = value as RawEntry
125
+ if (typeof entry.session_id !== 'string' || !entry.session_id) continue
126
+ if (typeof entry.slug !== 'string' || !entry.slug) continue
127
+ const existing = byId.get(entry.session_id)
128
+ if (!existing || freshness(entry) > freshness(existing)) byId.set(entry.session_id, entry)
129
+ if (byId.size >= MAX_ENTRIES) break
130
+ }
131
+ }
132
+ const entries = [...byId.values()]
133
+ parseCache = { key, entries }
134
+ return entries
135
+ }
136
+
137
+ /** Sort key for "which copy of this session is the real one". */
138
+ function freshness(entry: RawEntry): number {
139
+ const modified = typeof entry.modified === 'string' ? Date.parse(entry.modified) : Number.NaN
140
+ if (Number.isFinite(modified)) return modified
141
+ const fileMtime = Number(entry._file_mtime)
142
+ return Number.isFinite(fileMtime) ? fileMtime : 0
143
+ }
144
+
145
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
146
+ const parsed = Number(value)
147
+ if (!Number.isFinite(parsed)) return fallback
148
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
149
+ }
150
+
151
+ function str(value: unknown, fallback = ''): string {
152
+ return typeof value === 'string' ? value : fallback
153
+ }
154
+
155
+ function num(value: unknown): number {
156
+ const parsed = Number(value)
157
+ return Number.isFinite(parsed) ? parsed : 0
158
+ }
159
+
160
+ /** Unset COS_SCRIPTS_DIR means standalone: say so, never answer with an empty list. */
161
+ function unavailable(res: import('express').Response): void {
162
+ res.status(503).json({
163
+ available: false,
164
+ reason: pythonBridgeState(),
165
+ sessions: [],
166
+ total: 0,
167
+ devices: [],
168
+ })
169
+ }
170
+
171
+ sessionIndexRouter.get('/session-index', async (req, res) => {
172
+ if (!COS_SCRIPTS_DIR) { unavailable(res); return }
173
+ try {
174
+ const limit = boundedInteger(req.query.limit, 30, 1, 50)
175
+ const domainFilter = str(req.query.domain, 'all') || 'all'
176
+ const deviceFilter = str(req.query.device, 'all') || 'all'
177
+ const sidFilter = str(req.query.sid)
178
+ // 1,045 of 1,210 local sessions are proxy calls, readiness probes and hook
179
+ // spawns. A list of all of them is unbrowsable, which is why Claude Code's own
180
+ // sidebar shows roughly the 165 that a person actually opened.
181
+ const humanOnly = str(req.query.human) === '1'
182
+
183
+ const all = await readAllSessionCaches(COS_SCRIPTS_DIR)
184
+ const sessions: SessionEntry[] = []
185
+ const devices = new Set<string>()
186
+
187
+ for (const entry of all) {
188
+ const domain = str(entry.domain, 'unknown') || 'unknown'
189
+ const deviceId = str(entry.device_id, 'unknown') || 'unknown'
190
+ const glassesId = str(entry.glasses_session_id)
191
+ if (domainFilter !== 'all' && domain !== domainFilter) continue
192
+ if (deviceFilter !== 'all' && deviceId !== deviceFilter) continue
193
+ if (sidFilter && !glassesId.startsWith(sidFilter)) continue
194
+ if (humanOnly && entry.machine_spawned === true) continue
195
+
196
+ // Collected from the FILTERED set on purpose, so the device picker offers only
197
+ // devices that can actually produce rows under the current filters.
198
+ if (deviceId !== 'unknown') devices.add(deviceId)
199
+
200
+ sessions.push({
201
+ session_id: entry.session_id,
202
+ glasses_session_id: glassesId,
203
+ slug: entry.slug,
204
+ created: str(entry.created),
205
+ modified: str(entry.modified),
206
+ duration_minutes: num(entry.duration_minutes),
207
+ message_count: num(entry.message_count),
208
+ first_prompt: str(entry.first_prompt),
209
+ custom_title: str(entry.custom_title),
210
+ // Older cache rows predate these fields, so fall back rather than emitting
211
+ // an empty label for every session written before this release.
212
+ display_label: str(entry.display_label) || str(entry.custom_title)
213
+ || str(entry.first_prompt) || str(entry.slug) || entry.session_id.slice(0, 8),
214
+ machine_spawned: entry.machine_spawned === true,
215
+ project: str(entry.project),
216
+ domain,
217
+ device_id: deviceId,
218
+ })
219
+ }
220
+
221
+ sessions.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
222
+ res.set('Cache-Control', 'private, no-store')
223
+ res.json({ sessions: sessions.slice(0, limit), total: sessions.length, devices: [...devices] })
224
+ } catch (error) {
225
+ // Detail to the operator's log, never to the client: err.message here is a
226
+ // filesystem path.
227
+ console.error(`[session-index] list failed: ${error instanceof Error ? error.message : error}`)
228
+ res.status(500).json({ error: 'Failed to read session index', reason: 'session_index_read_failed' })
229
+ }
230
+ })
231
+
232
+ sessionIndexRouter.get('/session-index/:session_id', async (req, res) => {
233
+ if (!COS_SCRIPTS_DIR) { unavailable(res); return }
234
+ try {
235
+ const wanted = String(req.params.session_id ?? '')
236
+ if (!wanted) {
237
+ res.status(400).json({ error: 'session_id required', reason: 'missing_session_id' })
238
+ return
239
+ }
240
+ const all = await readAllSessionCaches(COS_SCRIPTS_DIR)
241
+ // Exact id, then the original glasses UUID, then a UUID prefix — the companion
242
+ // links by whichever it happens to hold.
243
+ const entry = all.find(e => e.session_id === wanted)
244
+ ?? all.find(e => str(e.glasses_session_id) === wanted)
245
+ ?? all.find(e => wanted.length >= 6 && str(e.glasses_session_id).startsWith(wanted))
246
+ if (!entry) {
247
+ res.status(404).json({ error: 'Session not found', reason: 'session_not_found' })
248
+ return
249
+ }
250
+ res.set('Cache-Control', 'private, no-store')
251
+ res.json({
252
+ session_id: entry.session_id,
253
+ glasses_session_id: str(entry.glasses_session_id),
254
+ slug: str(entry.slug),
255
+ created: str(entry.created),
256
+ modified: str(entry.modified),
257
+ duration_minutes: num(entry.duration_minutes),
258
+ user_message_count: num(entry.user_message_count),
259
+ assistant_message_count: num(entry.assistant_message_count),
260
+ message_count: num(entry.message_count),
261
+ first_prompt: str(entry.first_prompt),
262
+ custom_title: str(entry.custom_title),
263
+ display_label: str(entry.display_label) || str(entry.custom_title)
264
+ || str(entry.first_prompt) || str(entry.slug) || entry.session_id.slice(0, 8),
265
+ machine_spawned: entry.machine_spawned === true,
266
+ project: str(entry.project),
267
+ tools_used: (entry.tools_used && typeof entry.tools_used === 'object') ? entry.tools_used : {},
268
+ files_touched: Array.isArray(entry.files_touched) ? entry.files_touched : [],
269
+ domain: str(entry.domain, 'unknown') || 'unknown',
270
+ git_branch: str(entry.git_branch, 'unknown') || 'unknown',
271
+ has_subagents: entry.has_subagents === true,
272
+ total_input_tokens: num(entry.total_input_tokens),
273
+ total_output_tokens: num(entry.total_output_tokens),
274
+ file_size_bytes: num(entry.file_size_bytes),
275
+ device_id: str(entry.device_id, 'unknown') || 'unknown',
276
+ })
277
+ } catch (error) {
278
+ console.error(`[session-index] detail failed: ${error instanceof Error ? error.message : error}`)
279
+ res.status(500).json({ error: 'Failed to read session index', reason: 'session_index_read_failed' })
280
+ }
281
+ })
282
+
283
+ /** Test seam: drop the parse cache between cases. */
284
+ export function __resetSessionIndexCache(): void {
285
+ parseCache = null
286
+ }