@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 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.29.0",
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,