@gotcos/glasses-server 6.50.3 → 6.51.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
@@ -1,3 +1,21 @@
1
+ ## 6.51.0
2
+
3
+ Codex and Cursor sessions get the Continue and queue that Claude sessions have, each through the engine's own door, and Cursor composers get hook-grounded state. Nothing on a Claude path changes.
4
+
5
+ - **Continue into a Codex thread the Codex app has open.** Before this, a Continue into any thread open in ChatGPT.app (Codex Desktop) could not work: the app's app-server holds the thread's writer lock for as long as the thread is loaded, idle included, so the `codex exec resume` child died in about 0.2 s on "thread already has an active writer" AFTER the prompt was written to its stdin. That is an `ambiguous` turn and a fence (two on record from August, both released by hand). Now, when the gate's own verdict names a foreign writer on a Codex thread, the turn goes through `codex queue --thread=<id> --message=<text>`, which inserts a row into Codex's own queue (`$CODEX_HOME/queue_1.sqlite`). Every app-server watches that database for writes from other processes, so the Codex app runs the message 10 ms after the current turn's `task_complete`, or within about 7 s when the thread is idle, as an ordinary user turn that is visible and editable in the app's own queue. Canary 2026-09-19 against a scratch `CODEX_HOME` and codex-cli 0.155.0-alpha.9.2 (the detail is in `codex-live-queue.ts`). Delivered means exit 0 AND the CLI's confirmation line naming our thread: 200 `completed`, `via: live`, copy "Queued in the Codex app...". A timeout, a kill or an unreadable confirmation might have inserted a row, and nothing can re-check that before a resend, so the turn is `delivery_ambiguous`: ledgered (a replay answers the same) and the thread fenced until a person looks at the Codex app's queue and releases it, like every other unproven delivery. The CLI's own "failed to queue session message", or a clap usage error (a `codex` without `queue`), is `codex_live_unavailable`: nothing was queued, not retryable, and no child is spawned against the held thread. A Codex thread nobody holds takes the 6.50 path unchanged. `COS_CODEX_LIVE_QUEUE=0` restores 6.50 everywhere. The prompt rides in argv for about 50 ms (the CLI has no stdin form: `--message -` queues a literal dash); it is never logged, and the `--message=` form keeps a prompt that starts with a dash as text. The runner kills its whole process group at the 5 s ceiling and answers at the ceiling even if a daemonised grandchild keeps stdout open. `/api/health` gains `codexLiveQueue` next to `continueLive`.
6
+ - **A queued turn on a Codex thread drains at the engine's `task_complete`**, not 30 s later. The B6 clause gains a Codex branch: a Codex holder whose rollout's newest turn marker is `task_complete`, `turn_complete` or `turn_aborted` reads idle at once, with the drain's own tail rule (an aborted Codex turn now ends the turn on the stream and for the drain too; a follow-up used to wait out the 30 min open-turn ceiling). The clause is off with `COS_CODEX_LIVE_QUEUE=0`, so that switch restores 6.50 fully. That is safe for Codex in a way it never was for a Claude Stop, because every write into a held Codex thread now goes through the app's queue, which the engine serialises behind any turn it starts next.
7
+ - **Cursor: a turn can queue for a composer that is mid-turn, and leaves through that composer's own Stop hook.** Cursor 3.21 runs hooks from `~/.claude/settings.json` by default, so the COS session hook installed on 9/15 has been running for Cursor composers since (478 Cursor events in the ledger by 9/18). And Cursor submits a Stop hook's `{"followup_message": ...}` as that composer's next message. So: occupancy reads a composer its hooks show mid-turn as `native_thread_working` (still never attachable: Gate 0 stands); the queue admits a Cursor turn for that reason only (every other Cursor reason stays 423, including ones that are queueable for Claude); the drainer never delivers a Cursor turn; and at the composer's Stop the hook script asks `POST /hooks/cursor/stop-followup` (outside `/api`, hook-token auth) and prints the reply. One turn per Stop, oldest first, claimed before the text leaves, and never for a hook that already hung up (the claim happens only on an open connection); only a `completed` turn gets one (an `aborted` or `error` Stop gets nothing). The hook asks only when the queue folder holds a file for that composer (almost every Stop sends nothing), falls back to asking when the folder is absent, and reads the payload with any JSON spacing. The script's new branch runs only when the environment carries `CURSOR_VERSION` AND the payload carries `"cursor_version"`; a Claude Stop takes the old line and never touches the network. An idle composer fires no Stop, so a turn cannot be put into one; that stays refused. The new script reaches `~/.cos-glasses/bin` through `--hooks install` (the settings entries are unchanged, so `settings.json` is not rewritten).
8
+ - **Cursor rows carry `agent_state` from their own hooks.** A composer whose events reached the signal store gets the same one derivation Claude rows get (running while its turn is open, idle after its Stop), on the list and the detail. A Cursor row with no events keeps the 6.50 shape.
9
+ - Tests: the Codex hop over HTTP (queued via live, replay-safe, held on unverified or a throw, refused without spawning when nothing was queued, 6.50 when disabled, without a holder, or without the dep, never for Claude, the turn clock opening the gate); every verdict rule, the argv, the confirmation, and the runner against real children (argv without a shell, a hang, an escaped grandchild, a missing binary, end to end through a fake `codex`); the Codex turn clock against real rollout lines; the Cursor composer signal (open, closed, ended, stale, throwing, CLI chat untouched, Claude and Codex untouched); the queue route's Cursor rule and the drainer's skip including TTL retirement; the Stop claim over HTTP (one per Stop, aborted gets nothing, Claude Stop and junk get `{}`, token required, only the cursor queue); the hook script run as Cursor runs it (posts and prints, `{}` prints nothing, a Claude Stop mentioning cursor_version never posts, missing env, payload or event sends nothing, a missing token or a closed port is silent and fast); Cursor rows over HTTP. The refusal vocabulary table covers `codex_live_unavailable`. Gate: 30/30 new cases (`codex-live-*`, `route-codex-*`, `codex-clock-*`, `codex-abort-*`, `cursor-*`, `hook-script-*`); `codex-live-timeout-waits-for-close` survived the first draft of the tests and the escaped-grandchild case was added for it.
10
+ - **QA pass (one skeptic, before publish) and the live canary.** Fixed from QA: an unverified Codex hand-off was retryable, so the drainer could insert the same sentence at every turn end (now ambiguous and fenced); a Cursor claim could be marked delivered after the hook had hung up; the Codex turn clock stayed on with the switch off; `turn_aborted` was read differently by the server and COS Control. A second, narrow QA round found the hung-up check was not enough: when the server is busy past the hook's 3 s, curl gives up and closes, but Node reads the request before the close, so the socket still looked open and the turn was claimed for a hook that was gone. A real-curl test (the event loop blocked 2.6 s against a 1 s curl) reproduced it: the row read `delivered`. Now the route claims only within 2 s of the hook's own start stamp (the envelope's `ts`, same Mac clock), so the reply always leaves with a second to spare; a later or unstamped Stop leaves the turn waiting for the next one. The hook also reads `session_id` when a payload has no `conversation_id`, and asks the server rather than skipping when it cannot read an id at all. Live canary on a scratch server (scratch `CODEX_HOME`, throwaway sessions): Codex Continue into a held thread queued in the app and the holder itself ran it 2.6 s later; a working Codex thread refused at attach, the follow-up parked, and the drainer handed it to the app, which ran it 3.8 s after the hand-off; Cursor mid-turn admitted, idle refused, and a Claude-shaped or aborted Stop got nothing. Found upstream, not from this release: Claude Code 2.1.278 HOLDS a peer message (`crossSessionInbound`: "Held peer message, from an unidentified session") when the receiving session runs a permissive mode, so a live Continue into such a session reads `live_unverified` and waits for approval at the desk; the Claude path here is unchanged.
11
+
12
+ ## 6.50.4
13
+
14
+ - **The ring never moves in a way that stops the only recording.** A copy told to yield stops its recording (that is how a duplicate ends). QA of 6.50.3, with glasses 6.9.507 on the phone: reopening COS on a blank phone page mid-meeting (the 2026-09-18 08:15 symptom) gave the ring to the newer copy, so the meeting ended within 15 s and the new window was not recording. The referee now notes every meeting-chunk POST as it passes: a live chunk, or an iPhone offline chunk replayed; the preview does not count. The router is mounted before the transcribe-stream router and only notes, never answers. It records whose chunk it was (`?clientInstance=`, glasses 6.9.509+) or, for an untagged chunk, which device sent it. Two rules follow. (1) While the owner's OWN chunks arrive (30 s window, `CLIENT_INSTANCE_CAPTURE_LIVE_MS`), a newer copy is told to wait, however quiet the owner's claims. A hidden or locked WebView throttles its timers, but chunk uploads follow the audio. The ring moves on the first claim after the meeting stops. (2) A claimant whose own chunks arrive while the owner's do not takes the ring. That covers the case QA found in the first draft of this release, which held the ring on ANY chunk: COS reopened offline, a meeting started on the new copy, and on reconnect that copy's own chunks switched the hold on for the dead copy the Mac still remembered, so its claim was told to yield and the only recording stopped. (3) A claim now says whether its copy is recording (`recording`, glasses 6.9.510+). A recording claimant takes the ring from an owner that is not recording, and an owner's last `recording: true` holds the ring while it keeps claiming (45 s). Chunk arrival alone lags: after an outage the uploader backs off up to 60 s, and only a visible page resets it, which a hidden recording copy never gets. QA round 3 found that gap: a reopened copy that reclaimed offline took the ring back on reconnect, and the recording copy was then told to stop. Untagged chunks (glasses 6.9.507/6.9.508) count for the owner only while it still claims (45 s), so a dead owner is never pinned by them or by a stale flag. When both copies record, the owner keeps the ring and the claimant stops. The answer's `captureLive` means the owner is recording; glasses 6.9.510 refuse a yield while recording when it is `false`. `lastRecordingChunkAt` (first draft) is gone.
15
+ - **One owner per device.** Owners are kept by the request's address (`::ffff:` mapped addresses folded), up to 16 devices, so an Even simulator on the Mac or a second phone on the same token cannot take the ring from the glasses.
16
+ - **A boot time from the future is refused** (400) past an hour ahead of the server clock: a phone clock set ahead would otherwise outrank every later copy.
17
+ - Tests: the rules executed. The owner's chunks hold however quiet its claims, and release after. A recording claimant takes from a dead owner and from a newer owner that is not recording. With both recording, the owner keeps the ring. Untagged chunks hold only up to the owner's 45 s edge. The ledger's window edges, per-device untagged tracking, and bound are covered, and so are what counts as a chunk and what is a valid tag. The QA round 3 replay: a recording copy's own word takes the ring back from a fresher copy that is not recording, then holds it until 45 s after its last claim. The flag follows the latest claim, and only a literal `true` counts. Over HTTP, stub chunk routes are mounted behind the referee as in `index.ts`: the owner's tagged chunk holds; a recording claim takes the ring back and the other copy then gets `captureLive: true`; the QA round-2 replay (dead owner, a new copy's own chunk first) gives that copy the ring; a tagged iPhone offline replay holds; a preview does not; untagged chunks hold, then release past 45 s; a dual-stack server treats `127.0.0.1` and `::1` as two devices; a future boot is a 400. The mount test pins the referee before `transcribeStreamRouter`. Gate: 18/18 referee cases, including `referee-claimed-recording-ignored`, `referee-recording-flag-never-refreshed`, `referee-recording-parsed-loosely`, `referee-owner-recording-flag-ignored`, `referee-stops-the-only-recorder`, `referee-duplicate-claimant-wins`, `referee-dead-owner-pinned-by-untagged`, `referee-untagged-ignored`, `referee-any-copy-chunk-is-the-owners`, `referee-preview-is-a-chunk`, `referee-chunks-unnoted`.
18
+
1
19
  ## 6.50.3
