@gotcos/glasses-server 6.31.0 → 6.34.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,96 @@ unsaved capture, and makes batch status stop lying about finished work.
2219
2219
 
2220
2220
  # Changelog
2221
2221
 
2222
+ ## [6.34.0] - 2026-08-16
2223
+
2224
+ ### A continued turn now runs with the session's own permissions
2225
+
2226
+ - **This widens what Continue can do.** A prompt spoken into the glasses can run
2227
+ tools on the Mac with nobody at the keyboard. Authorized explicitly by Miles
2228
+ after his first real continued turn came back reporting that every tool was
2229
+ disabled, which is not the point of the feature.
2230
+ - Claude drops `--permission-mode plan` and the empty `--tools`/`--allowedTools`
2231
+ pair. Codex now uses `getCodexTrustMode()`, the same posture ordinary Codex runs
2232
+ use on the host, rather than a stricter one invented at this call site. Absent
2233
+ `COS_CODEX_SANDBOX=workspace-write` that is still read-only, so this is never
2234
+ more permissive than the rest of the server.
2235
+ - **Unchanged, and load-bearing:** `COS_THREAD_ATTACH_ENABLED` still gates the
2236
+ surface and off still leaves the routes unregistered; `findBannedPermissionArg`
2237
+ still rejects every real bypass at the spawn boundary; delivery is still gated
2238
+ on a fresh occupancy probe, the epoch floor, the per-target claim and the head
2239
+ watermark. Dropping a lockdown is not adding a bypass.
2240
+ - The old read-only posture had NO test asserting it was present, so it could have
2241
+ been deleted silently. The new posture is pinned, and so is the bypass ban.
2242
+
2243
+ ## [6.33.0] - 2026-08-16
2244
+
2245
+ ### One switch turns Continue on
2246
+
2247
+ Continue was gated by two environment variables, and the second one was
2248
+ invisible. `COS_THREAD_ATTACH_ENABLED=1` registered the write routes;
2249
+ `COS_THREAD_ATTACH_IDLE_HOLDER=1` was additionally needed before COS would write
2250
+ into a thread whose Mac window is open but idle. COS Control's "Continue agent
2251
+ threads" checkbox sets only the first, so health reported the feature ON while
2252
+ every single attempt was refused, because a developer's editor windows stay
2253
+ open and that is precisely the case the second flag covered.
2254
+
2255
+ - **`COS_THREAD_ATTACH_ENABLED=1` is now the whole answer.** It registers the two
2256
+ write routes and wires the transcript clock that tells an idle holder from a
2257
+ working one. Nothing else to set.
2258
+ - **`COS_THREAD_ATTACH_IDLE_HOLDER` is REMOVED, not deprecated.** If you set it
2259
+ by hand, it is now ignored and can be deleted from your LaunchAgent plist or
2260
+ shell profile. Leaving it in place changes nothing either way.
2261
+ - **COS Control needs no update for this.** Its existing checkbox already writes
2262
+ the surviving key, so the toggle that reported ON while refusing now reports
2263
+ ON and works.
2264
+
2265
+ The two switches were never independently useful, and folding them is the honest
2266
+ description of the decision rather than a convenience. The relaxation is what
2267
+ makes a silent fork possible, so "Continue is on" and "a fork may happen" are one
2268
+ choice, and one switch states it.
2269
+
2270
+ Every safety behaviour is unchanged. A holder measured WRITING is still refused
2271
+ with `native_thread_working`; a holder COS cannot measure is still refused with
2272
+ `live_desktop_process`, because only a positive idle observation relaxes the gate
2273
+ and an unreadable transcript is not one. Off still means the write routes are not
2274
+ registered at all, so a disabled server answers 404 rather than 403 and holds no
2275
+ reachable write code.
2276
+
2277
+ ## [6.32.0] - 2026-08-16
2278
+
2279
+ ### Continue now blocks on WORKING, not on merely open
2280
+
2281
+ Continuing a Claude Code thread from COS used to be refused whenever any foreign
2282
+ process held it. That came from `~/.claude/sessions/<pid>.json`, which records an
2283
+ OPEN WINDOW, not an agent generating: a session that finished ten minutes ago
2284
+ with the window still up looked identical to one mid-turn. Anyone who leaves
2285
+ Claude Code windows open was therefore refused on exactly the threads they work
2286
+ in, and allowed only on the ones they had abandoned.
2287
+
2288
+ - A foreign holder is now terminal only while it is **demonstrably writing**. A
2289
+ holder measured idle (registry record alive, transcript stale past the same
2290
+ 30s window `running_active` uses) is continuable.
2291
+ - New refusal reason **`native_thread_working`**, distinct from
2292
+ `live_desktop_process`, because the two ask for different things. Working
2293
+ clears itself in seconds and the copy says to wait; `live_desktop_process` now
2294
+ means COS could not measure the thread at all and says so rather than implying
2295
+ the thread is busy.
2296
+ - Off by default behind **`COS_THREAD_ATTACH_IDLE_HOLDER=1`**. Unset, no
2297
+ transcript clock is wired, every foreign holder reads unknown, and the gate
2298
+ behaves exactly as it did in 6.31.0. Composes with `COS_THREAD_ATTACH_ENABLED`.
2299
+
2300
+ Verified against a real interactive `claude` 2.1.229 held open and idle before
2301
+ the gate was touched. Writing into it delivers normally and never corrupts:
2302
+ across six injections and six desktop turns, including three writers landing
2303
+ within 45ms of each other, every transcript prefix stayed byte-identical, with
2304
+ no unparseable lines and no dangling parent references. What it DOES do is fork
2305
+ the conversation: the holder's in-memory view is stale, so its next turn branches
2306
+ off the pre-injection tail and the two writers stop seeing each other. Nothing is
2307
+ lost, and the divergence is caught one layer up by the head watermark, which
2308
+ changes on a desktop write and refuses the next COS turn with
2309
+ `native_thread_changed`. The full canary, including what it does NOT license, is
2310
+ recorded in `server/lib/thread-occupancy.ts` under THE IDLE-HOLDER RELAXATION.
2311
+
2222
2312
  ## [6.31.0] - 2026-08-16
