@gotcos/glasses-server 6.29.0 → 6.31.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 +69 -0
- package/package.json +1 -1
- package/server/lib/occupied-threads.ts +285 -0
- package/server/routes/agent-sessions.ts +183 -2
package/CHANGELOG.md
CHANGED
|
@@ -2219,6 +2219,75 @@ unsaved capture, and makes batch status stop lying about finished work.
|
|
|
2219
2219
|
|
|
2220
2220
|
# Changelog
|
|
2221
2221
|
|
|
2222
|
+
## [6.31.0] - 2026-08-16
|
|
2223
|
+
|
|
2224
|
+
### "Running" meant an open window, not a working agent
|
|
2225
|
+
|
|
2226
|
+
- `GET /api/agent-sessions` adds **`running_active`**: the thread's transcript was
|
|
2227
|
+
written in the last 30 seconds, so an agent is generating in it right now.
|
|
2228
|
+
- This exists because `running` could not answer that question and was being read
|
|
2229
|
+
as though it could. It comes from a process registry, and a Claude Code record
|
|
2230
|
+
describes an open WINDOW (`kind: interactive`, `entrypoint: claude-desktop`) —
|
|
2231
|
+
so a session that finished an hour ago, with the window still up, stayed
|
|
2232
|
+
`running: true` forever. A finished session reported itself active on the
|
|
2233
|
+
glasses and never cleared.
|
|
2234
|
+
- The transcript is the only part of a session with a clock in it. While a
|
|
2235
|
+
session generates, its jsonl mtime tracks the wall clock to within a second;
|
|
2236
|
+
when it stops, the mtime goes stale while the registry record does not. That
|
|
2237
|
+
divergence is the whole signal.
|
|
2238
|
+
- The window is 30 seconds, deliberately much wider than the observed write
|
|
2239
|
+
cadence, so a long tool call between writes does not flap a working session to
|
|
2240
|
+
idle and back.
|
|
2241
|
+
- **Costs one stat per HELD thread, not per listed thread.** Freshness is layered
|
|
2242
|
+
on after the occupancy scan and only over what it found, so a list with nothing
|
|
2243
|
+
running does no extra filesystem work at all.
|
|
2244
|
+
- Anything unmeasurable — no transcript, unreadable file, a timestamp from the
|
|
2245
|
+
future — reads as NOT active. The row then falls back to "open", which is still
|
|
2246
|
+
true of a thread with a live owner. The stronger claim has to be earned by an
|
|
2247
|
+
actual observation.
|
|
2248
|
+
- **Still a display hint and still never a write gate.** `attachability` and
|
|
2249
|
+
`attach` keep probing at the moment of the write, unchanged.
|
|
2250
|
+
|
|
2251
|
+
### The session detail describes its own liveness
|
|
2252
|
+
|
|
2253
|
+
- `GET /api/agent-sessions/:provider/:sessionId` now carries the same three
|
|
2254
|
+
`running_*` fields plus `running_stamped: true` and `runningDegraded`.
|
|
2255
|
+
- Until now only the LIST stamped occupancy, so the detail page had to borrow the
|
|
2256
|
+
flags from whichever row the user tapped. Those flags froze at the moment of the
|
|
2257
|
+
tap, which is the other half of why a finished session never cleared: the page
|
|
2258
|
+
had no way to ask again. A polling client needs a payload that carries its own
|
|
2259
|
+
liveness.
|
|
2260
|
+
- `running_stamped` exists so a client can tell an old server's silence from a new
|
|
2261
|
+
server's `false`. They demand opposite behaviour: keep the borrowed hint, or drop
|
|
2262
|
+
it as stale.
|
|
2263
|
+
- Nearly free: the handler already resolves and stats the transcript, so only the
|
|
2264
|
+
single-thread occupancy scan is new, and that is the same scan `attachability`
|
|
2265
|
+
runs on every menu open.
|
|
2266
|
+
|
|
2267
|
+
**Required by the COS Glasses build that ships the honest activity line.**
|
|
2268
|
+
|
|
2269
|
+
## [6.30.0] - 2026-08-16
|
|
2270
|
+
|
|
2271
|
+
### Sessions know which threads are live
|
|
2272
|
+
|
|
2273
|
+
- `GET /api/agent-sessions` now stamps each row with `running` (an agent is
|
|
2274
|
+
working in this thread right now) and `running_foreign` (it is held by
|
|
2275
|
+
something that is not COS, so a Continue would be refused), plus a
|
|
2276
|
+
`runningDegraded` flag on the payload when a probe could not see clearly.
|
|
2277
|
+
- `running` counts COS's own queued turn too. Two different questions: whether an
|
|
2278
|
+
agent is working (the badge) and whether a write would be refused (the
|
|
2279
|
+
affordance). Counting only foreign owners would hide your own turn from the
|
|
2280
|
+
screen you open to watch it.
|
|
2281
|
+
- **This is a display hint and never a write gate.** Attach and turn keep probing
|
|
2282
|
+
at the moment of the write, unchanged, so a desktop session opened between the
|
|
2283
|
+
list render and the tap is still caught.
|
|
2284
|
+
- Fails the opposite way to the gate: doubt reports `degraded` rather than
|
|
2285
|
+
painting every session busy.
|
|
2286
|
+
- One scan for the whole page, measured at 7ms for 45 real sessions (was 792ms
|
|
2287
|
+
before a scan-scoped memo and skipping lsof when no Codex writer lock exists).
|
|
2288
|
+
|
|
2289
|
+
**Required by COS Glasses 6.8.364.**
|
|
2290
|
+
|
|
2222
2291
|
## [6.29.0] - 2026-08-16
|
|
2223
2292
|
|
|
2224
2293
|
### Continue is queued instead of holding the phone
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.31.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": {
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Which threads currently have a live desktop process, in ONE pass.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS SEPARATELY FROM `threadOccupancy`.
|
|
4
|
+
//
|
|
5
|
+
// `claudeOwners(threadId, ...)` re-reads the whole session registry and shells
|
|
6
|
+
// out to `ps` per entry, for ONE thread. That is correct and cheap when a user is
|
|
7
|
+
// deciding whether to write into a single thread. Calling it once per row to
|
|
8
|
+
// decorate a 53-row session list would mean tens of directory scans and hundreds
|
|
9
|
+
// of process spawns on every list request.
|
|
10
|
+
//
|
|
11
|
+
// So this does the opposite shape: scan once, return the set of occupied ids, and
|
|
12
|
+
// let the caller join in memory.
|
|
13
|
+
//
|
|
14
|
+
// THE LOAD-BEARING RULE, AND THE REASON THIS IS SAFE:
|
|
15
|
+
//
|
|
16
|
+
// WHAT THIS PRODUCES IS A DISPLAY HINT. IT MUST NEVER GATE A WRITE.
|
|
17
|
+
//
|
|
18
|
+
// The attach and turn paths keep calling `threadOccupancy` at the moment of the
|
|
19
|
+
// write, unchanged. That is deliberate: a list is rendered seconds or minutes
|
|
20
|
+
// before the user acts, and a desktop session opened in that gap is exactly the
|
|
21
|
+
// race the per-write probe exists to catch. This hint answers "should the UI show
|
|
22
|
+
// this session as busy", never "is it safe to write".
|
|
23
|
+
//
|
|
24
|
+
// Being a hint is also why its failure mode is the opposite of the gate's. The
|
|
25
|
+
// gate fails CLOSED — doubt means refuse. A hint that failed closed would paint
|
|
26
|
+
// every session as running the moment a probe hiccuped, so doubt here means "I do
|
|
27
|
+
// not know", rendered as not-running, and the real gate still refuses at the
|
|
28
|
+
// write. Getting a hint wrong costs a misleading badge; getting the gate wrong
|
|
29
|
+
// costs someone's conversation.
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
claudeOwners,
|
|
33
|
+
codexLockPath,
|
|
34
|
+
codexOwners,
|
|
35
|
+
type OccupancyDirs,
|
|
36
|
+
type OccupancyProbes,
|
|
37
|
+
type ThreadOwner,
|
|
38
|
+
} from './thread-occupancy.js'
|
|
39
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
40
|
+
|
|
41
|
+
export interface OccupiedThread {
|
|
42
|
+
threadId: string
|
|
43
|
+
/**
|
|
44
|
+
* Every process holding this thread open, COS's own children included.
|
|
45
|
+
*
|
|
46
|
+
* WHAT THIS ACTUALLY MEASURES, stated plainly because the first version of the
|
|
47
|
+
* feature got it wrong in user-facing copy: a registry record means A PROCESS
|
|
48
|
+
* HOLDS THIS THREAD OPEN. It does NOT mean an agent is generating. A Claude
|
|
49
|
+
* Code record reads `kind: interactive, entrypoint: claude-desktop` for an open
|
|
50
|
+
* WINDOW, so a session that finished ten minutes ago with the window still up
|
|
51
|
+
* has an owner forever. The lens said "ACTIVE NOW" off this number and was
|
|
52
|
+
* lying; it now says the thread is OPEN, which is all this count can support.
|
|
53
|
+
*/
|
|
54
|
+
owners: number
|
|
55
|
+
/**
|
|
56
|
+
* Owners that are NOT COS's own children.
|
|
57
|
+
*
|
|
58
|
+
* The separate count matters because it answers a different question: whether a
|
|
59
|
+
* Continue would be refused. Self-owned work does not block a write; a desktop
|
|
60
|
+
* window does.
|
|
61
|
+
*/
|
|
62
|
+
foreignOwners: number
|
|
63
|
+
/**
|
|
64
|
+
* The transcript was WRITTEN inside the freshness window: an agent is
|
|
65
|
+
* generating here, not merely holding the file open.
|
|
66
|
+
*
|
|
67
|
+
* This is the signal `owners` cannot give. Set only from a positive
|
|
68
|
+
* observation of a recent write — see `withActiveRecently`, which is where it
|
|
69
|
+
* is filled in. `occupiedThreads` itself has no path to a transcript and
|
|
70
|
+
* always leaves it false.
|
|
71
|
+
*/
|
|
72
|
+
activeRecently: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OccupiedScan {
|
|
76
|
+
/** threadId -> occupancy, for every thread with at least one owner. */
|
|
77
|
+
occupied: Map<string, OccupiedThread>
|
|
78
|
+
/**
|
|
79
|
+
* True when the scan could not see clearly (unreadable registry, missing
|
|
80
|
+
* detector). The caller should render "unknown", never "everything is running".
|
|
81
|
+
*/
|
|
82
|
+
degraded: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const EMPTY: OccupiedScan = { occupied: new Map(), degraded: true }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Probes that answer each identical question once per scan.
|
|
89
|
+
*
|
|
90
|
+
* MEASURED, not assumed. `claudeOwners` re-reads the whole registry directory and
|
|
91
|
+
* re-runs `ps` for every entry, for ONE thread — so scanning 45 real sessions on
|
|
92
|
+
* this machine cost 771ms of repeated identical I/O. With this wrapper the same
|
|
93
|
+
* scan is one directory read, one read per entry, and one `ps` per pid.
|
|
94
|
+
*
|
|
95
|
+
* Correctness comes from reusing `claudeOwners` / `codexOwners` UNCHANGED, so the
|
|
96
|
+
* self-ownership rule and the PID-reuse guard stay in exactly one place. This only
|
|
97
|
+
* removes duplicate work; it makes no decisions.
|
|
98
|
+
*
|
|
99
|
+
* Scoped to a single scan on purpose. A longer-lived cache would answer "is a
|
|
100
|
+
* desktop process holding this thread" from stale data, and liveness has a
|
|
101
|
+
* lifetime of about now.
|
|
102
|
+
*/
|
|
103
|
+
function memoize(probes: OccupancyProbes): OccupancyProbes {
|
|
104
|
+
const dirs = new Map<string, string[]>()
|
|
105
|
+
const files = new Map<string, string | null>()
|
|
106
|
+
const starts = new Map<number, number | null>()
|
|
107
|
+
const alive = new Map<number, boolean>()
|
|
108
|
+
const exists = new Map<string, boolean>()
|
|
109
|
+
const dirsExist = new Map<string, boolean>()
|
|
110
|
+
let ledger: ReturnType<OccupancyProbes['cosSpawnedPids']> | undefined
|
|
111
|
+
|
|
112
|
+
const once = <K, V>(cache: Map<K, V>, key: K, compute: () => V): V => {
|
|
113
|
+
if (cache.has(key)) return cache.get(key)!
|
|
114
|
+
const value = compute()
|
|
115
|
+
cache.set(key, value)
|
|
116
|
+
return value
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
...probes,
|
|
121
|
+
readDir: (path: string) => once(dirs, path, () => probes.readDir(path)),
|
|
122
|
+
readFile: (path: string) => once(files, path, () => probes.readFile(path)),
|
|
123
|
+
processStartMs: (pid: number) => once(starts, pid, () => probes.processStartMs(pid)),
|
|
124
|
+
isAlive: (pid: number) => once(alive, pid, () => probes.isAlive(pid)),
|
|
125
|
+
fileExists: (path: string) => once(exists, path, () => probes.fileExists(path)),
|
|
126
|
+
dirExists: (path: string) => once(dirsExist, path, () => probes.dirExists(path)),
|
|
127
|
+
cosSpawnedPids: () => (ledger ??= probes.cosSpawnedPids()),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Scan one provider's registry once and return the threads a desktop process
|
|
133
|
+
* holds.
|
|
134
|
+
*
|
|
135
|
+
* Reuses `claudeOwners` / `codexOwners` rather than reimplementing the parsing,
|
|
136
|
+
* so the self-ownership rule (COS's own spawned children must not read as foreign
|
|
137
|
+
* owners) and the PID-reuse guard stay in exactly one place. Reuse alone would
|
|
138
|
+
* repeat the whole registry read per thread — `memoize` is what removes that, so
|
|
139
|
+
* the saving is real I/O rather than a claim in a comment.
|
|
140
|
+
*/
|
|
141
|
+
export function occupiedThreads(
|
|
142
|
+
provider: string,
|
|
143
|
+
threadIds: readonly string[],
|
|
144
|
+
probes: OccupancyProbes,
|
|
145
|
+
dirs: OccupancyDirs,
|
|
146
|
+
): OccupiedScan {
|
|
147
|
+
if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
|
|
148
|
+
|
|
149
|
+
// Deduplicated and validated up front. An invalid id cannot match a record, and
|
|
150
|
+
// it also reaches a filesystem path in `codexLockPath`.
|
|
151
|
+
const ids = [...new Set(threadIds)].filter(isValidNativeThreadId)
|
|
152
|
+
if (ids.length === 0) return { occupied: new Map(), degraded: false }
|
|
153
|
+
|
|
154
|
+
// Every identical read answered once for the whole scan. See `memoize`.
|
|
155
|
+
const probe = memoize(probes)
|
|
156
|
+
const occupied = new Map<string, OccupiedThread>()
|
|
157
|
+
let degraded = false
|
|
158
|
+
|
|
159
|
+
for (const threadId of ids) {
|
|
160
|
+
// MEASURED: `codexOwners` reaches `lsof` even when no writer lock exists, and
|
|
161
|
+
// lsof costs ~45ms a call — 761ms across 17 codex threads on this machine,
|
|
162
|
+
// against zero locks on disk. A missing lock means no writer, which is the
|
|
163
|
+
// same conclusion lsof reaches the expensive way, so skip it.
|
|
164
|
+
//
|
|
165
|
+
// Only an explicit `false` skips. `fileExists` collapses absent with
|
|
166
|
+
// unreadable, and an unreadable lock must still go through the real check
|
|
167
|
+
// rather than being read as free.
|
|
168
|
+
if (provider === 'codex') {
|
|
169
|
+
let lockPresent = true
|
|
170
|
+
try {
|
|
171
|
+
lockPresent = probe.fileExists(codexLockPath(threadId, dirs.codexLocksDir))
|
|
172
|
+
} catch {
|
|
173
|
+
lockPresent = true
|
|
174
|
+
}
|
|
175
|
+
if (!lockPresent) continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let owners: readonly ThreadOwner[]
|
|
179
|
+
let doubt: string | null
|
|
180
|
+
try {
|
|
181
|
+
const result = provider === 'claude'
|
|
182
|
+
? claudeOwners(threadId, probe, dirs.claudeSessionsDir)
|
|
183
|
+
: codexOwners(threadId, probe, dirs.codexLocksDir)
|
|
184
|
+
owners = result.owners
|
|
185
|
+
doubt = result.doubt
|
|
186
|
+
} catch {
|
|
187
|
+
// One thread's probe throwing does not invalidate the others.
|
|
188
|
+
degraded = true
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (doubt !== null) degraded = true
|
|
192
|
+
// ANY owner means an agent is working here. Counting only foreign ones would
|
|
193
|
+
// hide COS's own queued turn from the very screen the user opens to watch it.
|
|
194
|
+
const foreign = owners.filter(o => !o.selfOwned).length
|
|
195
|
+
if (owners.length > 0) {
|
|
196
|
+
// `activeRecently` is FALSE here and can only be raised by
|
|
197
|
+
// `withActiveRecently`. This function reads a process registry; a registry
|
|
198
|
+
// record cannot tell generating from idling, and inferring one from the
|
|
199
|
+
// other is the exact defect this field exists to fix.
|
|
200
|
+
occupied.set(threadId, { threadId, owners: owners.length, foreignOwners: foreign, activeRecently: false })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { occupied, degraded }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The safe answer when the caller cannot scan at all. */
|
|
208
|
+
export function noOccupancyKnown(): OccupiedScan {
|
|
209
|
+
return { occupied: new Map(EMPTY.occupied), degraded: true }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* How recently the transcript must have been written for a held thread to read
|
|
214
|
+
* as WORKING rather than merely OPEN.
|
|
215
|
+
*
|
|
216
|
+
* WHY A SECOND SIGNAL EXISTS AT ALL. `owners` answers "a process holds this
|
|
217
|
+
* thread", which is not the question the user is asking when he looks at the
|
|
218
|
+
* lens. He watched a session finish on his Mac while the glasses still showed it
|
|
219
|
+
* active, because the window was still open and the registry record therefore
|
|
220
|
+
* still existed. Occupancy has no clock in it; the transcript does.
|
|
221
|
+
*
|
|
222
|
+
* MEASURED, not assumed: while a session generates, its jsonl mtime tracks the
|
|
223
|
+
* wall clock to within a second, and when generation stops the mtime goes stale
|
|
224
|
+
* while the registry record stays exactly where it was. That divergence is the
|
|
225
|
+
* whole signal.
|
|
226
|
+
*
|
|
227
|
+
* 30 seconds is deliberately far wider than the observed sub-second write
|
|
228
|
+
* cadence. The gap this has to survive is a long tool call or a slow first token
|
|
229
|
+
* between writes, and a window sized to the cadence would flap a working session
|
|
230
|
+
* to OPEN and back every time the model paused to think. The cost of the wide
|
|
231
|
+
* window is bounded and known: a session that stops is reported working for up
|
|
232
|
+
* to 30 more seconds, which is a late correction rather than a permanent lie.
|
|
233
|
+
*/
|
|
234
|
+
export const ACTIVE_RECENTLY_WINDOW_MS = 30_000
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Was this transcript written inside the window?
|
|
238
|
+
*
|
|
239
|
+
* NULL IS NOT "RECENT". An unresolvable path, an unreadable file, a stat that
|
|
240
|
+
* threw — every one of them arrives here as null and answers false, so the row
|
|
241
|
+
* falls back to OPEN. That is not a fail-open: OPEN is still a true statement
|
|
242
|
+
* about a thread with a live owner. It is the STRONGER claim, "an agent is
|
|
243
|
+
* working in here", that has to be earned by an actual observation.
|
|
244
|
+
*
|
|
245
|
+
* A timestamp far in the FUTURE is refused for the same reason rather than
|
|
246
|
+
* treated as maximally fresh. Skew of a few seconds is normal and lands inside
|
|
247
|
+
* the window; a file dated next week is a clock this server cannot reason about,
|
|
248
|
+
* and letting it manufacture a permanent "working" badge would rebuild the bug
|
|
249
|
+
* from the other direction.
|
|
250
|
+
*/
|
|
251
|
+
export function isActiveRecently(mtimeMs: number | null | undefined, nowMs: number): boolean {
|
|
252
|
+
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return false
|
|
253
|
+
if (!Number.isFinite(nowMs)) return false
|
|
254
|
+
return Math.abs(nowMs - mtimeMs) <= ACTIVE_RECENTLY_WINDOW_MS
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Fill in `activeRecently` for a scan, from transcript write times.
|
|
259
|
+
*
|
|
260
|
+
* PURE, and separate from `occupiedThreads` on purpose. Resolving a thread id to
|
|
261
|
+
* a transcript path is the session STORE's job (it owns the projects/rollouts
|
|
262
|
+
* layout), and this module owns process occupancy; wiring the store in here
|
|
263
|
+
* would make an occupancy scan depend on a filesystem it does not otherwise know
|
|
264
|
+
* about. The caller stats — for HELD THREADS ONLY, which is what keeps this
|
|
265
|
+
* cheap, since it is one stat per already-occupied row and never one per listed
|
|
266
|
+
* row — and hands the readings in.
|
|
267
|
+
*
|
|
268
|
+
* A thread with no entry in the map is not an error and not "recent": it reads
|
|
269
|
+
* exactly like an unreadable one, because from here the two are the same
|
|
270
|
+
* absence of evidence.
|
|
271
|
+
*/
|
|
272
|
+
export function withActiveRecently(
|
|
273
|
+
scan: OccupiedScan,
|
|
274
|
+
transcriptMtimes: ReadonlyMap<string, number | null>,
|
|
275
|
+
nowMs: number,
|
|
276
|
+
): OccupiedScan {
|
|
277
|
+
const occupied = new Map<string, OccupiedThread>()
|
|
278
|
+
for (const [threadId, record] of scan.occupied) {
|
|
279
|
+
occupied.set(threadId, {
|
|
280
|
+
...record,
|
|
281
|
+
activeRecently: isActiveRecently(transcriptMtimes.get(threadId), nowMs),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
return { occupied, degraded: scan.degraded }
|
|
285
|
+
}
|
|
@@ -32,6 +32,15 @@ import {
|
|
|
32
32
|
import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
|
|
33
33
|
import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
|
|
34
34
|
import { workspaceFromCwd } from '../lib/claude-session-registry.js'
|
|
35
|
+
import {
|
|
36
|
+
occupiedThreads,
|
|
37
|
+
noOccupancyKnown,
|
|
38
|
+
withActiveRecently,
|
|
39
|
+
type OccupiedScan,
|
|
40
|
+
type OccupiedThread,
|
|
41
|
+
} from '../lib/occupied-threads.js'
|
|
42
|
+
import { realOccupancyDirs, realOccupancyProbes } from '../lib/occupancy-probes.js'
|
|
43
|
+
import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
35
44
|
|
|
36
45
|
export const agentSessionsRouter = Router()
|
|
37
46
|
|
|
@@ -61,6 +70,159 @@ function toSearchHit(row: AgentSessionSearchHit) {
|
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Which of these sessions a desktop process is holding right now.
|
|
75
|
+
*
|
|
76
|
+
* ONE scan for the whole page. Calling `threadOccupancy` per row would re-read the
|
|
77
|
+
* registry and shell out to `ps` per entry, which at 53 sessions is hundreds of
|
|
78
|
+
* process spawns on a single list request.
|
|
79
|
+
*
|
|
80
|
+
* THIS IS A DISPLAY HINT AND MUST NEVER GATE A WRITE. The attach and turn routes
|
|
81
|
+
* keep probing at the moment of the write, unchanged, because a list is rendered
|
|
82
|
+
* seconds or minutes before the user acts and a desktop session opened in that gap
|
|
83
|
+
* is exactly the race the per-write probe exists to catch.
|
|
84
|
+
*/
|
|
85
|
+
function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
|
|
86
|
+
try {
|
|
87
|
+
const dirs = realOccupancyDirs()
|
|
88
|
+
// The spawn ledger is what lets a turn COS itself queued read as ours rather
|
|
89
|
+
// than as a foreign desktop window holding the thread.
|
|
90
|
+
const probes = realOccupancyProbes(cosSpawnedPids)
|
|
91
|
+
const byProvider = new Map<string, string[]>()
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
if (row.provider !== 'claude' && row.provider !== 'codex') continue
|
|
94
|
+
const list = byProvider.get(row.provider) ?? []
|
|
95
|
+
list.push(row.session_id)
|
|
96
|
+
byProvider.set(row.provider, list)
|
|
97
|
+
}
|
|
98
|
+
const merged = new Map<string, OccupiedThread>()
|
|
99
|
+
let degraded = false
|
|
100
|
+
for (const [provider, ids] of byProvider) {
|
|
101
|
+
const scan = occupiedThreads(provider, ids, probes, dirs)
|
|
102
|
+
for (const [id, occ] of scan.occupied) merged.set(id, occ)
|
|
103
|
+
if (scan.degraded) degraded = true
|
|
104
|
+
}
|
|
105
|
+
return { occupied: merged, degraded }
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// The list is the point; occupancy is decoration. A probe failure must never
|
|
108
|
+
// cost the user their sessions.
|
|
109
|
+
console.error(`[agent-sessions] occupancy scan failed: ${error instanceof Error ? error.message : error}`)
|
|
110
|
+
return noOccupancyKnown()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* When each HELD thread's transcript was last written.
|
|
116
|
+
*
|
|
117
|
+
* ONLY the threads the scan already found occupied. That is the entire cost
|
|
118
|
+
* argument: the occupied set is typically one or two rows out of sixty, so this
|
|
119
|
+
* is one or two path resolutions and stats per request, not sixty. An empty scan
|
|
120
|
+
* does no filesystem work at all.
|
|
121
|
+
*
|
|
122
|
+
* WHY NOT REUSE `row.modified`, WHICH IS ALREADY AN MTIME. Because for the rows
|
|
123
|
+
* that matter it is not one. A live Claude row comes from `liveClaudeRows`,
|
|
124
|
+
* whose `modified` is the registry's `lastActiveAt` (or, absent that, literally
|
|
125
|
+
* `new Date()`), and `enrichLiveClaude` does not replace it with the file's
|
|
126
|
+
* mtime. Reading freshness off that field would report an open window as a fresh
|
|
127
|
+
* write on every single request, which is precisely the bug being fixed.
|
|
128
|
+
*
|
|
129
|
+
* Every failure lands on null, and null is doubt rather than "recent" — see
|
|
130
|
+
* `isActiveRecently`. A row that cannot be measured reads OPEN, which is still
|
|
131
|
+
* true of a thread with a live owner.
|
|
132
|
+
*/
|
|
133
|
+
async function transcriptMtimes(
|
|
134
|
+
scan: OccupiedScan,
|
|
135
|
+
rows: readonly AgentSessionRow[],
|
|
136
|
+
): Promise<Map<string, number | null>> {
|
|
137
|
+
const mtimes = new Map<string, number | null>()
|
|
138
|
+
if (scan.occupied.size === 0) return mtimes
|
|
139
|
+
|
|
140
|
+
const providerById = new Map<string, AgentProvider>()
|
|
141
|
+
for (const row of rows) providerById.set(row.session_id, row.provider)
|
|
142
|
+
const roots = agentSessionRoots()
|
|
143
|
+
|
|
144
|
+
await Promise.all([...scan.occupied.keys()].map(async threadId => {
|
|
145
|
+
try {
|
|
146
|
+
const provider = providerById.get(threadId)
|
|
147
|
+
// An occupied id with no row cannot happen today (the scan is built FROM
|
|
148
|
+
// the rows), but guessing a provider to go looking for a file is how a
|
|
149
|
+
// caller-supplied id reaches the filesystem. Unknown stays unmeasured.
|
|
150
|
+
if (!provider) {
|
|
151
|
+
mtimes.set(threadId, null)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const file = await findAgentSessionFile(provider, threadId, roots)
|
|
155
|
+
mtimes.set(threadId, file ? (await stat(file)).mtimeMs : null)
|
|
156
|
+
} catch {
|
|
157
|
+
mtimes.set(threadId, null)
|
|
158
|
+
}
|
|
159
|
+
}))
|
|
160
|
+
return mtimes
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Stamp the running hint onto a projected row.
|
|
165
|
+
*
|
|
166
|
+
* EXPORTED so the WIRE KEYS are covered by a behaviour test rather than by
|
|
167
|
+
* reading this file. The client reads `running_active` by exact name
|
|
168
|
+
* (`session-running.ts` accepts only a literal `true` on a known key), so a
|
|
169
|
+
* rename here is silent on this side and renders every session as merely open on
|
|
170
|
+
* the lens. Pure: it takes the scan, it does no I/O.
|
|
171
|
+
*/
|
|
172
|
+
export function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
|
|
173
|
+
const occ = scan.occupied.get(entry.session_id)
|
|
174
|
+
return {
|
|
175
|
+
...entry,
|
|
176
|
+
// A process HOLDS this thread open, whoever started it. Not "is generating":
|
|
177
|
+
// a Claude record for an open window outlives the work by however long the
|
|
178
|
+
// window stays up, which is why `running_active` exists below.
|
|
179
|
+
running: occ !== undefined,
|
|
180
|
+
// Held by something that is not COS, so a Continue would be refused. The
|
|
181
|
+
// badge reads `running`; the Continue affordance reads this.
|
|
182
|
+
running_foreign: (occ?.foreignOwners ?? 0) > 0,
|
|
183
|
+
// The transcript was WRITTEN in the last few seconds: an agent is generating
|
|
184
|
+
// here, not just holding the file. This is the only one of the three that
|
|
185
|
+
// clears on its own when the work stops. Still a display hint, never a gate.
|
|
186
|
+
running_active: occ?.activeRecently === true,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Occupancy plus freshness for ONE thread, for the detail route.
|
|
192
|
+
*
|
|
193
|
+
* WHY THE DETAIL ROUTE STAMPS THIS AT ALL. It did not, and the lens had to borrow
|
|
194
|
+
* the hint from whichever list row the user tapped. That worked exactly once: the
|
|
195
|
+
* detail page was a single fetch, so the borrowed flags were frozen at the moment
|
|
196
|
+
* it opened and could never clear, which is half of why a finished session stayed
|
|
197
|
+
* "active" on the glasses until Miles navigated away. A page that polls needs a
|
|
198
|
+
* payload that carries its own liveness.
|
|
199
|
+
*
|
|
200
|
+
* NEARLY FREE HERE. The handler has already resolved the transcript path and
|
|
201
|
+
* stat'ed it, so freshness costs nothing extra and only the single-thread
|
|
202
|
+
* occupancy scan is new — the same scan `attachability` already runs on every
|
|
203
|
+
* menu open. Contrast the list, where the same work would be sixty scans.
|
|
204
|
+
*
|
|
205
|
+
* STILL A DISPLAY HINT. Nothing about being on the detail payload makes it a
|
|
206
|
+
* gate; `attach` re-probes at the write, unchanged.
|
|
207
|
+
*/
|
|
208
|
+
function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: number): OccupiedScan {
|
|
209
|
+
if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
|
|
210
|
+
try {
|
|
211
|
+
const scan = occupiedThreads(
|
|
212
|
+
provider,
|
|
213
|
+
[threadId],
|
|
214
|
+
realOccupancyProbes(cosSpawnedPids),
|
|
215
|
+
realOccupancyDirs(),
|
|
216
|
+
)
|
|
217
|
+
return withActiveRecently(scan, new Map([[threadId, mtimeMs]]), Date.now())
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// The transcript is the point; occupancy is decoration. A probe failure must
|
|
220
|
+
// never cost the user their session.
|
|
221
|
+
console.error(`[agent-sessions] detail occupancy failed: ${error instanceof Error ? error.message : error}`)
|
|
222
|
+
return noOccupancyKnown()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
64
226
|
function toEntry(row: AgentSessionRow) {
|
|
65
227
|
return {
|
|
66
228
|
session_id: row.session_id,
|
|
@@ -107,12 +269,20 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
107
269
|
const sort = asSort(req.query.sort)
|
|
108
270
|
const live = await liveClaudeRows()
|
|
109
271
|
const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
|
|
272
|
+
const scan = runningThreads(sessions)
|
|
273
|
+
// Freshness is layered on AFTER occupancy, and only over what occupancy
|
|
274
|
+
// found. `Date.now()` is read once so every row in a payload is judged
|
|
275
|
+
// against the same instant.
|
|
276
|
+
const running = withActiveRecently(scan, await transcriptMtimes(scan, sessions), Date.now())
|
|
110
277
|
res.json({
|
|
111
|
-
sessions: sessions.map(toEntry),
|
|
278
|
+
sessions: sessions.map(row => withRunning(toEntry(row), running)),
|
|
112
279
|
total: sessions.length,
|
|
113
280
|
windowHours: AGENT_SESSION_WINDOW_HOURS,
|
|
114
281
|
sort,
|
|
115
282
|
enabled: true,
|
|
283
|
+
// True when a probe could not see clearly. The client must render "unknown"
|
|
284
|
+
// rather than treating a quiet scan as "nothing is running".
|
|
285
|
+
runningDegraded: running.degraded,
|
|
116
286
|
})
|
|
117
287
|
} catch (error) {
|
|
118
288
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|
|
@@ -173,8 +343,19 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
173
343
|
if (named) parsed.display_label = named
|
|
174
344
|
}
|
|
175
345
|
const modified = st.mtime.toISOString()
|
|
346
|
+
const running = runningForThread(provider, parsed.session_id, st.mtimeMs)
|
|
176
347
|
res.json({
|
|
177
|
-
session_id: parsed.session_id,
|
|
348
|
+
...withRunning({ session_id: parsed.session_id }, running),
|
|
349
|
+
// The client must be able to tell "this server stamped nothing" from "this
|
|
350
|
+
// server stamped false", because the two demand opposite behaviour: an old
|
|
351
|
+
// server's silence means keep using the hint borrowed from the list row, and
|
|
352
|
+
// a new server's `false` means the thread genuinely went quiet and the
|
|
353
|
+
// borrowed hint is the stale thing. Without this marker the poll could never
|
|
354
|
+
// clear the flag it exists to clear.
|
|
355
|
+
running_stamped: true,
|
|
356
|
+
// Same meaning as on the list: a probe could not see clearly, so render
|
|
357
|
+
// unknown rather than treating a quiet scan as "nothing is running".
|
|
358
|
+
runningDegraded: running.degraded,
|
|
178
359
|
provider: parsed.provider,
|
|
179
360
|
slug: parsed.session_id,
|
|
180
361
|
custom_title: parsed.display_label,
|