2
20
 
3
21
  - **The server referees between copies of the COS Glasses app.** 2026-09-18 10:06: the Even Realities app kept an older WebView copy of the plugin alive (hidden since at least 09:30) behind the one Miles opened, both answered one Start Meeting to the millisecond, and the same 90-minute meeting was recorded and HQ-polished twice. Glasses 6.9.505's claim in the Even app's storage could not stop it: the new copy read the old copy's claim 36 minutes stale, and the hidden copy never refreshed its claim or read the new one, while its HTTP calls to this server kept working (it uploaded all 827 chunks). `POST /api/client-instance/claim` `{ id, bootAt, version }` answers `{ verdict: 'owner' | 'yield', owner }`: the newest boot that is still posting owns the ring (ties by id); a claim from an older boot takes over only after the owner has been quiet for 45 s (`CLIENT_INSTANCE_LIVE_MS`, three 15 s ticks). `check: true` answers without taking, so a copy that already yielded can ask whether the owner is gone without stealing the ring after a phone lock froze both copies' timers. Behind the API token. In memory: a restart forgets the owner and the next posts re-establish it within a tick. Glasses 6.9.507 asks this route first and falls back to the storage claim when the Mac is unreachable or older.
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # COS session hook (glasses-server 6.48.0). Installed into ~/.claude/settings.json by
2
+ # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0). Installed into ~/.claude/settings.json by
3
3
  # `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