2223
2313
 
2224
2314
  ### "Running" meant an open window, not a working agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.31.0",
3
+ "version": "6.34.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
@@ -20,10 +20,13 @@ import { transcribeRouter } from './routes/transcribe.js'
20
20
  import { sessionIndexRouter } from './routes/session-index.js'
21
21
  import { agentSessionsRouter } from './routes/agent-sessions.js'
22
22
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
23
- import { createAgentSessionBindingsRouter } from './routes/agent-session-bindings.js'
23
+ import {
24
+ createAgentSessionBindingsRouter,
25
+ threadAttachEnabled,
26
+ } from './routes/agent-session-bindings.js'
24
27
  import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
25
28
  import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
26
- import { realOccupancyDirs, realOccupancyProbes } from './lib/occupancy-probes.js'
29
+ import { buildOccupancyProbes, realOccupancyDirs } from './lib/occupancy-probes.js'
27
30
  import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
28
31
  import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
29
32
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
@@ -301,10 +304,33 @@ bindingReapTimer.unref()
301
304
 
302
305
  // Built once. Each of these reads the disk, so sharing them keeps an attach from
303
306
  // re-deriving roots per request.
304
- const occupancyProbes = realOccupancyProbes(cosSpawnedPids)
305
307
  const occupancyDirs = realOccupancyDirs()
306
308
  const nativeHeadDeps = realNativeHeadDeps()
307
309
  const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
