@gotcos/glasses-server 6.23.0 → 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 +122 -0
- package/package.json +1 -1
- package/server/index.ts +8 -0
- package/server/lib/claude-session-registry.ts +185 -0
- package/server/lib/quarantine-auto-recover.ts +148 -0
- package/server/routes/claude-sessions.ts +162 -0
- package/server/routes/session-index.ts +282 -0
- package/server/routes/transcribe-stream.ts +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,125 @@
|
|
|
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
|
+
|
|
86
|
+
## 6.23.1
|
|
87
|
+
|
|
88
|
+
Closes the hole 6.23.0 left open, plus a lockfile version that 6.23.0 shipped out of
|
|
89
|
+
sync with package.json.
|
|
90
|
+
|
|
91
|
+
- **A restart used to re-open the bug.** 6.23.0 saves a stranded capture at the
|
|
92
|
+
4-hour cutoff, but only while the server stays up. `recoverSessions()` refuses to
|
|
93
|
+
load any session already past that cutoff at boot — it tombstones it — so a
|
|
94
|
+
restart, a COS Control update, or a crash at the wrong moment meant the sweeper
|
|
95
|
+
never saw the session and its audio landed in quarantine with no meeting. Not
|
|
96
|
+
hypothetical: `meeting_1786237535593` (139 chunks, 31 MB, `idle_expiry_unsaved`)
|
|
97
|
+
arrived there that way.
|
|
98
|
+
- **Quarantined audio now recovers itself.** The same 60-second tick picks ONE
|
|
99
|
+
unrecovered capture with chunks and asks the real
|
|
100
|
+
`POST /api/meeting/orphans/:id/recover` to turn it into a meeting. One at a time
|
|
101
|
+
because that route runs a full batch transcription — real GPU work, minutes for a
|
|
102
|
+
long capture — and a parallel backlog would starve a live recording. Oldest first,
|
|
103
|
+
since it is closest to the 72-hour purge.
|
|
104
|
+
- **It gives up rather than looping.** Three attempts per capture, then it stops and
|
|
105
|
+
says so. A capture with unreadable chunks would otherwise be retried every 60
|
|
106
|
+
seconds for three days. The audio stays quarantined and recoverable by hand, which
|
|
107
|
+
beats a retry loop that never converges. A 409 from a manual recovery does not burn
|
|
108
|
+
the budget.
|
|
109
|
+
- Recovered captures are titled "Recovered capture (audio only)", distinct from a
|
|
110
|
+
promoted session's "Auto-saved capture", because a quarantine recovery has no live
|
|
111
|
+
ASR and every speaker comes back Unknown. The library should say which is which
|
|
112
|
+
without opening the file.
|
|
113
|
+
- **`package-lock.json` was still on 6.22.1 while package.json said 6.23.0.** Caught
|
|
114
|
+
by the repo's own `launcher-contract` test, which I did not re-run after bumping the
|
|
115
|
+
version. The published 6.23.0 code is unaffected; the lockfile is now aligned and
|
|
116
|
+
the suite runs after the bump, not before it.
|
|
117
|
+
|
|
118
|
+
Verified live before this release: a backdated synthetic session was recovered at
|
|
119
|
+
boot, drafted by the sweeper within 60s, and promoted to a meeting at the cutoff
|
|
120
|
+
about 40s later, with the draft cleared and the domain inferred rather than
|
|
121
|
+
hardcoded. 14 new tests here, full suite 1425 serially.
|
|
122
|
+
|
|
1
123
|
## 6.23.0
|
|
2
124
|
|
|
3
125
|
A recording whose phone goes away now becomes a meeting on its own. Miles: "we end
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
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,148 @@
|
|
|
1
|
+
// Quarantined audio that nobody asked to recover still has to become a meeting.
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE THIS CLOSES. 6.23.0 made a stranded session save itself at the 4-hour
|
|
4
|
+
// cutoff, but that only works while the server stays up. `recoverSessions()`
|
|
5
|
+
// refuses to load any session already past the cutoff at boot — it tombstones it —
|
|
6
|
+
// so a restart, a COS Control update, or a crash at the wrong moment means the
|
|
7
|
+
// sweeper never sees the session, its audio dir is orphaned, and it lands in
|
|
8
|
+
// quarantine with no meeting. That is exactly the old behavior, reached by a
|
|
9
|
+
// different door, and it is not hypothetical: `meeting_1786237535593` (139 chunks,
|
|
10
|
+
// 31 MB, reason `idle_expiry_unsaved`) came through it.
|
|
11
|
+
//
|
|
12
|
+
// Quarantine already keeps the audio for 72 hours and `POST
|
|
13
|
+
// /api/meeting/orphans/:id/recover` already turns it into a meeting. The only thing
|
|
14
|
+
// missing was that a human had to notice and press it. This picks one per sweep.
|
|
15
|
+
//
|
|
16
|
+
// WHY ONE AT A TIME. Recovery runs a full batch transcription over every chunk WAV
|
|
17
|
+
// — real GPU and CPU work, minutes for a long capture. Recovering a backlog in
|
|
18
|
+
// parallel would starve a live recording, so the sweep takes the oldest and leaves
|
|
19
|
+
// the rest for the next tick. The route's own `shouldAbort` already yields to a live
|
|
20
|
+
// recording once started.
|
|
21
|
+
//
|
|
22
|
+
// WHY THE ATTEMPT LEDGER. A capture that cannot be recovered — unreadable chunks, a
|
|
23
|
+
// codec the batch path rejects — would otherwise be retried every 60 seconds for 72
|
|
24
|
+
// hours, burning the machine and drowning the log. After a few failures it is left
|
|
25
|
+
// alone: the audio is still preserved and still recoverable by hand, which is
|
|
26
|
+
// strictly better than a retry loop that never converges.
|
|
27
|
+
|
|
28
|
+
import type { UnsavedCapture } from './unsaved-audio-quarantine.js'
|
|
29
|
+
|
|
30
|
+
/** Attempts per capture before the sweep stops trying on its own. */
|
|
31
|
+
export const MAX_AUTO_RECOVER_ATTEMPTS = 3
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Title for a capture the sweep recovers.
|
|
35
|
+
*
|
|
36
|
+
* Distinct from the promote title on purpose. A promoted session carried a live ASR
|
|
37
|
+
* transcript with speaker labels; a quarantine recovery has neither, because no live
|
|
38
|
+
* ASR ever ran on it — every speaker comes back Unknown. The user should be able to
|
|
39
|
+
* tell those two apart in the library without opening them.
|
|
40
|
+
*/
|
|
41
|
+
export const AUTO_RECOVER_TITLE = 'Recovered capture (audio only)'
|
|
42
|
+
|
|
43
|
+
export interface AutoRecoverState {
|
|
44
|
+
/** sessionId → attempts already made this process lifetime. */
|
|
45
|
+
attempts: Map<string, number>
|
|
46
|
+
/** Recoveries currently running, from the route's own set. */
|
|
47
|
+
inFlight: ReadonlySet<string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which quarantined capture, if any, should the sweep recover next?
|
|
52
|
+
*
|
|
53
|
+
* Returns null when there is nothing to do — the common case — so the caller does no
|
|
54
|
+
* work on a quiet tick.
|
|
55
|
+
*/
|
|
56
|
+
export function pickQuarantineToRecover(
|
|
57
|
+
items: readonly UnsavedCapture[],
|
|
58
|
+
state: AutoRecoverState,
|
|
59
|
+
): UnsavedCapture | null {
|
|
60
|
+
const eligible = items.filter(item => {
|
|
61
|
+
// Already a meeting. Recovering again would duplicate it.
|
|
62
|
+
if (item.recovered) return false
|
|
63
|
+
// Nothing to transcribe: a chunk-less dir is residue, not evidence.
|
|
64
|
+
if (item.chunkFiles <= 0) return false
|
|
65
|
+
// Another recovery owns this one.
|
|
66
|
+
if (state.inFlight.has(item.sessionId)) return false
|
|
67
|
+
return (state.attempts.get(item.sessionId) ?? 0) < MAX_AUTO_RECOVER_ATTEMPTS
|
|
68
|
+
})
|
|
69
|
+
if (eligible.length === 0) return null
|
|
70
|
+
// Oldest first: it is closest to the 72-hour purge, so it has the least time left.
|
|
71
|
+
// `ageHours` can be null when the marker is unreadable — treat that as oldest
|
|
72
|
+
// rather than newest, because an unreadable marker is itself a sign of an old dir.
|
|
73
|
+
return [...eligible].sort((a, b) => (b.ageHours ?? Number.MAX_SAFE_INTEGER)
|
|
74
|
+
- (a.ageHours ?? Number.MAX_SAFE_INTEGER))[0] ?? null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Record an attempt. Called BEFORE the request, so a hang still counts. */
|
|
78
|
+
export function noteRecoverAttempt(state: AutoRecoverState, sessionId: string): void {
|
|
79
|
+
state.attempts.set(sessionId, (state.attempts.get(sessionId) ?? 0) + 1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Clear the ledger for a capture that succeeded, so a later re-quarantine is fresh. */
|
|
83
|
+
export function clearRecoverAttempts(state: AutoRecoverState, sessionId: string): void {
|
|
84
|
+
state.attempts.delete(sessionId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function autoRecoverExhausted(state: AutoRecoverState, sessionId: string): boolean {
|
|
88
|
+
return (state.attempts.get(sessionId) ?? 0) >= MAX_AUTO_RECOVER_ATTEMPTS
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RecoverRequestResult {
|
|
92
|
+
ok: boolean
|
|
93
|
+
status: number
|
|
94
|
+
filename?: string
|
|
95
|
+
reason?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RecoverRequestOptions {
|
|
99
|
+
port: number
|
|
100
|
+
token: string
|
|
101
|
+
fetchImpl?: typeof fetch
|
|
102
|
+
timeoutMs?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Ask the real recover route to turn a quarantined capture into a meeting.
|
|
107
|
+
*
|
|
108
|
+
* Loopback for the same reason promote is: that route owns the recovering-set
|
|
109
|
+
* guard, the batch transcription, the `shouldAbort` yield to a live recording, the
|
|
110
|
+
* receipt and the `markRecovered` stamp. Reimplementing any of it here would fork
|
|
111
|
+
* the path a user's own button press takes.
|
|
112
|
+
*
|
|
113
|
+
* A generous default timeout: batch transcription of a long capture is minutes of
|
|
114
|
+
* real work, and aborting early would leave the route running with no one reading
|
|
115
|
+
* the result.
|
|
116
|
+
*/
|
|
117
|
+
export async function requestQuarantineRecovery(
|
|
118
|
+
sessionId: string,
|
|
119
|
+
options: RecoverRequestOptions,
|
|
120
|
+
): Promise<RecoverRequestResult> {
|
|
121
|
+
if (!options.token) return { ok: false, status: 0, reason: 'no_token' }
|
|
122
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
123
|
+
const controller = new AbortController()
|
|
124
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 1_800_000)
|
|
125
|
+
try {
|
|
126
|
+
const res = await doFetch(
|
|
127
|
+
`http://127.0.0.1:${options.port}/api/meeting/orphans/${encodeURIComponent(sessionId)}/recover`,
|
|
128
|
+
{
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json', 'X-Cos-Token': options.token },
|
|
131
|
+
body: JSON.stringify({ title: AUTO_RECOVER_TITLE }),
|
|
132
|
+
signal: controller.signal,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
let payload: Record<string, unknown> = {}
|
|
136
|
+
try { payload = await res.json() as Record<string, unknown> } catch {}
|
|
137
|
+
return {
|
|
138
|
+
ok: res.ok,
|
|
139
|
+
status: res.status,
|
|
140
|
+
filename: typeof payload.filename === 'string' ? payload.filename : undefined,
|
|
141
|
+
reason: typeof payload.reason === 'string' ? payload.reason : undefined,
|
|
142
|
+
}
|
|
143
|
+
} catch (error: any) {
|
|
144
|
+
return { ok: false, status: 0, reason: error?.name === 'AbortError' ? 'timeout' : 'request_failed' }
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timer)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -23,6 +23,14 @@ import {
|
|
|
23
23
|
writeStrandedDraft,
|
|
24
24
|
} from '../lib/stranded-session-actions.js'
|
|
25
25
|
import { getSessionHeartbeat } from '../lib/session-heartbeats.js'
|
|
26
|
+
import {
|
|
27
|
+
MAX_AUTO_RECOVER_ATTEMPTS,
|
|
28
|
+
autoRecoverExhausted,
|
|
29
|
+
clearRecoverAttempts,
|
|
30
|
+
noteRecoverAttempt,
|
|
31
|
+
pickQuarantineToRecover,
|
|
32
|
+
requestQuarantineRecovery,
|
|
33
|
+
} from '../lib/quarantine-auto-recover.js'
|
|
26
34
|
import { TranscriptionUnavailableError } from '../lib/transcribe-audio.js'
|
|
27
35
|
|
|
28
36
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
@@ -56,6 +64,7 @@ import {
|
|
|
56
64
|
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
57
65
|
import {
|
|
58
66
|
countChunkWavs,
|
|
67
|
+
listUnsavedCaptures,
|
|
59
68
|
purgeExpiredQuarantine,
|
|
60
69
|
quarantineSessionAudio,
|
|
61
70
|
sweepOrphanedSessionAudio,
|
|
@@ -836,6 +845,57 @@ if (maintenanceAdmissionsOpen()) {
|
|
|
836
845
|
recoverSessions()
|
|
837
846
|
}
|
|
838
847
|
|
|
848
|
+
/**
|
|
849
|
+
* Quarantined audio has no live session, so it needs its own pass.
|
|
850
|
+
*
|
|
851
|
+
* This closes the hole 6.23.0 left open: `recoverSessions()` tombstones any session
|
|
852
|
+
* already past the 4-hour cutoff at boot, so a restart while a capture is stranded
|
|
853
|
+
* means the promote above never runs and the audio lands here instead — preserved
|
|
854
|
+
* for 72 hours, but not a meeting, and only recoverable by hand. One per tick.
|
|
855
|
+
*/
|
|
856
|
+
const autoRecoverState = {
|
|
857
|
+
attempts: new Map<string, number>(),
|
|
858
|
+
inFlight: new Set<string>(),
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function autoRecoverOneQuarantinedCapture(): void {
|
|
862
|
+
const token = process.env.COS_API_TOKEN ?? ''
|
|
863
|
+
if (!token) return
|
|
864
|
+
const picked = pickQuarantineToRecover(listUnsavedCaptures(), autoRecoverState)
|
|
865
|
+
if (!picked) return
|
|
866
|
+
const sessionId = picked.sessionId
|
|
867
|
+
autoRecoverState.inFlight.add(sessionId)
|
|
868
|
+
noteRecoverAttempt(autoRecoverState, sessionId)
|
|
869
|
+
void requestQuarantineRecovery(sessionId, {
|
|
870
|
+
port: parseInt(process.env.PORT ?? '3141', 10),
|
|
871
|
+
token,
|
|
872
|
+
})
|
|
873
|
+
.then(result => {
|
|
874
|
+
if (result.ok) {
|
|
875
|
+
console.warn(
|
|
876
|
+
`[quarantine] Auto-recovered ${sessionId} \u2192 ${result.filename ?? 'saved'} `
|
|
877
|
+
+ `(${picked.chunkFiles} chunks, speakers unlabeled — no live ASR ran on it)`,
|
|
878
|
+
)
|
|
879
|
+
clearRecoverAttempts(autoRecoverState, sessionId)
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
// 409 means a manual recovery already owns it, which is not a failure and
|
|
883
|
+
// should not burn the budget.
|
|
884
|
+
if (result.status === 409) {
|
|
885
|
+
clearRecoverAttempts(autoRecoverState, sessionId)
|
|
886
|
+
return
|
|
887
|
+
}
|
|
888
|
+
const attempts = autoRecoverState.attempts.get(sessionId) ?? 0
|
|
889
|
+
console.error(
|
|
890
|
+
`[quarantine] Auto-recovery failed for ${sessionId} `
|
|
891
|
+
+ `(${result.status} ${result.reason ?? ''}), attempt ${attempts}/${MAX_AUTO_RECOVER_ATTEMPTS}`
|
|
892
|
+
+ `${autoRecoverExhausted(autoRecoverState, sessionId)
|
|
893
|
+
? ' — giving up, audio stays quarantined and recoverable by hand' : ''}`,
|
|
894
|
+
)
|
|
895
|
+
})
|
|
896
|
+
.finally(() => autoRecoverState.inFlight.delete(sessionId))
|
|
897
|
+
}
|
|
898
|
+
|
|
839
899
|
/** Sessions with an auto-save in flight, so a later tick cannot start a second. */
|
|
840
900
|
const promotingStranded = new Set<string>()
|
|
841
901
|
|
|
@@ -931,6 +991,7 @@ setInterval(() => {
|
|
|
931
991
|
}
|
|
932
992
|
}
|
|
933
993
|
purgeExpiredQuarantine()
|
|
994
|
+
autoRecoverOneQuarantinedCapture()
|
|
934
995
|
} catch {}
|
|
935
996
|
// Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
|
|
936
997
|
// restart can exceed 2h (2026-07-27: two sessions purged before batch).
|