4
4
  # payload on stdin. It spools one JSON file per event into the hook spool and exits 0
5
5
  # on every path. Only PermissionRequest ever talks to the server, and only when the
@@ -23,6 +23,29 @@ T="$SPOOL/.tmp.$$.$TS"; head -c 1048576 > "$T.in" # stdin to
23
23
  case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
24
24
  { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
25
25
  rm -f "$T.in"
26
+ # 6.51.0, CURSOR ONLY. Cursor runs this hook from ~/.claude/settings.json for its own
27
+ # composers, and submits a Stop hook's {"followup_message":...} as that composer's next
28
+ # message. A turn COS queued for the composer is handed over here, at the end of its turn.
29
+ # Claude Code sets no CURSOR_VERSION and sends no cursor_version, so a Claude Stop takes
30
+ # the line below this block exactly as before and never touches the network.
31
+ if [ "$EVENT" = Stop ] && [ -n "${CURSOR_VERSION:-}" ] && grep -Eq '"cursor_version"[[:space:]]*:[[:space:]]*"' "$T" 2>/dev/null; then
32
+ cp "$T" "$T.req" 2>/dev/null; mv "$T" "$SPOOL/$TS-$$-$EVENT.json"
33
+ # Nothing queued for this composer (almost every Stop): no request at all. The queue
34
+ # folder sits beside the spool. If it is not there, or the id cannot be read here,
35
+ # ask anyway rather than guess; the server reads the id the same way (conversation_id,
36
+ # then session_id).
37
+ QDIR="$(dirname "$SPOOL")/thread-turn-queue"
38
+ CONV=$(grep -Eo '"conversation_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$T.req" 2>/dev/null | grep -Eo '[0-9A-Fa-f-]{36}' | head -1 | tr 'A-F' 'a-f')
39
+ [ -n "$CONV" ] || CONV=$(grep -Eo '"session_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$T.req" 2>/dev/null | grep -Eo '[0-9A-Fa-f-]{36}' | head -1 | tr 'A-F' 'a-f')
40
+ if [ -d "$QDIR" ] && [ -n "$CONV" ] && [ ! -f "$QDIR/cursor-$CONV.json" ]; then rm -f "$T.req"; exit 0; fi
41
+ TOK=$(cat "$GH/hook-token" 2>/dev/null); PORT=$(cat "$GH/hook-port" 2>/dev/null || echo 3141)
42
+ if [ -n "$TOK" ] && [ -f "$T.req" ]; then
43
+ OUT=$(curl -s --connect-timeout 1 --max-time 3 -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
44
+ --data-binary @"$T.req" "http://127.0.0.1:$PORT/hooks/cursor/stop-followup") || OUT=''
45
+ case "$OUT" in '{"followup_message"'*) printf '%s' "$OUT" ;; esac
46
+ fi
47
+ rm -f "$T.req"; exit 0
48
+ fi
26
49
  if [ "$EVENT" != PermissionRequest ]; then mv "$T" "$SPOOL/$TS-$$-$EVENT.json"; exit 0; fi # rename = publish
27
50
  # A permission request keeps its own copy: the server unlinks the published file within
28
51
  # milliseconds of the rename, and the request body below must outlive that.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.50.3",
3
+ "version": "6.51.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -34,10 +34,13 @@ import {
34
34
  import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
35
35
  import { targetKey } from './lib/agent-session-binding-store.js'
36
36
  import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
37
- import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
37
+ import { buildOccupancyProbes, realOccupancyDirs, withCodexTurnClock, withCursorComposerTurn, withHookTurnClock } from './lib/occupancy-probes.js'
38
+ import { readCodexTurnEndedAtMs } from './lib/codex-turn-clock.js'
39
+ import { codexLiveQueueEnabled } from './lib/codex-live-queue.js'
38
40
  import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
39
41
  import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
40
42
  import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
43
+ import { makeCodexLiveDeliverer } from './lib/codex-live-queue-deps.js'
41
44
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
42
45
  import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
43
46
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
@@ -131,7 +134,9 @@ import {
131
134
 
132
135
  const app = express()
133
136
  import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
134
- import { queuedThreadKeys, transcriptTurnEnded, transcriptTurnVerdict } from './lib/thread-turn-queue-store.js'
137
+ import { createCursorStopFollowupRouter } from './routes/cursor-stop-followup.js'
138
+ import { queuedThreadKeys, readQueue, transcriptTurnEnded, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
139
+ import { readHookToken } from './lib/claude-hooks-installer.js'
135
140
  import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
136
141
  import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
137
142
  import { transcriptPathFor } from './lib/native-head.js'
@@ -386,12 +391,24 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
386
391
  * Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
387
392
  * RELAXATION before changing this line.
388
393
  */
389
- const occupancyProbes = sessionHooksEnabled()
394
+ const occupancyProbes = withCursorComposerTurn(withCodexTurnClock(sessionHooksEnabled()
390
395
  // 6.48.1, the B6 clause: with the hooks on, a foreign Desktop holder whose newest hook
391
396
  // event is a Stop reads idle at once instead of after the 30 s transcript window. The
392
397
  // wrapper only adds a probe; the gate's precedence is unchanged. Off is 6.48.0.
393
398
  ? withHookTurnClock(buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()), signalFor, registryIdleAfterStop)
394
- : buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
399
+ : buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()),
400
+ // 6.51.0: the Codex counterpart, from the rollout's own turn markers. A queued COS turn
401
+ // on a Codex thread then drains at the engine's `task_complete` instead of 30 s later,
402
+ // and goes out through the app's own queue (`codex-live-queue.ts`).
403
+ // Off with `COS_CODEX_LIVE_QUEUE=0`: the clock only opens the gate because every write
404
+ // into a held thread goes through the app's queue, so without that hop it must not.
405
+ (threadId: string) => (codexLiveQueueEnabled() ? readCodexTurnEndedAtMs(threadId, nativeHeadDeps) : null)),
406
+ // 6.51.0: a Cursor composer its own hooks show mid-turn reads `native_thread_working`,
407
+ // so a turn can queue for its Stop hook. Without the hooks feature there is no signal
408
+ // and every composer keeps the 6.50 verdict.
409
+ (sessionId: string) => (sessionHooksEnabled() ? signalFor(sessionId) : undefined),
410
+ () => Date.now(),
411
+ OPEN_TURN_CEILING_MS)
395
412
 
396
413
  // 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
397
414
  // before any router is registered so the first list request already sees the replayed
@@ -532,6 +549,7 @@ const forkThreadForRoute = (request: {
532
549
  app.use('/api', healthRouter)
533
550
  app.use('/api', diagRouter)
534
551
  // 6.50.3: the referee between copies of the COS Glasses app (routes/client-instance.ts).
552
+ // Mounted BEFORE transcribeStreamRouter: it notes each meeting chunk on its way past.
535
553
  app.use('/api', createClientInstanceRouter())
536
554
  app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
537
555
  prepareAdmission: preparePublicDurableQueryAdmission,
@@ -689,6 +707,14 @@ if (threadAttachEnabled()) {
689
707
  now: () => Date.now(),
690
708
  }
691
709
  app.use('/api', createThreadTurnQueueRouter(queueDeps))
710
+ // 6.51.0: a queued Cursor turn leaves ONLY through the composer's own Stop hook (the
711
+ // drainer skips Cursor, see `drainThread`). Hook-token auth, outside /api.
712
+ app.use(createCursorStopFollowupRouter({
713
+ hookToken: readHookToken,
714
+ readQueue,
715
+ writeQueue,
716
+ now: () => Date.now(),
717
+ }))
692
718
 
693
719
  // Every 20s. Fast enough that a freed thread drains while the user is still looking
694
720
  // at the pending row, slow enough to be nothing: the sweep does no work at all when
@@ -761,6 +787,10 @@ app.use('/api', createAgentSessionBindingsRouter({
761
787
  // the resume child second. Reads `COS_CONTINUE_LIVE` per call, so the flag reports
762
788
  // on /api/health without a restart and every refusal falls back to the 6.48.2 path.
763
789
  deliverLiveTurn: makeLiveTurnDeliverer(nativeHeadDeps),
790
+ // 6.51.0: a Codex thread the Codex app holds goes into that app's own queue
791
+ // (`codex queue`), never into a resume child that cannot open a held thread.
792
+ // `COS_CODEX_LIVE_QUEUE=0` restores the 6.50 path.
793
+ deliverCodexLiveTurn: makeCodexLiveDeliverer(),
764
794
  forkThread: forkThreadForRoute,
765
795
  // The fork's real spawn directory. Separate from `resolveTarget` above, which
766
796
  // deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
@@ -16,11 +16,26 @@
16
16
  export const CLIENT_INSTANCE_TICK_MS = 15_000
17
17
  /** Three missed ticks: the owner is gone. */
18
18
  export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
19
+ /**
20
+ * 6.50.4: a meeting chunk inside this window means that copy is recording. Five chunk
21
+ * intervals (~6 s each), so a stretch of silence or a slow upload does not end the hold.
22
+ */
23
+ export const CLIENT_INSTANCE_CAPTURE_LIVE_MS = 30_000
24
+ /** App copies whose newest chunk is remembered; the least recent is dropped past this. */
25
+ export const CLIENT_INSTANCE_MAX_TAGGED = 64
26
+ /** A boot time this far past the server clock is a phone clock set wrong, not a newer copy. */
27
+ export const CLIENT_INSTANCE_MAX_FUTURE_BOOT_MS = 60 * 60_000
19
28
 
20
29
  export interface ClientInstanceClaim {
21
30
  id: string
22
31
  bootAt: number
23
32
  version: string
33
+ /**
34
+ * The copy says it is recording (glasses 6.9.509+; false when absent). Chunk arrival
35
+ * alone lags: after an outage the uploader backs off up to 60 s, and only a visible page
36
+ * or a display reconnect resets it, which a hidden recording copy never gets (QA round 3).
37
+ */
38
+ recording: boolean
24
39
  }
25
40
 
26
41
  export interface ClientInstanceOwner extends ClientInstanceClaim {
@@ -31,14 +46,74 @@ export type ClientInstanceVerdict = 'owner' | 'yield'
31
46
 
32
47
  const ID_PATTERN = /^[a-z0-9]{1,16}-[a-z0-9]{1,8}$/
33
48
 
49
+ /** The `clientInstance` a chunk POST names (glasses 6.9.509+), or null. Never throws. */
50
+ export function parseInstanceTag(value: unknown): string | null {
51
+ return typeof value === 'string' && ID_PATTERN.test(value) ? value : null
52
+ }
53
+
54
+ /**
55
+ * What the chunk stream says about who is recording, for one claim from one device.
56
+ * `owner` / `claimant`: that copy's OWN tagged chunks arrived inside
57
+ * CLIENT_INSTANCE_CAPTURE_LIVE_MS. `untagged`: chunks without a tag (glasses before 6.9.509)
58
+ * arrived from this device, so they could be anyone's.
59
+ */
60
+ export interface CaptureEvidence {
61
+ owner: boolean
62
+ claimant: boolean
63
+ untagged: boolean
64
+ }
65
+
66
+ export const NO_CAPTURE: CaptureEvidence = { owner: false, claimant: false, untagged: false }
67
+
68
+ /**
69
+ * Newest chunk arrival per tagged app copy, and per device for untagged chunks. The clock
70
+ * is passed in. Bounded: a copy that stopped sending is forgotten once 64 newer ones have.
71
+ */
72
+ export class ClientChunkLedger {
73
+ private readonly tagged = new Map<string, number>()
74
+ private readonly untagged = new Map<string, number>()
75
+
76
+ note(device: string, instance: string | null, at: number): void {
77
+ const map = instance ? this.tagged : this.untagged
78
+ const key = instance ?? device
79
+ map.delete(key)
80
+ map.set(key, at)
81
+ while (map.size > CLIENT_INSTANCE_MAX_TAGGED) map.delete(map.keys().next().value as string)
82
+ }
83
+
84
+ evidence(device: string, ownerId: string, claimId: string, now: number): CaptureEvidence {
85
+ const live = (at: number | undefined) => at !== undefined && now - at < CLIENT_INSTANCE_CAPTURE_LIVE_MS
86
+ return {
87
+ owner: live(this.tagged.get(ownerId)),
88
+ claimant: live(this.tagged.get(claimId)),
89
+ untagged: live(this.untagged.get(device)),
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Whether the owner is recording. Its own tagged chunks are proof however quiet its claims
96
+ * (a hidden or locked WebView throttles its timers; chunk uploads follow the audio). Its
97
+ * own last claim saying `recording`, and untagged chunks from the device (which could be
98
+ * anyone's), count only while it is still claiming: a dead owner must never be pinned by
99
+ * a stale flag or by the chunks of the copy that replaced it (QA round 2).
100
+ */
101
+ export function ownerIsRecording(owner: ClientInstanceOwner, evidence: CaptureEvidence, now: number): boolean {
102
+ if (evidence.owner) return true
103
+ return (owner.recording || evidence.untagged) && now - owner.seenAt <= CLIENT_INSTANCE_LIVE_MS
104
+ }
105
+
34
106
  /** A body the route may act on, or null. Never throws. */
35
- export function parseClientInstanceClaim(body: unknown): ClientInstanceClaim | null {
107
+ export function parseClientInstanceClaim(body: unknown, now = Date.now()): ClientInstanceClaim | null {
36
108
  if (!body || typeof body !== 'object') return null
37
109
  const o = body as Record<string, unknown>
38
110
  if (typeof o.id !== 'string' || !ID_PATTERN.test(o.id)) return null
39
111
  if (typeof o.bootAt !== 'number' || !Number.isFinite(o.bootAt) || o.bootAt <= 0) return null
112
+ // A copy that booted with the phone clock set ahead would outrank every later copy for
113
+ // as long as the clock stayed wrong (QA, 6.50.3).
114
+ if (o.bootAt > now + CLIENT_INSTANCE_MAX_FUTURE_BOOT_MS) return null
40
115
  const version = typeof o.version === 'string' ? o.version.slice(0, 32) : ''
41
- return { id: o.id, bootAt: o.bootAt, version }
116
+ return { id: o.id, bootAt: o.bootAt, version, recording: o.recording === true }
42
117
  }
43
118
 
44
119
  function isNewer(a: ClientInstanceClaim, b: ClientInstanceClaim): boolean {
@@ -50,10 +125,22 @@ export function arbitrateClientInstance(
50
125
  owner: ClientInstanceOwner | null,
51
126
  claim: ClientInstanceClaim,
52
127
  now: number,
128
+ evidence: CaptureEvidence = NO_CAPTURE,
53
129
  ): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
54
130
  const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
55
131
  if (!owner) return { owner: fresh, verdict: 'owner', took: true }
56
- if (owner.id === claim.id) return { owner: { ...owner, seenAt: now }, verdict: 'owner', took: false }
132
+ if (owner.id === claim.id) return { owner: { ...owner, seenAt: now, recording: claim.recording }, verdict: 'owner', took: false }
133
+ // 6.50.4: the yielding copy stops its recording (that is how a duplicate ends), so the
134
+ // ring must never move in a way that stops the only recording.
135
+ // (1) The claimant is recording and the owner is not: the claimant is the meeting.
136
+ // Reopened offline, the Mac still names the dead copy from before; when signal returns,
137
+ // the new copy's chunks land first and its claim must not be told to stop (QA round 2).
138
+ // Its chunks or its own word: after an outage the uploads lag the claims (QA round 3).
139
+ if ((evidence.claimant || claim.recording) && !ownerIsRecording(owner, evidence, now)) return { owner: fresh, verdict: 'owner', took: true }
140
+ // (2) The owner is recording: a newer copy waits, however quiet the owner's claims.
141
+ // Reopening COS on a blank phone page mid-meeting ended the meeting on 6.50.3 (QA).
142
+ // The ring moves on the newer copy's first claim after the meeting stops.
143
+ if (ownerIsRecording(owner, evidence, now)) return { owner, verdict: 'yield', took: false }
57
144
  if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
58
145
  if (now - owner.seenAt > CLIENT_INSTANCE_LIVE_MS) return { owner: fresh, verdict: 'owner', took: true }
59
146
  return { owner, verdict: 'yield', took: false }
@@ -0,0 +1,135 @@
1
+ // The real world behind `codex-live-queue.ts`: the binary, the process, the counters.
2
+ //
3
+ // Kept apart from the pure module so the verdict rules there never touch a process.
4
+ // Nothing here writes a file; the only write is the one `codex queue` makes itself.
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import { resolveProviderBinary } from './provider-binary.js'
8
+ import {
9
+ codexLiveQueueEnabled,
10
+ deliverOverCodexQueue,
11
+ type CodexLiveResult,
12
+ type CodexQueueRun,
13
+ } from './codex-live-queue.js'
14
+
15
+ /** Output past this is not a confirmation line; keep memory bounded if the CLI misbehaves. */
16
+ const MAX_CAPTURE_BYTES = 64 * 1024
17
+
18
+ /**
19
+ * Run the CLI with no shell, a closed stdin and a hard timeout. Always resolves.
20
+ *
21
+ * The environment is the server's own, so `CODEX_HOME` (if an operator set one) is the
22
+ * same one occupancy read the writer lock from: the queue row lands in the database the
23
+ * holder is watching.
24
+ */
25
+ export function runCodexQueueCli(binary: string, args: readonly string[], timeoutMs: number): Promise<CodexQueueRun> {
26
+ return new Promise(resolve => {
27
+ let stdout = ''
28
+ let stderr = ''
29
+ let timedOut = false
30
+ let settled = false
31
+ const finish = (run: CodexQueueRun) => {
32
+ if (settled) return
33
+ settled = true
34
+ resolve(run)
35
+ }
36
+ let child: ReturnType<typeof spawn>
37
+ try {
38
+ // Its own process group, so the ceiling can kill anything it started too: a
39
+ // grandchild holding stdout open would otherwise keep 'close' from ever firing
40
+ // (found by the hang test, which waited out the whole `sleep`).
41
+ child = spawn(binary, [...args], { stdio: ['ignore', 'pipe', 'pipe'], shell: false, detached: true })
42
+ } catch (error) {
43
+ finish({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: error instanceof Error ? error.message : 'spawn threw' })
44
+ return
45
+ }
46
+ const timer = setTimeout(() => {
47
+ timedOut = true
48
+ try { if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGKILL') } catch { /* group already gone */ }
49
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
50
+ // Answer at the ceiling, not whenever the pipes finally close.
51
+ finish({ code: null, stdout, stderr, timedOut: true, spawnError: null })
52
+ }, Math.max(100, timeoutMs))
53
+ timer.unref?.()
54
+ child.stdout?.on('data', (chunk: Buffer) => { if (stdout.length < MAX_CAPTURE_BYTES) stdout += chunk.toString('utf-8') })
55
+ child.stderr?.on('data', (chunk: Buffer) => { if (stderr.length < MAX_CAPTURE_BYTES) stderr += chunk.toString('utf-8') })
56
+ child.on('error', error => {
57
+ clearTimeout(timer)
58
+ // ENOENT/EACCES arrive here before any byte ran: nothing was queued.
59
+ finish({ code: null, stdout, stderr, timedOut: false, spawnError: error.message })
60
+ })
61
+ child.on('close', code => {
62
+ clearTimeout(timer)
63
+ finish({ code: timedOut ? null : code, stdout, stderr, timedOut, spawnError: null })
64
+ })
65
+ })
66
+ }
67
+
68
+ export interface CodexLiveStats {
69
+ attempts: number
70
+ delivered: number
71
+ /** By reason, so the health row says WHY a Codex Continue did not go live. */
72
+ fallbacks: Record<string, number>
73
+ lastReason: string | null
74
+ lastAt: number | null
75
+ }
76
+
77
+ const stats: CodexLiveStats = { attempts: 0, delivered: 0, fallbacks: {}, lastReason: null, lastAt: null }
78
+
79
+ /** For `/api/health`. A copy, never the live object. */
80
+ export function codexLiveStats(): CodexLiveStats {
81
+ return { ...stats, fallbacks: { ...stats.fallbacks } }
82
+ }
83
+
84
+ /** Tests only. */
85
+ export function __resetCodexLiveStatsForTests(): void {
86
+ stats.attempts = 0
87
+ stats.delivered = 0
88
+ stats.fallbacks = {}
89
+ stats.lastReason = null
90
+ stats.lastAt = null
91
+ }
92
+
93
+ /**
94
+ * The route's Codex dep. `disabled` and `no_holder` are not counted as attempts: the
95
+ * first is the operator's choice and the second is the ordinary spawn path, so neither
96
+ * should make the health row look busy.
97
+ */
98
+ export function makeCodexLiveDeliverer(deps?: {
99
+ run?: typeof runCodexQueueCli
100
+ resolveBinary?: () => string | null
101
+ enabled?: () => boolean
102
+ }) {
103
+ return async (request: { provider: string; sessionId: string; prompt: string; foreignHolder?: boolean }): Promise<CodexLiveResult> => {
104
+ const result = await deliverOverCodexQueue(
105
+ {
106
+ provider: request.provider,
107
+ sessionId: request.sessionId,
108
+ prompt: request.prompt,
109
+ enabled: (deps?.enabled ?? codexLiveQueueEnabled)(),
110
+ foreignHolder: request.foreignHolder === true,
111
+ },
112
+ {
113
+ resolveBinary: deps?.resolveBinary ?? (() => {
114
+ const resolved = resolveProviderBinary('codex')
115
+ return resolved.ok ? resolved.path : null
116
+ }),
117
+ run: deps?.run ?? runCodexQueueCli,
118
+ now: () => Date.now(),
119
+ log: (level, event, detail) => {
120
+ const line = `[${event}] ${JSON.stringify(detail)}`
121
+ if (level === 'warn') console.warn(line)
122
+ else console.log(line)
123
+ },
124
+ },
125
+ )
126
+ if (result.reason !== 'disabled' && result.reason !== 'no_holder') {
127
+ stats.attempts += 1
128
+ if (result.ok) stats.delivered += 1
129
+ else stats.fallbacks[result.reason] = (stats.fallbacks[result.reason] ?? 0) + 1
130
+ stats.lastReason = result.reason
131
+ stats.lastAt = Date.now()
132
+ }
133
+ return result
134
+ }
135
+ }