310
+ /**
311
+ * THE ONE PLACE the idle-holder relaxation is switched on, on the ONE flag
312
+ * that also registers the write routes (6.33.0).
313
+ *
314
+ * Without the clock, a foreign holder is terminal and Continue is refused for
315
+ * any thread with a Claude Code window open on it — including the ones Miles
316
+ * actually works in, because he leaves those windows open. With it, a holder
317
+ * measured idle is continuable and only a holder measured WRITING is refused.
318
+ *
319
+ * `threadAttachEnabled()` is the same call `createAgentSessionBindingsRouter`
320
+ * makes below to decide whether the two POST routes exist at all. Reading it
321
+ * here rather than a second env key is what makes "Continue is on" a single
322
+ * answer: the pair of flags this replaced could disagree, and did, reporting
323
+ * the feature enabled while every attempt was refused.
324
+ *
325
+ * Both gates read this same object — the route's detector via `probes` below,
326
+ * and the adapter's pre-spawn preflight via `occupancyProbes` at the closure
327
+ * further down — so they cannot come to different conclusions. That is the whole
328
+ * reason the clock is a probe rather than a check bolted onto each call site.
329
+ *
330
+ * Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
331
+ * RELAXATION before changing this line.
332
+ */
333
+ const occupancyProbes = buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
308
334
 
309
335
  /**
310
336
  * The shim between the route's request shape and the adapter's.
@@ -93,6 +93,7 @@ import { isValidNativeThreadId } from './native-thread-id.js'
93
93
  import { isBindableProvider, type BindableProvider } from './agent-session-binding-store.js'
94
94
  import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store.js'
95
95
  import { processStartMs as realProcessStartMs } from './occupancy-probes.js'
96
+ import { getCodexTrustMode } from './codex-run-ledger.js'
96
97
 
97
98
  /** Providers with a certified attached path. Cursor is Fork-only (plan 2.5). */
98
99
  export type AttachedProvider = BindableProvider
@@ -529,17 +530,35 @@ const STRIPPED_ENV_KEYS: readonly string[] = ['CLAUDECODE', 'COS_API_TOKEN']
529
530
  // ---------------------------------------------------------------------------
530
531
 
531
532
  /**
532
- * Claude: `claude -p --resume <id>`, read-only, prompt on stdin.
533
+ * Claude: `claude -p --resume <id>`, prompt on stdin.
533
534
  *
534
535
  * The prompt is NOT an argv element. argv is world-readable through `ps`, and
535
536
  * the prompt is the user's private text; stdin also matches what both ordinary
536
537
  * bridges already do.
537
538
  *
538
- * Two independent read-only layers, because either alone is one assumption
539
- * deep: `--permission-mode plan` means the model cannot mutate regardless of
540
- * its tool list, and the empty `--tools`/`--allowedTools` pair is the exact
541
- * text-only posture `claude-permissions.ts` already ships for untrusted mode.
542
- * If an older CLI rejects `plan`, it exits non-zero and we fail closed.
539
+ * THE TURN INHERITS THE SESSION'S OWN PERMISSIONS (Miles, 2026-08-16, explicit).
540
+ *
541
+ * This used to force two independent read-only layers: `--permission-mode plan`
542
+ * plus an empty `--tools`/`--allowedTools` pair. The result was a Continue that
543
+ * could talk into a thread and do nothing in it -- his first real continued turn
544
+ * came back reporting that every tool was disabled, which is not "like being at
545
+ * the desk". Both layers are gone; a resumed turn now runs as that session
546
+ * ordinarily would.
547
+ *
548
+ * What that means plainly: a prompt spoken into a pair of glasses can run tools
549
+ * on this Mac with nobody at the keyboard. That is the intent, not an oversight.
550
+ * The protections that remain, and that must not be quietly removed with it:
551
+ *
552
+ * 1. `COS_THREAD_ATTACH_ENABLED` still gates the whole surface, and off means
553
+ * the write routes are never registered.
554
+ * 2. `findBannedPermissionArg` still runs at the spawn boundary and still
555
+ * rejects the actual bypasses -- `--dangerously-skip-permissions`, `--yolo`,
556
+ * `danger-full-access` and the rest. Dropping a lockdown is not the same as
557
+ * adding a bypass, and the bypass ban is unchanged.
558
+ * 3. The menu cursor rests on a row that cannot act, so a false-positive ring
559
+ * tap cannot reach Continue (`defaultSessionThreadActionIndex`).
560
+ * 4. Delivery is still gated on a fresh occupancy probe, the epoch floor, the
561
+ * per-target claim and the head watermark.
543
562
  */
