@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 +102 -0
- package/package.json +1 -1
- package/server/index.ts +8 -0
- package/server/lib/claude-session-registry.ts +185 -0
- package/server/routes/claude-sessions.ts +162 -0
- package/server/routes/session-index.ts +286 -0
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.
|
|
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
|
+
}
|