@gotcos/glasses-server 6.30.0 → 6.32.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 +82 -0
- package/package.json +1 -1
- package/server/index.ts +25 -2
- package/server/lib/occupancy-probes.ts +57 -0
- package/server/lib/occupied-threads.ts +69 -4
- package/server/lib/thread-occupancy.ts +229 -5
- package/server/routes/agent-session-bindings.ts +23 -2
- package/server/routes/agent-sessions.ts +126 -6
package/CHANGELOG.md
CHANGED
|
@@ -2219,6 +2219,88 @@ unsaved capture, and makes batch status stop lying about finished work.
|
|
|
2219
2219
|
|
|
2220
2220
|
# Changelog
|
|
2221
2221
|
|
|
2222
|
+
## [6.32.0] - 2026-08-16
|
|
2223
|
+
|
|
2224
|
+
### Continue now blocks on WORKING, not on merely open
|
|
2225
|
+
|
|
2226
|
+
Continuing a Claude Code thread from COS used to be refused whenever any foreign
|
|
2227
|
+
process held it. That came from `~/.claude/sessions/<pid>.json`, which records an
|
|
2228
|
+
OPEN WINDOW, not an agent generating: a session that finished ten minutes ago
|
|
2229
|
+
with the window still up looked identical to one mid-turn. Anyone who leaves
|
|
2230
|
+
Claude Code windows open was therefore refused on exactly the threads they work
|
|
2231
|
+
in, and allowed only on the ones they had abandoned.
|
|
2232
|
+
|
|
2233
|
+
- A foreign holder is now terminal only while it is **demonstrably writing**. A
|
|
2234
|
+
holder measured idle (registry record alive, transcript stale past the same
|
|
2235
|
+
30s window `running_active` uses) is continuable.
|
|
2236
|
+
- New refusal reason **`native_thread_working`**, distinct from
|
|
2237
|
+
`live_desktop_process`, because the two ask for different things. Working
|
|
2238
|
+
clears itself in seconds and the copy says to wait; `live_desktop_process` now
|
|
2239
|
+
means COS could not measure the thread at all and says so rather than implying
|
|
2240
|
+
the thread is busy.
|
|
2241
|
+
- Off by default behind **`COS_THREAD_ATTACH_IDLE_HOLDER=1`**. Unset, no
|
|
2242
|
+
transcript clock is wired, every foreign holder reads unknown, and the gate
|
|
2243
|
+
behaves exactly as it did in 6.31.0. Composes with `COS_THREAD_ATTACH_ENABLED`.
|
|
2244
|
+
|
|
2245
|
+
Verified against a real interactive `claude` 2.1.229 held open and idle before
|
|
2246
|
+
the gate was touched. Writing into it delivers normally and never corrupts:
|
|
2247
|
+
across six injections and six desktop turns, including three writers landing
|
|
2248
|
+
within 45ms of each other, every transcript prefix stayed byte-identical, with
|
|
2249
|
+
no unparseable lines and no dangling parent references. What it DOES do is fork
|
|
2250
|
+
the conversation: the holder's in-memory view is stale, so its next turn branches
|
|
2251
|
+
off the pre-injection tail and the two writers stop seeing each other. Nothing is
|
|
2252
|
+
lost, and the divergence is caught one layer up by the head watermark, which
|
|
2253
|
+
changes on a desktop write and refuses the next COS turn with
|
|
2254
|
+
`native_thread_changed`. The full canary, including what it does NOT license, is
|
|
2255
|
+
recorded in `server/lib/thread-occupancy.ts` under THE IDLE-HOLDER RELAXATION.
|
|
2256
|
+
|
|
2257
|
+
## [6.31.0] - 2026-08-16
|
|
2258
|
+
|
|
2259
|
+
### "Running" meant an open window, not a working agent
|
|
2260
|
+
|
|
2261
|
+
- `GET /api/agent-sessions` adds **`running_active`**: the thread's transcript was
|
|
2262
|
+
written in the last 30 seconds, so an agent is generating in it right now.
|
|
2263
|
+
- This exists because `running` could not answer that question and was being read
|
|
2264
|
+
as though it could. It comes from a process registry, and a Claude Code record
|
|
2265
|
+
describes an open WINDOW (`kind: interactive`, `entrypoint: claude-desktop`) —
|
|
2266
|
+
so a session that finished an hour ago, with the window still up, stayed
|
|
2267
|
+
`running: true` forever. A finished session reported itself active on the
|
|
2268
|
+
glasses and never cleared.
|
|
2269
|
+
- The transcript is the only part of a session with a clock in it. While a
|
|
2270
|
+
session generates, its jsonl mtime tracks the wall clock to within a second;
|
|
2271
|
+
when it stops, the mtime goes stale while the registry record does not. That
|
|
2272
|
+
divergence is the whole signal.
|
|
2273
|
+
- The window is 30 seconds, deliberately much wider than the observed write
|
|
2274
|
+
cadence, so a long tool call between writes does not flap a working session to
|
|
2275
|
+
idle and back.
|
|
2276
|
+
- **Costs one stat per HELD thread, not per listed thread.** Freshness is layered
|
|
2277
|
+
on after the occupancy scan and only over what it found, so a list with nothing
|
|
2278
|
+
running does no extra filesystem work at all.
|
|
2279
|
+
- Anything unmeasurable — no transcript, unreadable file, a timestamp from the
|
|
2280
|
+
future — reads as NOT active. The row then falls back to "open", which is still
|
|
2281
|
+
true of a thread with a live owner. The stronger claim has to be earned by an
|
|
2282
|
+
actual observation.
|
|
2283
|
+
- **Still a display hint and still never a write gate.** `attachability` and
|
|
2284
|
+
`attach` keep probing at the moment of the write, unchanged.
|
|
2285
|
+
|
|
2286
|
+
### The session detail describes its own liveness
|
|
2287
|
+
|
|
2288
|
+
- `GET /api/agent-sessions/:provider/:sessionId` now carries the same three
|
|
2289
|
+
`running_*` fields plus `running_stamped: true` and `runningDegraded`.
|
|
2290
|
+
- Until now only the LIST stamped occupancy, so the detail page had to borrow the
|
|
2291
|
+
flags from whichever row the user tapped. Those flags froze at the moment of the
|
|
2292
|
+
tap, which is the other half of why a finished session never cleared: the page
|
|
2293
|
+
had no way to ask again. A polling client needs a payload that carries its own
|
|
2294
|
+
liveness.
|
|
2295
|
+
- `running_stamped` exists so a client can tell an old server's silence from a new
|
|
2296
|
+
server's `false`. They demand opposite behaviour: keep the borrowed hint, or drop
|
|
2297
|
+
it as stale.
|
|
2298
|
+
- Nearly free: the handler already resolves and stats the transcript, so only the
|
|
2299
|
+
single-thread occupancy scan is new, and that is the same scan `attachability`
|
|
2300
|
+
runs on every menu open.
|
|
2301
|
+
|
|
2302
|
+
**Required by the COS Glasses build that ships the honest activity line.**
|
|
2303
|
+
|
|
2222
2304
|
## [6.30.0] - 2026-08-16
|
|
2223
2305
|
|
|
2224
2306
|
### Sessions know which threads are live
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.32.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
|
@@ -23,7 +23,12 @@ import { claudeSessionsRouter } from './routes/claude-sessions.js'
|
|
|
23
23
|
import { createAgentSessionBindingsRouter } from './routes/agent-session-bindings.js'
|
|
24
24
|
import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
|
|
25
25
|
import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
idleHolderContinueEnabled,
|
|
28
|
+
realOccupancyDirs,
|
|
29
|
+
realOccupancyProbes,
|
|
30
|
+
withTranscriptClock,
|
|
31
|
+
} from './lib/occupancy-probes.js'
|
|
27
32
|
import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
|
|
28
33
|
import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
|
|
29
34
|
import { forkThread, realForkDeps } from './lib/fork-thread.js'
|
|
@@ -301,10 +306,28 @@ bindingReapTimer.unref()
|
|
|
301
306
|
|
|
302
307
|
// Built once. Each of these reads the disk, so sharing them keeps an attach from
|
|
303
308
|
// re-deriving roots per request.
|
|
304
|
-
const occupancyProbes = realOccupancyProbes(cosSpawnedPids)
|
|
305
309
|
const occupancyDirs = realOccupancyDirs()
|
|
306
310
|
const nativeHeadDeps = realNativeHeadDeps()
|
|
307
311
|
const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
|
|
312
|
+
/**
|
|
313
|
+
* THE ONE PLACE the idle-holder relaxation is switched on (6.32.0).
|
|
314
|
+
*
|
|
315
|
+
* Without the clock, a foreign holder is terminal and Continue is refused for
|
|
316
|
+
* any thread with a Claude Code window open on it — including the ones Miles
|
|
317
|
+
* actually works in, because he leaves those windows open. With it, a holder
|
|
318
|
+
* measured idle is continuable and only a holder measured WRITING is refused.
|
|
319
|
+
*
|
|
320
|
+
* Both gates read this same object — the route's detector via `probes` below,
|
|
321
|
+
* and the adapter's pre-spawn preflight via `occupancyProbes` at the closure
|
|
322
|
+
* further down — so they cannot come to different conclusions. That is the whole
|
|
323
|
+
* reason the clock is a probe rather than a check bolted onto each call site.
|
|
324
|
+
*
|
|
325
|
+
* Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
|
|
326
|
+
* RELAXATION before changing this line.
|
|
327
|
+
*/
|
|
328
|
+
const occupancyProbes = idleHolderContinueEnabled()
|
|
329
|
+
? withTranscriptClock(realOccupancyProbes(cosSpawnedPids), nativeHeadDeps)
|
|
330
|
+
: realOccupancyProbes(cosSpawnedPids)
|
|
308
331
|
|
|
309
332
|
/**
|
|
310
333
|
* The shim between the route's request shape and the adapter's.
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
import { homedir } from 'node:os'
|
|
50
50
|
import { basename, join, resolve } from 'node:path'
|
|
51
51
|
import { NATIVE_THREAD_ID_RE } from './native-thread-id.js'
|
|
52
|
+
import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
|
|
52
53
|
import { parseProcStartUtcMs, type OccupancyDirs, type OccupancyProbes } from './thread-occupancy.js'
|
|
53
54
|
|
|
54
55
|
// Re-exported rather than reimplemented. `claudeSessionsDir` already encodes the
|
|
@@ -440,5 +441,61 @@ export function realOccupancyProbes(ledger: SpawnLedgerAccessor): OccupancyProbe
|
|
|
440
441
|
readFile,
|
|
441
442
|
lockHolders,
|
|
442
443
|
cosSpawnedPids: () => sanitizeLedger(ledger()),
|
|
444
|
+
// transcriptMtimeMs is DELIBERATELY absent here. See `withTranscriptClock`.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Is the idle-holder relaxation switched on for this install?
|
|
450
|
+
*
|
|
451
|
+
* ONE READER, exported so every other module imports it rather than re-reading
|
|
452
|
+
* `process.env` — the same anti-drift rule `threadAttachEnabled` follows, and for
|
|
453
|
+
* the same reason: a second copy is how a surface comes to advertise a write path
|
|
454
|
+
* the gate will refuse.
|
|
455
|
+
*
|
|
456
|
+
* `=== '1'` opt-IN, not `!== '0'` opt-out. This decides whether COS may write
|
|
457
|
+
* into a conversation a human still has open, so anything ambiguous — unset,
|
|
458
|
+
* empty, 'true', 'yes' — reads as OFF. It composes with
|
|
459
|
+
* `COS_THREAD_ATTACH_ENABLED`: with attach itself off, this flag does nothing at
|
|
460
|
+
* all, because there is no write path to relax.
|
|
461
|
+
*/
|
|
462
|
+
export function idleHolderContinueEnabled(): boolean {
|
|
463
|
+
return process.env.COS_THREAD_ATTACH_IDLE_HOLDER === '1'
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Add a transcript clock to a probe set, so an idle foreign holder can be told
|
|
468
|
+
* apart from a working one.
|
|
469
|
+
*
|
|
470
|
+
* WHY THIS IS A SEPARATE WRAPPER rather than a member of `realOccupancyProbes`:
|
|
471
|
+
* the strict gate has to be what you get by DEFAULT. A probe set built the
|
|
472
|
+
* ordinary way has no clock, every foreign holder reads `unknown`, and
|
|
473
|
+
* `threadOccupancy` refuses exactly as it did before 6.32.0. Turning the
|
|
474
|
+
* relaxation on is then one visible call at one wiring site, and turning it off
|
|
475
|
+
* is deleting that call — no rollback, no second code path to keep in step.
|
|
476
|
+
*
|
|
477
|
+
* Null on every failure: an unresolvable id, a missing file, a stat that threw.
|
|
478
|
+
* `holderActivity` reads null as `unknown`, which refuses. There is deliberately
|
|
479
|
+
* no path from "could not read the transcript" to "the holder is idle".
|
|
480
|
+
*
|
|
481
|
+
* `statSync` and not `stat`: `OccupancyProbes` is synchronous throughout, and the
|
|
482
|
+
* adapter's pre-spawn preflight REFUSES a thenable rather than awaiting one —
|
|
483
|
+
* awaiting there would reopen the very race the check exists to close.
|
|
484
|
+
*/
|
|
485
|
+
export function withTranscriptClock(
|
|
486
|
+
probes: OccupancyProbes,
|
|
487
|
+
headDeps: NativeHeadDeps,
|
|
488
|
+
): OccupancyProbes {
|
|
489
|
+
return {
|
|
490
|
+
...probes,
|
|
491
|
+
transcriptMtimeMs: (provider, threadId) => {
|
|
492
|
+
try {
|
|
493
|
+
const path = transcriptPathFor(provider, threadId, headDeps)
|
|
494
|
+
if (path === null) return null
|
|
495
|
+
return statSync(path).mtimeMs
|
|
496
|
+
} catch {
|
|
497
|
+
return null
|
|
498
|
+
}
|
|
499
|
+
},
|
|
443
500
|
}
|
|
444
501
|
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
claudeOwners,
|
|
33
33
|
codexLockPath,
|
|
34
34
|
codexOwners,
|
|
35
|
+
isActiveRecently,
|
|
35
36
|
type OccupancyDirs,
|
|
36
37
|
type OccupancyProbes,
|
|
37
38
|
type ThreadOwner,
|
|
@@ -41,10 +42,15 @@ import { isValidNativeThreadId } from './native-thread-id.js'
|
|
|
41
42
|
export interface OccupiedThread {
|
|
42
43
|
threadId: string
|
|
43
44
|
/**
|
|
44
|
-
* Every process
|
|
45
|
+
* Every process holding this thread open, COS's own children included.
|
|
45
46
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
47
|
+
* WHAT THIS ACTUALLY MEASURES, stated plainly because the first version of the
|
|
48
|
+
* feature got it wrong in user-facing copy: a registry record means A PROCESS
|
|
49
|
+
* HOLDS THIS THREAD OPEN. It does NOT mean an agent is generating. A Claude
|
|
50
|
+
* Code record reads `kind: interactive, entrypoint: claude-desktop` for an open
|
|
51
|
+
* WINDOW, so a session that finished ten minutes ago with the window still up
|
|
52
|
+
* has an owner forever. The lens said "ACTIVE NOW" off this number and was
|
|
53
|
+
* lying; it now says the thread is OPEN, which is all this count can support.
|
|
48
54
|
*/
|
|
49
55
|
owners: number
|
|
50
56
|
/**
|
|
@@ -55,6 +61,16 @@ export interface OccupiedThread {
|
|
|
55
61
|
* window does.
|
|
56
62
|
*/
|
|
57
63
|
foreignOwners: number
|
|
64
|
+
/**
|
|
65
|
+
* The transcript was WRITTEN inside the freshness window: an agent is
|
|
66
|
+
* generating here, not merely holding the file open.
|
|
67
|
+
*
|
|
68
|
+
* This is the signal `owners` cannot give. Set only from a positive
|
|
69
|
+
* observation of a recent write — see `withActiveRecently`, which is where it
|
|
70
|
+
* is filled in. `occupiedThreads` itself has no path to a transcript and
|
|
71
|
+
* always leaves it false.
|
|
72
|
+
*/
|
|
73
|
+
activeRecently: boolean
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
export interface OccupiedScan {
|
|
@@ -178,7 +194,11 @@ export function occupiedThreads(
|
|
|
178
194
|
// hide COS's own queued turn from the very screen the user opens to watch it.
|
|
179
195
|
const foreign = owners.filter(o => !o.selfOwned).length
|
|
180
196
|
if (owners.length > 0) {
|
|
181
|
-
|
|
197
|
+
// `activeRecently` is FALSE here and can only be raised by
|
|
198
|
+
// `withActiveRecently`. This function reads a process registry; a registry
|
|
199
|
+
// record cannot tell generating from idling, and inferring one from the
|
|
200
|
+
// other is the exact defect this field exists to fix.
|
|
201
|
+
occupied.set(threadId, { threadId, owners: owners.length, foreignOwners: foreign, activeRecently: false })
|
|
182
202
|
}
|
|
183
203
|
}
|
|
184
204
|
|
|
@@ -189,3 +209,48 @@ export function occupiedThreads(
|
|
|
189
209
|
export function noOccupancyKnown(): OccupiedScan {
|
|
190
210
|
return { occupied: new Map(EMPTY.occupied), degraded: true }
|
|
191
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The freshness window and the raw reading now live in `thread-occupancy.ts`.
|
|
215
|
+
*
|
|
216
|
+
* MOVED, not duplicated (6.32.0). The write gate needs the same window this
|
|
217
|
+
* hint uses, and it is the lower module, so keeping a second copy here would be
|
|
218
|
+
* two definitions of "recently" that could drift. Re-exported so every existing
|
|
219
|
+
* importer of these two names is unaffected.
|
|
220
|
+
*
|
|
221
|
+
* Note the polarity difference before reusing `isActiveRecently` anywhere new:
|
|
222
|
+
* for a HINT, its `false` safely means "render OPEN". For a GATE, that same
|
|
223
|
+
* `false` would mean "allow the write", and unmeasurable must not mean allowed.
|
|
224
|
+
* `holderActivity` in `thread-occupancy.ts` is the gate-side reading.
|
|
225
|
+
*/
|
|
226
|
+
export { ACTIVE_RECENTLY_WINDOW_MS, isActiveRecently } from './thread-occupancy.js'
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Fill in `activeRecently` for a scan, from transcript write times.
|
|
230
|
+
*
|
|
231
|
+
* PURE, and separate from `occupiedThreads` on purpose. Resolving a thread id to
|
|
232
|
+
* a transcript path is the session STORE's job (it owns the projects/rollouts
|
|
233
|
+
* layout), and this module owns process occupancy; wiring the store in here
|
|
234
|
+
* would make an occupancy scan depend on a filesystem it does not otherwise know
|
|
235
|
+
* about. The caller stats — for HELD THREADS ONLY, which is what keeps this
|
|
236
|
+
* cheap, since it is one stat per already-occupied row and never one per listed
|
|
237
|
+
* row — and hands the readings in.
|
|
238
|
+
*
|
|
239
|
+
* A thread with no entry in the map is not an error and not "recent": it reads
|
|
240
|
+
* exactly like an unreadable one, because from here the two are the same
|
|
241
|
+
* absence of evidence.
|
|
242
|
+
*/
|
|
243
|
+
export function withActiveRecently(
|
|
244
|
+
scan: OccupiedScan,
|
|
245
|
+
transcriptMtimes: ReadonlyMap<string, number | null>,
|
|
246
|
+
nowMs: number,
|
|
247
|
+
): OccupiedScan {
|
|
248
|
+
const occupied = new Map<string, OccupiedThread>()
|
|
249
|
+
for (const [threadId, record] of scan.occupied) {
|
|
250
|
+
occupied.set(threadId, {
|
|
251
|
+
...record,
|
|
252
|
+
activeRecently: isActiveRecently(transcriptMtimes.get(threadId), nowMs),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
return { occupied, degraded: scan.degraded }
|
|
256
|
+
}
|
|
@@ -56,6 +56,12 @@ export interface ThreadOwner {
|
|
|
56
56
|
|
|
57
57
|
export type OccupancyReason =
|
|
58
58
|
| 'live_desktop_process'
|
|
59
|
+
// A foreign holder that is DEMONSTRABLY generating right now, as opposed to
|
|
60
|
+
// merely holding the thread open. Separate from `live_desktop_process` because
|
|
61
|
+
// the two ask the user for different things: this one clears by itself in
|
|
62
|
+
// seconds and is worth waiting out, the other needs a window closed. Only
|
|
63
|
+
// reachable when a transcript clock is wired — see THE IDLE-HOLDER RELAXATION below.
|
|
64
|
+
| 'native_thread_working'
|
|
59
65
|
| 'unsupported_provider'
|
|
60
66
|
| 'invalid_thread_id'
|
|
61
67
|
| 'detector_unavailable'
|
|
@@ -73,13 +79,30 @@ export interface Occupancy {
|
|
|
73
79
|
owners: ThreadOwner[]
|
|
74
80
|
/** Null only when attachable. Drives the Control/lens footer copy. */
|
|
75
81
|
reason: OccupancyReason | null
|
|
82
|
+
/**
|
|
83
|
+
* This verdict is attachable DESPITE a foreign owner, because that owner was
|
|
84
|
+
* measured idle. See THE IDLE-HOLDER RELAXATION below.
|
|
85
|
+
*
|
|
86
|
+
* It exists so the permissive outcome has to be DECLARED rather than inferred
|
|
87
|
+
* from `attachable` alone. `projectAttachability` treats a foreign owner on an
|
|
88
|
+
* attachable verdict as a contradiction and forces it back to refused; without
|
|
89
|
+
* a positive marker, relaxing the gate would mean deleting that check, and the
|
|
90
|
+
* check is what catches a future detector that flips `attachable` by accident.
|
|
91
|
+
* With the marker, the only way to reach the permissive path is to say so.
|
|
92
|
+
*
|
|
93
|
+
* Absent (not false) on every other verdict, so `=== true` is the only test.
|
|
94
|
+
*/
|
|
95
|
+
idleHolder?: true
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
/**
|
|
79
99
|
* What a scan could not establish. Any non-null value forbids attaching, even
|
|
80
100
|
* with zero owners — that is the whole point of the type.
|
|
81
101
|
*/
|
|
82
|
-
type Doubt = Exclude<
|
|
102
|
+
type Doubt = Exclude<
|
|
103
|
+
OccupancyReason,
|
|
104
|
+
'live_desktop_process' | 'native_thread_working' | 'unsupported_provider' | 'invalid_thread_id'
|
|
105
|
+
> | null
|
|
83
106
|
|
|
84
107
|
interface ScanResult {
|
|
85
108
|
owners: ThreadOwner[]
|
|
@@ -111,6 +134,127 @@ export interface OccupancyProbes {
|
|
|
111
134
|
* membership — the start time is what makes the claim checkable.
|
|
112
135
|
*/
|
|
113
136
|
cosSpawnedPids: () => ReadonlyMap<number, number>
|
|
137
|
+
/**
|
|
138
|
+
* Transcript write time for this thread, epoch ms, or null when it cannot be
|
|
139
|
+
* read. OPTIONAL, and its ABSENCE is the default.
|
|
140
|
+
*
|
|
141
|
+
* Absent means this install has no clock on the thread, which means every
|
|
142
|
+
* foreign holder stays `live_desktop_process` — byte-for-byte the behaviour
|
|
143
|
+
* that shipped before the idle-holder relaxation existed. That is why it is
|
|
144
|
+
* optional rather than required: the strict gate is what you get by doing
|
|
145
|
+
* nothing, and the relaxation has to be wired on purpose (see `server/index.ts`,
|
|
146
|
+
* where the wiring is itself behind `COS_THREAD_ATTACH_IDLE_HOLDER`).
|
|
147
|
+
*
|
|
148
|
+
* Null is NOT idle. See `holderActivity` for why that distinction is the whole
|
|
149
|
+
* safety property here.
|
|
150
|
+
*/
|
|
151
|
+
transcriptMtimeMs?: (provider: OccupancyProvider, threadId: string) => number | null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* How recently the transcript must have been written for a held thread to read
|
|
156
|
+
* as WORKING rather than merely OPEN.
|
|
157
|
+
*
|
|
158
|
+
* WHY A SECOND SIGNAL EXISTS AT ALL. `owners` answers "a process holds this
|
|
159
|
+
* thread", which is not the question the user is asking. He watched a session
|
|
160
|
+
* finish on his Mac while the glasses still showed it active, because the window
|
|
161
|
+
* was still open and the registry record therefore still existed. Occupancy has
|
|
162
|
+
* no clock in it; the transcript does.
|
|
163
|
+
*
|
|
164
|
+
* MEASURED, not assumed: while a session generates, its jsonl mtime tracks the
|
|
165
|
+
* wall clock to within a second, and when generation stops the mtime goes stale
|
|
166
|
+
* while the registry record stays exactly where it was. That divergence is the
|
|
167
|
+
* whole signal.
|
|
168
|
+
*
|
|
169
|
+
* 30 seconds is deliberately far wider than the observed sub-second write
|
|
170
|
+
* cadence. The gap this has to survive is a long tool call or a slow first token
|
|
171
|
+
* between writes, and a window sized to the cadence would flap a working session
|
|
172
|
+
* to OPEN and back every time the model paused to think. The cost of the wide
|
|
173
|
+
* window is bounded and known: a session that stops is reported working for up
|
|
174
|
+
* to 30 more seconds, which is a late correction rather than a permanent lie.
|
|
175
|
+
*
|
|
176
|
+
* LIVES HERE, not in `occupied-threads.ts`, since 6.32.0. The display hint and
|
|
177
|
+
* the write gate must agree on what "recently" means, and the gate is the lower
|
|
178
|
+
* module of the two. `occupied-threads.ts` re-exports it, so every existing
|
|
179
|
+
* importer is unaffected.
|
|
180
|
+
*/
|
|
181
|
+
export const ACTIVE_RECENTLY_WINDOW_MS = 30_000
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Was this transcript written inside the window?
|
|
185
|
+
*
|
|
186
|
+
* NULL IS NOT "RECENT". An unresolvable path, an unreadable file, a stat that
|
|
187
|
+
* threw — every one of them arrives here as null and answers false, so the row
|
|
188
|
+
* falls back to OPEN. That is not a fail-open FOR A DISPLAY HINT: OPEN is still
|
|
189
|
+
* a true statement about a thread with a live owner. It is the STRONGER claim,
|
|
190
|
+
* "an agent is working in here", that has to be earned by an actual observation.
|
|
191
|
+
*
|
|
192
|
+
* A timestamp far in the FUTURE is refused for the same reason rather than
|
|
193
|
+
* treated as maximally fresh. Skew of a few seconds is normal and lands inside
|
|
194
|
+
* the window; a file dated next week is a clock this server cannot reason about,
|
|
195
|
+
* and letting it manufacture a permanent "working" badge would rebuild the bug
|
|
196
|
+
* from the other direction.
|
|
197
|
+
*
|
|
198
|
+
* DO NOT CALL THIS FROM A WRITE GATE. Its false has two meanings — "measured,
|
|
199
|
+
* and stale" and "could not measure" — and a gate that treats the second as the
|
|
200
|
+
* first is fail-open. `holderActivity` is the gate-side reading.
|
|
201
|
+
*/
|
|
202
|
+
export function isActiveRecently(mtimeMs: number | null | undefined, nowMs: number): boolean {
|
|
203
|
+
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return false
|
|
204
|
+
if (!Number.isFinite(nowMs)) return false
|
|
205
|
+
return Math.abs(nowMs - mtimeMs) <= ACTIVE_RECENTLY_WINDOW_MS
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What a foreign holder is doing, for a caller that must DECIDE rather than
|
|
210
|
+
* render.
|
|
211
|
+
*
|
|
212
|
+
* THREE VALUES, AND THE THIRD IS THE POINT. `isActiveRecently` collapses "I
|
|
213
|
+
* measured a stale file" and "I could not measure anything" into the same
|
|
214
|
+
* `false`, which is correct for a badge — the weaker claim, OPEN, is true either
|
|
215
|
+
* way — and catastrophic for a gate, where that same `false` would mean ALLOW
|
|
216
|
+
* THE WRITE. Same reading, opposite polarity, so the gate gets its own function
|
|
217
|
+
* instead of reusing the hint's boolean.
|
|
218
|
+
*
|
|
219
|
+
* Only `idle` is a positive observation of an idle holder: a real number, not in
|
|
220
|
+
* the future, measured outside the window. Everything else is `unknown` and
|
|
221
|
+
* refuses. This is the same "no owner found is not no owner" rule the rest of
|
|
222
|
+
* this module runs on, applied to the clock instead of the registry.
|
|
223
|
+
*
|
|
224
|
+
* The window itself is NOT redefined here — `isActiveRecently` decides what
|
|
225
|
+
* recent means, so the badge and the gate can never drift apart on it.
|
|
226
|
+
*/
|
|
227
|
+
export type HolderActivity = 'working' | 'idle' | 'unknown'
|
|
228
|
+
|
|
229
|
+
export function holderActivity(mtimeMs: number | null | undefined, nowMs: number): HolderActivity {
|
|
230
|
+
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return 'unknown'
|
|
231
|
+
if (!Number.isFinite(nowMs)) return 'unknown'
|
|
232
|
+
// A future-dated transcript is a clock this server cannot reason about. The
|
|
233
|
+
// hint may treat a few seconds of skew as freshness; the gate may not treat
|
|
234
|
+
// ANY amount of it as idleness, because "stale" is the permissive answer here
|
|
235
|
+
// and a wrong clock would hand it out forever.
|
|
236
|
+
if (mtimeMs > nowMs + ACTIVE_RECENTLY_WINDOW_MS) return 'unknown'
|
|
237
|
+
return isActiveRecently(mtimeMs, nowMs) ? 'working' : 'idle'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read the transcript clock for a thread, refusing to guess.
|
|
242
|
+
*
|
|
243
|
+
* A probe that is absent, throws, or answers null all land on `unknown`, which
|
|
244
|
+
* refuses. There is no path from a failed reading to a permissive verdict.
|
|
245
|
+
*/
|
|
246
|
+
function readHolderActivity(
|
|
247
|
+
provider: OccupancyProvider,
|
|
248
|
+
threadId: string,
|
|
249
|
+
probes: OccupancyProbes,
|
|
250
|
+
nowMs: number,
|
|
251
|
+
): HolderActivity {
|
|
252
|
+
if (typeof probes.transcriptMtimeMs !== 'function') return 'unknown'
|
|
253
|
+
try {
|
|
254
|
+
return holderActivity(probes.transcriptMtimeMs(provider, threadId), nowMs)
|
|
255
|
+
} catch {
|
|
256
|
+
return 'unknown'
|
|
257
|
+
}
|
|
114
258
|
}
|
|
115
259
|
|
|
116
260
|
/** A Claude registry filename is exactly `<pid>.json`. Not `*.json`. */
|
|
@@ -321,14 +465,81 @@ export interface OccupancyDirs {
|
|
|
321
465
|
codexLocksDir: string
|
|
322
466
|
}
|
|
323
467
|
|
|
468
|
+
// ===========================================================================
|
|
469
|
+
// THE IDLE-HOLDER RELAXATION (6.32.0)
|
|
470
|
+
// ===========================================================================
|
|
471
|
+
//
|
|
472
|
+
// This loosens a guard that was deliberate. Read this before touching it, and
|
|
473
|
+
// do not widen it further without repeating the experiment.
|
|
474
|
+
//
|
|
475
|
+
// WHAT CHANGED. A foreign owner used to be terminal on its own. It is now
|
|
476
|
+
// terminal only while that owner is DEMONSTRABLY WRITING. A holder measured
|
|
477
|
+
// idle — registry record alive, transcript stale — is continuable.
|
|
478
|
+
//
|
|
479
|
+
// WHY THE OLD RULE WAS WRONG. The registry records an OPEN WINDOW, not active
|
|
480
|
+
// generation. `~/.claude/sessions/<pid>.json` for a session that finished ten
|
|
481
|
+
// minutes ago is byte-identical to one mid-turn. Miles keeps Claude Code
|
|
482
|
+
// windows open, so Continue was refused for exactly the threads he cares about
|
|
483
|
+
// and allowed only for the ones he had abandoned. The gate was measuring the
|
|
484
|
+
// wrong thing, not measuring it too strictly.
|
|
485
|
+
//
|
|
486
|
+
// THE CANARY, run 2026-08-16 against a real interactive `claude` 2.1.229 held
|
|
487
|
+
// open in tmux (pid 48446, `kind: interactive`, `entrypoint: claude-desktop`,
|
|
488
|
+
// indistinguishable from a desktop window) in a scratch workspace. Turns were
|
|
489
|
+
// injected with the EXACT argv this server spawns. Raw findings:
|
|
490
|
+
//
|
|
491
|
+
// 1. DELIVERY WORKS. `claude -p --resume <id>` into an idle-held thread
|
|
492
|
+
// exits 0 in ~5s and returns its result. Same session id, same file.
|
|
493
|
+
// 2. NO CORRUPTION. Across 6 injections and 6 desktop turns, including
|
|
494
|
+
// three SDK writers landing within 45ms of each other on the same node,
|
|
495
|
+
// the transcript stayed append-only: every prefix byte-identical before
|
|
496
|
+
// and after (`cmp`), zero unparseable lines, zero dangling parentUuid.
|
|
497
|
+
// Claude Code appends whole rows, so byte interleaving does not occur.
|
|
498
|
+
// 3. THE HOLDER IGNORES THE INJECTED TURN — this is the real finding. Its
|
|
499
|
+
// in-memory view is stale and stays stale. Asked afterwards to list every
|
|
500
|
+
// word it had been told to reply with, it answered "ALPHA": the injected
|
|
501
|
+
// "BRAVO" was invisible to it.
|
|
502
|
+
// 4. NOTHING IS CLOBBERED, BUT THE THREAD FORKS. The holder's next turn
|
|
503
|
+
// parented to the PRE-INJECTION tail, making the transcript a tree. Both
|
|
504
|
+
// turns survive in full; they are on different branches, and from then on
|
|
505
|
+
// each writer sees only its own. A later `--resume` read back
|
|
506
|
+
// "ALPHA, BRAVO" and never saw the desktop's "CHARLIE".
|
|
507
|
+
// 5. THE WORKING CASE IS DIFFERENT AND STAYS BLOCKED. With the holder
|
|
508
|
+
// generating, the transcript mtime was 6.2s old and `holderActivity`
|
|
509
|
+
// returned `working`, so this relaxation does not fire there at all.
|
|
510
|
+
//
|
|
511
|
+
// SO THE ANSWER IS: no clobbering, no corruption, no data loss — but the two
|
|
512
|
+
// views diverge silently after the write, and neither side is told by THIS
|
|
513
|
+
// module. What makes that acceptable is the guard one layer up: `nativeHead`
|
|
514
|
+
// digests the transcript tail, and a desktop write CHANGES it (measured:
|
|
515
|
+
// nh1:faba1b35... -> nh1:ace93a1f...), so the next COS turn on that binding is
|
|
516
|
+
// refused with `native_thread_changed` and the user is offered refresh, continue
|
|
517
|
+
// anyway, or fork. Divergence is caught by the CONTENT watermark, which is the
|
|
518
|
+
// signal that can actually see it. Occupancy never could.
|
|
519
|
+
//
|
|
520
|
+
// WHAT THIS IS NOT A LICENCE FOR. Do not extend the same reasoning to the
|
|
521
|
+
// `doubt` reasons below: those mean the scan could not SEE, and an unreadable
|
|
522
|
+
// registry is not an idle holder. Do not relax the `working` branch on the
|
|
523
|
+
// grounds that "nothing got corrupted in the canary either" — case 5 was never
|
|
524
|
+
// run to completion precisely because it stays blocked. And do not reach for
|
|
525
|
+
// `isActiveRecently` here; its `false` means "stale OR unmeasurable", and only
|
|
526
|
+
// `holderActivity` separates those.
|
|
527
|
+
//
|
|
528
|
+
// TURNING IT OFF. Unwire `probes.transcriptMtimeMs` (in practice: unset
|
|
529
|
+
// `COS_THREAD_ATTACH_IDLE_HOLDER`). Every foreign holder then reads `unknown`
|
|
530
|
+
// and refuses, which is the pre-6.32.0 gate exactly. No rollback needed.
|
|
531
|
+
|
|
324
532
|
/**
|
|
325
533
|
* The Phase 0 attach precondition.
|
|
326
534
|
*
|
|
327
535
|
* Returns attachable ONLY when a supported provider proved its detector exists,
|
|
328
|
-
* read every candidate record, and found no
|
|
329
|
-
* names why. The whole function is wrapped: a throwing probe — including
|
|
330
|
-
* spawn ledger, which is the most safety-critical of them — is `probe_failed`,
|
|
536
|
+
* read every candidate record, and found no owner it must respect. Every other
|
|
537
|
+
* outcome names why. The whole function is wrapped: a throwing probe — including
|
|
538
|
+
* the spawn ledger, which is the most safety-critical of them — is `probe_failed`,
|
|
331
539
|
* never an exception escaping into a route.
|
|
540
|
+
*
|
|
541
|
+
* A foreign owner measured IDLE is the one exception, and it is marked
|
|
542
|
+
* `idleHolder` on the way out. See THE IDLE-HOLDER RELAXATION above.
|
|
332
543
|
*/
|
|
333
544
|
export function threadOccupancy(
|
|
334
545
|
provider: string,
|
|
@@ -358,10 +569,23 @@ export function threadOccupancy(
|
|
|
358
569
|
|
|
359
570
|
const foreign = result.owners.filter(o => !o.selfOwned)
|
|
360
571
|
if (foreign.length > 0) {
|
|
361
|
-
|
|
572
|
+
const activity = readHolderActivity(provider, threadId, probes, Date.now())
|
|
573
|
+
if (activity !== 'idle') {
|
|
574
|
+
return {
|
|
575
|
+
attachable: false,
|
|
576
|
+
owners: result.owners,
|
|
577
|
+
reason: activity === 'working' ? 'native_thread_working' : 'live_desktop_process',
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// FALL THROUGH, deliberately. See THE IDLE-HOLDER RELAXATION below for why this is
|
|
581
|
+
// safe and what it is NOT. Everything after this point still applies: a scan
|
|
582
|
+
// that could not establish something still refuses on the next line.
|
|
362
583
|
}
|
|
363
584
|
if (result.doubt !== null) {
|
|
364
585
|
return { attachable: false, owners: result.owners, reason: result.doubt }
|
|
365
586
|
}
|
|
587
|
+
if (foreign.length > 0) {
|
|
588
|
+
return { attachable: true, owners: result.owners, reason: null, idleHolder: true }
|
|
589
|
+
}
|
|
366
590
|
return { attachable: true, owners: result.owners, reason: null }
|
|
367
591
|
}
|
|
@@ -391,8 +391,17 @@ export const REASON_COPY: Record<OccupancyReason, string> = {
|
|
|
391
391
|
// supported permanent configuration, not a degraded one (plan 4.9).
|
|
392
392
|
attach_disabled:
|
|
393
393
|
'Continuing a thread on your Mac is turned off. COS is read-only here. Fork it instead.',
|
|
394
|
+
// Held open by another app AND COS has no clock on the thread, so it cannot
|
|
395
|
+
// tell working from idle. Since 6.32.0 an idle holder is continuable, which
|
|
396
|
+
// makes this the "could not measure" case rather than the "someone else is
|
|
397
|
+
// here" case, and the copy says which.
|
|
394
398
|
live_desktop_process:
|
|
395
|
-
'Open on your Mac
|
|
399
|
+
'Open on your Mac, and COS cannot tell whether it is still working. It will not write into it. Fork it instead.',
|
|
400
|
+
// Measured, and the answer was yes. Deliberately a different instruction from
|
|
401
|
+
// the line above: this clears on its own within seconds, so the useful advice
|
|
402
|
+
// is to wait, with fork as the fallback rather than the recommendation.
|
|
403
|
+
native_thread_working:
|
|
404
|
+
'Your Mac is writing to this thread right now. Wait a few seconds and try again, or fork it.',
|
|
396
405
|
unsupported_provider:
|
|
397
406
|
'This assistant cannot be continued from COS yet. Fork it instead.',
|
|
398
407
|
invalid_thread_id:
|
|
@@ -672,14 +681,26 @@ export interface AttachabilityBody {
|
|
|
672
681
|
* permissive way if simply forwarded: attachable with a reason, attachable with an
|
|
673
682
|
* owner that is not provably ours, and a non-array owners field. Any of them is a
|
|
674
683
|
* defect upstream, and a defect must not resolve to permissive.
|
|
684
|
+
*
|
|
685
|
+
* THE ONE EXEMPTION, added with the idle-holder relaxation in 6.32.0: a foreign
|
|
686
|
+
* owner is allowed on an attachable verdict when the verdict itself carries
|
|
687
|
+
* `idleHolder === true`. The check is not weakened by this, it is made explicit —
|
|
688
|
+
* before, "attachable" and "no foreign owner" were the same claim, so a detector
|
|
689
|
+
* that flipped `attachable` by mistake was caught here. It still is. What can no
|
|
690
|
+
* longer be caught here is a detector that ALSO sets `idleHolder`, which takes a
|
|
691
|
+
* deliberate edit in `thread-occupancy.ts` rather than an accident, and which the
|
|
692
|
+
* mutation tests over that file cover.
|
|
675
693
|
*/
|
|
676
694
|
export function projectAttachability(verdict: Occupancy): AttachabilityBody {
|
|
677
695
|
const owners = Array.isArray(verdict?.owners) ? verdict.owners : null
|
|
696
|
+
// Strictly `=== true`. An idleHolder of 1, 'yes', or {} is a malformed verdict,
|
|
697
|
+
// and a malformed verdict must not buy an exemption.
|
|
698
|
+
const foreignOwnerDeclared = verdict?.idleHolder === true
|
|
678
699
|
const sound =
|
|
679
700
|
verdict?.attachable === true &&
|
|
680
701
|
verdict.reason === null &&
|
|
681
702
|
owners !== null &&
|
|
682
|
-
owners.every(owner => owner?.selfOwned === true)
|
|
703
|
+
owners.every(owner => owner?.selfOwned === true || foreignOwnerDeclared)
|
|
683
704
|
const reason: OccupancyReason | null = sound ? null : ((verdict?.reason ?? 'probe_failed') as OccupancyReason)
|
|
684
705
|
return {
|
|
685
706
|
attachable: sound,
|
|
@@ -32,7 +32,13 @@ 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 {
|
|
35
|
+
import {
|
|
36
|
+
occupiedThreads,
|
|
37
|
+
noOccupancyKnown,
|
|
38
|
+
withActiveRecently,
|
|
39
|
+
type OccupiedScan,
|
|
40
|
+
type OccupiedThread,
|
|
41
|
+
} from '../lib/occupied-threads.js'
|
|
36
42
|
import { realOccupancyDirs, realOccupancyProbes } from '../lib/occupancy-probes.js'
|
|
37
43
|
import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
38
44
|
|
|
@@ -105,16 +111,115 @@ function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
|
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
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) {
|
|
110
173
|
const occ = scan.occupied.get(entry.session_id)
|
|
111
174
|
return {
|
|
112
175
|
...entry,
|
|
113
|
-
//
|
|
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.
|
|
114
179
|
running: occ !== undefined,
|
|
115
180
|
// Held by something that is not COS, so a Continue would be refused. The
|
|
116
181
|
// badge reads `running`; the Continue affordance reads this.
|
|
117
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()
|
|
118
223
|
}
|
|
119
224
|
}
|
|
120
225
|
|
|
@@ -164,7 +269,11 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
164
269
|
const sort = asSort(req.query.sort)
|
|
165
270
|
const live = await liveClaudeRows()
|
|
166
271
|
const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
|
|
167
|
-
const
|
|
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())
|
|
168
277
|
res.json({
|
|
169
278
|
sessions: sessions.map(row => withRunning(toEntry(row), running)),
|
|
170
279
|
total: sessions.length,
|
|
@@ -234,8 +343,19 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
234
343
|
if (named) parsed.display_label = named
|
|
235
344
|
}
|
|
236
345
|
const modified = st.mtime.toISOString()
|
|
346
|
+
const running = runningForThread(provider, parsed.session_id, st.mtimeMs)
|
|
237
347
|
res.json({
|
|
238
|
-
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,
|
|
239
359
|
provider: parsed.provider,
|
|
240
360
|
slug: parsed.session_id,
|
|
241
361
|
custom_title: parsed.display_label,
|