544
563
  export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
545
564
  return [
@@ -549,9 +568,6 @@ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
549
568
  // never observe the session id we are required to verify.
550
569
  '--verbose',
551
570
  '--resume', nativeThreadId,
552
- '--permission-mode', 'plan',
553
- '--tools', '',
554
- '--allowedTools', '',
555
571
  ]
556
572
  }
557
573
 
@@ -571,7 +587,10 @@ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
571
587
  export function buildCodexAttachedArgs(nativeThreadId: string, cwd: string): string[] {
572
588
  return [
573
589
  'exec',
574
- '--sandbox', 'read-only',
590
+ // The posture ordinary Codex runs use on this host, not a stricter one
591
+ // invented here. `COS_CODEX_SANDBOX=workspace-write` opts in; absent stays
592
+ // read-only, so this is never MORE permissive than the rest of the server.
593
+ '--sandbox', getCodexTrustMode(),
575
594
  '--cd', cwd,
576
595
  'resume',
577
596
  '--json',
@@ -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,79 @@ 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
+ * The probe set this install should run, clock and all.
450
+ *
451
+ * ONE DECISION, IN ONE PLACE (6.33.0). `attachEnabled` is the SAME answer that
452
+ * decides whether the two write routes get registered — `threadAttachEnabled()`,
453
+ * passed in by the composition root rather than re-read here. There is exactly
454
+ * one reader of `COS_THREAD_ATTACH_ENABLED` in the server, and a second copy is
455
+ * how a surface comes to advertise a write path the gate will refuse.
456
+ *
457
+ * WHY THE CLOCK RIDES THE SAME SWITCH. In 6.32.0 the relaxation had a second
458
+ * env key of its own (named in the 6.33.0 changelog entry; it is now ignored),
459
+ * and the two were never independently useful: without the clock, Continue is
460
+ * refused on every thread with an editor window open, which for a working
461
+ * developer is all of them. The pair also cost a real testing cycle, because
462
+ * COS Control's checkbox knew only the first key and reported the feature ON
463
+ * while the gate still refused every session. And the honest reading is that
464
+ * they were always ONE decision: the relaxation is what makes a silent fork
465
+ * possible, so "Continue is on" and "fork is possible" are the same choice, and
466
+ * one switch says so.
467
+ *
468
+ * Attach off is still byte-for-byte the pre-6.28 posture: no clock, so every
469
+ * foreign holder reads `unknown` and refuses, and the write routes do not exist
470
+ * to be reached anyway.
471
+ */
472
+ export function buildOccupancyProbes(
473
+ ledger: SpawnLedgerAccessor,
474
+ headDeps: NativeHeadDeps,
475
+ attachEnabled: boolean,
476
+ ): OccupancyProbes {
477
+ const base = realOccupancyProbes(ledger)
478
+ // Strictly `=== true`. A caller that hands over a truthy non-boolean has not
479
+ // answered the question, and the permissive branch is the one that must be
480
+ // earned by a real yes.
481
+ return attachEnabled === true ? withTranscriptClock(base, headDeps) : base
482
+ }
483
+
484
+ /**
485
+ * Add a transcript clock to a probe set, so an idle foreign holder can be told
486
+ * apart from a working one.
487
+ *
488
+ * WHY THIS IS A SEPARATE WRAPPER rather than a member of `realOccupancyProbes`:
489
+ * the strict gate has to be what you get by DEFAULT. A probe set built the
490
+ * ordinary way has no clock, every foreign holder reads `unknown`, and
491
+ * `threadOccupancy` refuses exactly as it did before 6.32.0. Turning the
492
+ * relaxation on is then one visible call at one wiring site, and turning it off
493
+ * is deleting that call — no rollback, no second code path to keep in step.
494
+ *
495
+ * Null on every failure: an unresolvable id, a missing file, a stat that threw.
496
+ * `holderActivity` reads null as `unknown`, which refuses. There is deliberately
497
+ * no path from "could not read the transcript" to "the holder is idle".
498
+ *
499
+ * `statSync` and not `stat`: `OccupancyProbes` is synchronous throughout, and the
500
+ * adapter's pre-spawn preflight REFUSES a thenable rather than awaiting one —
501
+ * awaiting there would reopen the very race the check exists to close.
502
+ */
503
+ export function withTranscriptClock(
504
+ probes: OccupancyProbes,
505
+ headDeps: NativeHeadDeps,
506
+ ): OccupancyProbes {
507
+ return {
508
+ ...probes,
509
+ transcriptMtimeMs: (provider, threadId) => {
510
+ try {
511
+ const path = transcriptPathFor(provider, threadId, headDeps)
512
+ if (path === null) return null
513
+ return statSync(path).mtimeMs
514
+ } catch {
515
+ return null
516
+ }
517
+ },
443
518
  }
444
519
  }
@@ -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,
@@ -210,49 +211,19 @@ export function noOccupancyKnown(): OccupiedScan {
210
211
  }
211
212
 
212
213
  /**
213
- * How recently the transcript must have been written for a held thread to read
214
- * as WORKING rather than merely OPEN.
214
+ * The freshness window and the raw reading now live in `thread-occupancy.ts`.
215
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.
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.
221
220
  *
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.
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.
233
225
  */
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
- }
226
+ export { ACTIVE_RECENTLY_WINDOW_MS, isActiveRecently } from './thread-occupancy.js'
256
227
 
257
228
  /**
258
229
  * Fill in `activeRecently` for a scan, from transcript write times.
@@ -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<OccupancyReason, 'live_desktop_process' | 'unsupported_provider' | 'invalid_thread_id'> | null
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,128 @@ 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 is wired on purpose by `buildOccupancyProbes`
146
+ * in `occupancy-probes.ts`, which attaches the clock only when thread attach
147
+ * itself is on.
148
+ *
149
+ * Null is NOT idle. See `holderActivity` for why that distinction is the whole
150
+ * safety property here.
151
+ */
152
+ transcriptMtimeMs?: (provider: OccupancyProvider, threadId: string) => number | null
153
+ }
154
+
155
+ /**
156
+ * How recently the transcript must have been written for a held thread to read
157
+ * as WORKING rather than merely OPEN.
158
+ *
159
+ * WHY A SECOND SIGNAL EXISTS AT ALL. `owners` answers "a process holds this
160
+ * thread", which is not the question the user is asking. He watched a session
161
+ * finish on his Mac while the glasses still showed it active, because the window
162
+ * was still open and the registry record therefore still existed. Occupancy has
163
+ * no clock in it; the transcript does.
164
+ *
165
+ * MEASURED, not assumed: while a session generates, its jsonl mtime tracks the
166
+ * wall clock to within a second, and when generation stops the mtime goes stale
167
+ * while the registry record stays exactly where it was. That divergence is the
168
+ * whole signal.
169
+ *
170
+ * 30 seconds is deliberately far wider than the observed sub-second write
171
+ * cadence. The gap this has to survive is a long tool call or a slow first token
172
+ * between writes, and a window sized to the cadence would flap a working session
173
+ * to OPEN and back every time the model paused to think. The cost of the wide
174
+ * window is bounded and known: a session that stops is reported working for up
175
+ * to 30 more seconds, which is a late correction rather than a permanent lie.
176
+ *
177
+ * LIVES HERE, not in `occupied-threads.ts`, since 6.32.0. The display hint and
178
+ * the write gate must agree on what "recently" means, and the gate is the lower
179
+ * module of the two. `occupied-threads.ts` re-exports it, so every existing
180
+ * importer is unaffected.
181
+ */
182
+ export const ACTIVE_RECENTLY_WINDOW_MS = 30_000
183
+
184
+ /**
185
+ * Was this transcript written inside the window?
186
+ *
187
+ * NULL IS NOT "RECENT". An unresolvable path, an unreadable file, a stat that
188
+ * threw — every one of them arrives here as null and answers false, so the row
189
+ * falls back to OPEN. That is not a fail-open FOR A DISPLAY HINT: OPEN is still
190
+ * a true statement about a thread with a live owner. It is the STRONGER claim,
191
+ * "an agent is working in here", that has to be earned by an actual observation.
192
+ *
193
+ * A timestamp far in the FUTURE is refused for the same reason rather than
194
+ * treated as maximally fresh. Skew of a few seconds is normal and lands inside
195
+ * the window; a file dated next week is a clock this server cannot reason about,
196
+ * and letting it manufacture a permanent "working" badge would rebuild the bug
197
+ * from the other direction.
198
+ *
199
+ * DO NOT CALL THIS FROM A WRITE GATE. Its false has two meanings — "measured,
200
+ * and stale" and "could not measure" — and a gate that treats the second as the
201
+ * first is fail-open. `holderActivity` is the gate-side reading.
202
+ */
203
+ export function isActiveRecently(mtimeMs: number | null | undefined, nowMs: number): boolean {
204
+ if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return false
205
+ if (!Number.isFinite(nowMs)) return false
206
+ return Math.abs(nowMs - mtimeMs) <= ACTIVE_RECENTLY_WINDOW_MS
207
+ }
208
+
209
+ /**
210
+ * What a foreign holder is doing, for a caller that must DECIDE rather than
211
+ * render.
212
+ *
213
+ * THREE VALUES, AND THE THIRD IS THE POINT. `isActiveRecently` collapses "I
214
+ * measured a stale file" and "I could not measure anything" into the same
215
+ * `false`, which is correct for a badge — the weaker claim, OPEN, is true either
216
+ * way — and catastrophic for a gate, where that same `false` would mean ALLOW
217
+ * THE WRITE. Same reading, opposite polarity, so the gate gets its own function
218
+ * instead of reusing the hint's boolean.
219
+ *
220
+ * Only `idle` is a positive observation of an idle holder: a real number, not in
221
+ * the future, measured outside the window. Everything else is `unknown` and
222
+ * refuses. This is the same "no owner found is not no owner" rule the rest of
223
+ * this module runs on, applied to the clock instead of the registry.
224
+ *
225
+ * The window itself is NOT redefined here — `isActiveRecently` decides what
226
+ * recent means, so the badge and the gate can never drift apart on it.
227
+ */
228
+ export type HolderActivity = 'working' | 'idle' | 'unknown'
229
+
230
+ export function holderActivity(mtimeMs: number | null | undefined, nowMs: number): HolderActivity {
231
+ if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return 'unknown'
232
+ if (!Number.isFinite(nowMs)) return 'unknown'
233
+ // A future-dated transcript is a clock this server cannot reason about. The
234
+ // hint may treat a few seconds of skew as freshness; the gate may not treat
235
+ // ANY amount of it as idleness, because "stale" is the permissive answer here
236
+ // and a wrong clock would hand it out forever.
237
+ if (mtimeMs > nowMs + ACTIVE_RECENTLY_WINDOW_MS) return 'unknown'
238
+ return isActiveRecently(mtimeMs, nowMs) ? 'working' : 'idle'
239
+ }
240
+
241
+ /**
242
+ * Read the transcript clock for a thread, refusing to guess.
243
+ *
244
+ * A probe that is absent, throws, or answers null all land on `unknown`, which
245
+ * refuses. There is no path from a failed reading to a permissive verdict.
246
+ */
247
+ function readHolderActivity(
248
+ provider: OccupancyProvider,
249
+ threadId: string,
250
+ probes: OccupancyProbes,
251
+ nowMs: number,
252
+ ): HolderActivity {
253
+ if (typeof probes.transcriptMtimeMs !== 'function') return 'unknown'
254
+ try {
255
+ return holderActivity(probes.transcriptMtimeMs(provider, threadId), nowMs)
256
+ } catch {
257
+ return 'unknown'
258
+ }
114
259
  }
115
260
 
116
261
  /** A Claude registry filename is exactly `<pid>.json`. Not `*.json`. */
@@ -321,14 +466,84 @@ export interface OccupancyDirs {
321
466
  codexLocksDir: string
322
467
  }
323
468
 
469
+ // ===========================================================================
470
+ // THE IDLE-HOLDER RELAXATION (6.32.0)
471
+ // ===========================================================================
472
+ //
473
+ // This loosens a guard that was deliberate. Read this before touching it, and
474
+ // do not widen it further without repeating the experiment.
475
+ //
476
+ // WHAT CHANGED. A foreign owner used to be terminal on its own. It is now
477
+ // terminal only while that owner is DEMONSTRABLY WRITING. A holder measured
478
+ // idle — registry record alive, transcript stale — is continuable.
479
+ //
480
+ // WHY THE OLD RULE WAS WRONG. The registry records an OPEN WINDOW, not active
481
+ // generation. `~/.claude/sessions/<pid>.json` for a session that finished ten
482
+ // minutes ago is byte-identical to one mid-turn. Miles keeps Claude Code
483
+ // windows open, so Continue was refused for exactly the threads he cares about
484
+ // and allowed only for the ones he had abandoned. The gate was measuring the
485
+ // wrong thing, not measuring it too strictly.
486
+ //
487
+ // THE CANARY, run 2026-08-16 against a real interactive `claude` 2.1.229 held
488
+ // open in tmux (pid 48446, `kind: interactive`, `entrypoint: claude-desktop`,
489
+ // indistinguishable from a desktop window) in a scratch workspace. Turns were
490
+ // injected with the EXACT argv this server spawns. Raw findings:
491
+ //
492
+ // 1. DELIVERY WORKS. `claude -p --resume <id>` into an idle-held thread
493
+ // exits 0 in ~5s and returns its result. Same session id, same file.
494
+ // 2. NO CORRUPTION. Across 6 injections and 6 desktop turns, including
495
+ // three SDK writers landing within 45ms of each other on the same node,
496
+ // the transcript stayed append-only: every prefix byte-identical before
497
+ // and after (`cmp`), zero unparseable lines, zero dangling parentUuid.
498
+ // Claude Code appends whole rows, so byte interleaving does not occur.
499
+ // 3. THE HOLDER IGNORES THE INJECTED TURN — this is the real finding. Its
500
+ // in-memory view is stale and stays stale. Asked afterwards to list every
501
+ // word it had been told to reply with, it answered "ALPHA": the injected
502
+ // "BRAVO" was invisible to it.
503
+ // 4. NOTHING IS CLOBBERED, BUT THE THREAD FORKS. The holder's next turn
504
+ // parented to the PRE-INJECTION tail, making the transcript a tree. Both
505
+ // turns survive in full; they are on different branches, and from then on
506
+ // each writer sees only its own. A later `--resume` read back
507
+ // "ALPHA, BRAVO" and never saw the desktop's "CHARLIE".
508
+ // 5. THE WORKING CASE IS DIFFERENT AND STAYS BLOCKED. With the holder
509
+ // generating, the transcript mtime was 6.2s old and `holderActivity`
510
+ // returned `working`, so this relaxation does not fire there at all.
511
+ //
512
+ // SO THE ANSWER IS: no clobbering, no corruption, no data loss — but the two
513
+ // views diverge silently after the write, and neither side is told by THIS
514
+ // module. What makes that acceptable is the guard one layer up: `nativeHead`
515
+ // digests the transcript tail, and a desktop write CHANGES it (measured:
516
+ // nh1:faba1b35... -> nh1:ace93a1f...), so the next COS turn on that binding is
517
+ // refused with `native_thread_changed` and the user is offered refresh, continue
518
+ // anyway, or fork. Divergence is caught by the CONTENT watermark, which is the
519
+ // signal that can actually see it. Occupancy never could.
520
+ //
521
+ // WHAT THIS IS NOT A LICENCE FOR. Do not extend the same reasoning to the
522
+ // `doubt` reasons below: those mean the scan could not SEE, and an unreadable
523
+ // registry is not an idle holder. Do not relax the `working` branch on the
524
+ // grounds that "nothing got corrupted in the canary either" — case 5 was never
525
+ // run to completion precisely because it stays blocked. And do not reach for
526
+ // `isActiveRecently` here; its `false` means "stale OR unmeasurable", and only
527
+ // `holderActivity` separates those.
528
+ //
529
+ // TURNING IT OFF. Unwire `probes.transcriptMtimeMs`. Since 6.33.0 that happens
530
+ // by unsetting `COS_THREAD_ATTACH_ENABLED`, the one switch that also unregisters
531
+ // the write routes: the relaxation is what makes a silent fork possible, so it
532
+ // is the same decision as Continue itself and no longer has a flag of its own.
533
+ // Every foreign holder then reads `unknown` and refuses, which is the pre-6.32.0
534
+ // gate exactly, and with attach off there is no write route to reach anyway.
535
+
324
536
  /**
325
537
  * The Phase 0 attach precondition.
326
538
  *
327
539
  * Returns attachable ONLY when a supported provider proved its detector exists,
328
- * read every candidate record, and found no foreign owner. Every other outcome
329
- * names why. The whole function is wrapped: a throwing probe — including the
330
- * spawn ledger, which is the most safety-critical of them — is `probe_failed`,
540
+ * read every candidate record, and found no owner it must respect. Every other
541
+ * outcome names why. The whole function is wrapped: a throwing probe — including
542
+ * the spawn ledger, which is the most safety-critical of them — is `probe_failed`,
331
543
  * never an exception escaping into a route.
544
+ *
545
+ * A foreign owner measured IDLE is the one exception, and it is marked
546
+ * `idleHolder` on the way out. See THE IDLE-HOLDER RELAXATION above.
332
547
  */
333
548
  export function threadOccupancy(
334
549
  provider: string,
@@ -358,10 +573,23 @@ export function threadOccupancy(
358
573
 
359
574
  const foreign = result.owners.filter(o => !o.selfOwned)
360
575
  if (foreign.length > 0) {
361
- return { attachable: false, owners: result.owners, reason: 'live_desktop_process' }
576
+ const activity = readHolderActivity(provider, threadId, probes, Date.now())
577
+ if (activity !== 'idle') {
578
+ return {
579
+ attachable: false,
580
+ owners: result.owners,
581
+ reason: activity === 'working' ? 'native_thread_working' : 'live_desktop_process',
582
+ }
583
+ }
584
+ // FALL THROUGH, deliberately. See THE IDLE-HOLDER RELAXATION below for why this is
585
+ // safe and what it is NOT. Everything after this point still applies: a scan
586
+ // that could not establish something still refuses on the next line.
362
587
  }
363
588
  if (result.doubt !== null) {
364
589
  return { attachable: false, owners: result.owners, reason: result.doubt }
365
590
  }
591
+ if (foreign.length > 0) {
592
+ return { attachable: true, owners: result.owners, reason: null, idleHolder: true }
593
+ }
366
594
  return { attachable: true, owners: result.owners, reason: null }
367
595
  }
@@ -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. COS will not write into a thread another app is holding. Fork it instead.',
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,