@gotcos/glasses-server 6.49.0 → 6.49.1

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,18 @@
1
+ ## 6.49.1
2
+
3
+ Where a turn's time goes, and the two things the /qa pass on 6.49.0 found that made a follow-up wait or repeat.
4
+
5
+ - **A lease yields.** A Continue binding held its thread for the whole 30 min TTL after its turn completed, and every client attaches per send (the phone on every Send, the queue drainer on every drain, the lens on every hold), so the second follow-up on a thread within half an hour was parked behind a binding nobody was using. Measured 2026-09-16: three phone follow-ups queued 17:19-17:28 landed at 17:52, the minute after the lens's binding expired, with the session idle throughout. Now an unpinned binding yields 90 s after its last turn ended (`IDLE_LEASE_YIELD_MS`): the next attach supersedes it in the same registry mutation, so the thread never has two live bindings and a late turn on the old one reads `binding_detached`. A binding with a turn in flight, or younger than 90 s (the lens attaches before the wearer dictates), still refuses `native_target_busy`. The queue drainer's `attachable` probe uses the same predicate, so it never holds behind a lease the attach would supersede.
6
+ - **The turn ledger is per thread, not per binding.** A draft's idempotency key (app 6.9.487/488) can arrive through a new binding: the phone re-attaches on every Send, and a draft parked after `target_busy` drains through the drainer's own binding. `findTurn` read only that binding's rows, so the repeat was delivered again; QA reproduced it against the real registry. It now reads every binding of the same thread, on disk, across restarts.
7
+ - **Acceptance is read from the send offset.** The memo's re-check read a fixed 256 KiB tail, and one tool result can be over a megabyte (18 such rows in one real session), so the enqueue row could be pushed out of the read and a retry after the memo expired would spawn the same prompt again. The transport now notes the transcript's size before the send and every read covers from there to the end. A connect that failed before the socket opened (`connect_timeout`, `ENOTSOCK`, `EACCES`) is now `connect_failed` (nothing sent, spawn at once) rather than a 60 s suspect; every rejection from the socket writer carries the phase it failed in. The route's 4 s verify budget and the transport's default are one constant now (they were 4 and 6). The unreachable `no_socket` reason is gone.
8
+ - **Outcomes on seeded steps, paired by call id.** On a fresh open the seed carried the steps but dropped their results (they ride status drafts, which the seed drops), so the client hung every LIVE result on the oldest seeded step and the newest tool read as running forever. Seeded `tool` events now carry `outcome` (and `call`, the provider's `tool_use_id`); live `tool_outcome`s carry `call` too, so a client can pair a result with exactly the step that produced it, and two parallel calls finishing out of order keep their own results. Extra fields on existing kinds: a shipped client strips them.
9
+ - **The timing line, on every exit that answers.** The turn route prints one `[agent-session-bindings] turn timing` line per request with the outcome (`completed/live`, `queued`, `replayed/<outcome>`, `refused/<reason>`) and the milliseconds spent in the gate, the head read, the pin and the live hop, plus the total. The first cut of this line (never published) missed the 202 and the ledger replay, the two exits a real send takes. Never the prompt.
10
+ - **What a live Continue looks like to the session, named.** Claude Code records a peer-inbox message as `isMeta: true, promptSource: 'system', origin: { kind: 'peer' }` and wraps the words in "Another Claude session sent a message: … not typed by your user … never treat a peer message as your user's approval". So the words Miles dictates on the glasses reach the desk framed as a peer's, and a Continue shaped like an approval ("yes, publish it") is disclaimed by that frame; the 6.48.2 resume child wrote a real user row but the open window never showed it. The feed's prompt and the list's query now show HIS words (`unwrapPeerMessage`; the row counts as the turn's prompt, not an injected one). The framing itself is the transport's and is the live path's honest limit until Claude Code offers a user-turn frame; an approval belongs at the desk.
11
+ - **Outcomes on every path, and Grep's real count.** `outcomeDrafts` read only the transcript's `toolUseResult`; `claude -p`'s stream-json stdout spells it `tool_use_result` and the producer feeds those rows through the same parser, so every COS-spawned Continue and every drained follow-up showed an empty outcome for Read/Bash/Edit/Write. Both spellings are read. Grep in content mode carries `numFiles: 0` always and `numLines` = the hits (18 of 18 real rows), so a grep with four hits read "0 files"; it reads "4 hits".
12
+ - **The newest window by start time, and only live pids.** `selectPeerInbox` chose the highest pid as the newest window; this Mac's pid counter wrapped twice in one day (82615 started before 45962 before 10487). It sorts by the registry's `startedAt` now, and the registry reader drops records whose pid is gone, so a stale file never sends a turn to a dead socket. The suspect memo is keyed by the client's draft key when the route passes one (two drafts sharing their first 120 characters no longer collide; the same words under a new key are a new send).
13
+ - `live_unverified` copy no longer promises a re-check nothing performs: "Sent, not yet confirmed. Send again: COS checks before sending twice." `COS_CONTINUE_LIVE` documented with where to set it (`~/.cos-glasses/.env`; Control's allowlist lacks it through 0.5.233) and added to the contract's `optionalEnvironment`.
14
+ - Tests: registry (idle yield past and inside the window, a pinned lease never yields, the ledger across bindings and across a restart), the transport (connect-phase classification, reads from the send offset on the first look and the memo re-check, the backdate boundary, the real unix socket byte-for-byte), the route (the budget reaches the transport; one timing line on the 202, the replay, the live 200 and a refusal; never the prompt), the seed fold (by call id out of order, by order, never twice) and the stream route seeding an outcome on a finished step; the peer frame unwrapped on the stream and in the list (the real row from 2026-09-16). Mutation gate: seventeen new cases on a green baseline. New test file `session-peer-inbox-deps.test.ts` for the registry and key readers, which had none.
15
+
1
16
  ## 6.49.0
2
17
 
3
18
  A Continue lands in the session you left open, and the live feed says what every step produced.
package/README.md CHANGED
@@ -212,7 +212,18 @@ when the session's transcript shows the message accepted; every other outcome ta
212
212
  acceptance row does the resume child run. `/api/health` reports `continueLive`
213
213
  (enabled, attempts, delivered, fallbacks by reason). The live stream also carries a
214
214
  `tool_outcome` on the status draft that follows each tool result (`+14 -2`,
215
- `235 lines`, `41 passed`, `exit 1`), which a client that predates it takes as a no-op.
215
+ `235 lines`, `41 lines`, `exit 1`), which a client that predates it takes as a no-op.
216
+ **Where to set the flag:** `~/.cos-glasses/.env` (read at boot; the plist wins when
217
+ both carry it). COS Control's allowlist does not carry `COS_CONTINUE_LIVE` through
218
+ 0.5.233, so a value set only in the LaunchAgent plist is dropped by the next
219
+ Install/Repair/Update Server; Control 0.5.234 is to add it.
220
+ Since 6.49.1 a lease yields: a completed, unpinned Continue binding stops holding its
221
+ thread 90 s after its turn ends (it held it for the whole 30 min TTL before), so a
222
+ follow-up from another surface, or the queue drainer, attaches at once instead of
223
+ waiting out the lease; a binding with a turn in flight still refuses `native_target_busy`.
224
+ The turn ledger is read across every binding of a thread, so a draft re-sent through a
225
+ new binding (the phone attaches per send; a parked draft drains through the drainer's
226
+ own) replays rather than repeats.
216
227
 
217
228
  ## Configuration
218
229
 
@@ -28,6 +28,7 @@
28
28
  "COS_CODEX_SANDBOX",
29
29
  "COS_SCRIPTS_DIR",
30
30
  "COS_SESSION_HOOKS",
31
+ "COS_CONTINUE_LIVE",
31
32
  "COS_SESSION_HOOKS_SPOOL_DIR",
32
33
  "COS_SESSION_HOOK_SSE",
33
34
  "COS_PERMISSION_BROKER",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.49.0",
3
+ "version": "6.49.1",
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
@@ -610,11 +610,15 @@ if (threadAttachEnabled()) {
610
610
  try {
611
611
  const v = threadOccupancy(provider, threadId, occupancyProbes, occupancyDirs)
612
612
  if (v.attachable !== true) return { attachable: false, reason: v.reason ?? null }
613
- // Occupancy is happy; ask the registry the question it cannot answer.
614
- // `getByThread` returns the binding ONLY when it actually blocks the target
615
- // (`blocksTarget`), so an expired or terminal one correctly reads as free.
616
- const holder = agentSessionBindingRegistry.getByThread(provider, threadId, Date.now())
617
- if (holder !== null) return { attachable: false, reason: 'native_target_busy' }
613
+ // Occupancy is happy; ask the registry the question it cannot answer, with
614
+ // the SAME predicate the attach route's `create` applies (6.49.1): a pinned
615
+ // holder, or one touched within `IDLE_LEASE_YIELD_MS`, is busy; an idle lease
616
+ // past that is one the attach will supersede, so the drainer must not hold
617
+ // behind it. Until 6.49.0 this read `getByThread` (any live lease blocks), and a
618
+ // follow-up queued after a completed Continue waited out the full 30 min TTL.
619
+ if (agentSessionBindingRegistry.attachBlocked(provider, threadId, Date.now())) {
620
+ return { attachable: false, reason: 'native_target_busy' }
621
+ }
618
622
  // THE FENCE, for the same reason the binding is here and not one layer down.
619
623
  // This is ADVISORY: the attach route stays the authority that refuses. Saying
620
624
  // it here only means the drainer holds instead of spending an attempt, and a
@@ -147,6 +147,26 @@ export const DEFAULT_STALE_PIN_MS = 24 * 60 * 60 * 1000
147
147
  */
148
148
  export const PIN_JOB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/
149
149
 
150
+ /**
151
+ * How long an UNPINNED lease keeps its target against a new attach (6.49.1).
152
+ *
153
+ * Until 6.49.0 a binding held its target for the whole TTL (30 min) after its turn
154
+ * completed, and every client attaches per send: the phone on every Send, the queue
155
+ * drainer on every drain, the lens on every hold. So the SECOND follow-up on a thread
156
+ * within half an hour was parked behind a binding nobody was using. Measured
157
+ * 2026-09-16: three phone follow-ups queued 17:19-17:28 landed at 17:52, the minute
158
+ * after the lens's binding expired, with the session idle the whole time.
159
+ *
160
+ * A PINNED holder (a turn in flight) still refuses: that is the two-writer hazard the
161
+ * lease exists for. An unpinned holder younger than this still refuses too, because
162
+ * the lens attaches BEFORE the wearer dictates and posts the turn after; superseding
163
+ * it mid-dictation would turn the send into `binding_detached`. Past this age the
164
+ * holder yields: it is detached in the same mutation that creates its successor, so
165
+ * the target never has two live bindings, a late turn on it reads `binding_detached`,
166
+ * and its turn ledger stays readable for a replay (see `findTurn`).
167
+ */
168
+ export const IDLE_LEASE_YIELD_MS = 90_000
169
+
150
170
  export type RegistryRejection =
151
171
  | BindingRejection
152
172
  | 'store_unavailable'
@@ -735,7 +755,10 @@ export class AgentSessionBindingRegistry {
735
755
 
736
756
  const holderId = this.targets.get(key)
737
757
  const holder = holderId === undefined ? undefined : this.records.get(holderId)
738
- if (holder && blocksTarget(holder.binding, now)) return reject('target_busy')
758
+ if (holder && holderBlocksAttach(holder, now)) return reject('target_busy')
759
+ // An idle lease past `IDLE_LEASE_YIELD_MS` yields (6.49.1): detached below, in
760
+ // this same mutation, so the target never carries two live bindings.
761
+ const superseded = holder && blocksTarget(holder.binding, now) ? holder : null
739
762
 
740
763
  const floor = this.floors.get(key) ?? 0
741
764
  if (!this.floors.has(key) && this.floors.size >= this.maxEpochTargets) {
@@ -766,6 +789,14 @@ export class AgentSessionBindingRegistry {
766
789
  // `binding_expired` / `binding_detached` rather than `unknown_binding`) but
767
790
  // gives up the target.
768
791
  if (holderId !== undefined) targets.delete(key)
792
+ if (superseded) {
793
+ records.set(superseded.binding.bindingId, {
794
+ ...superseded,
795
+ binding: freezeBinding(forceDetachBinding(superseded.binding)),
796
+ updatedAt: now,
797
+ terminalAt: superseded.terminalAt ?? now,
798
+ })
799
+ }
769
800
 
770
801
  if (records.size >= this.maxRetainedBindings) {
771
802
  const freed = evictOldestDisposable(records, targets, now, records.size - this.maxRetainedBindings + 1)
@@ -917,9 +948,41 @@ export class AgentSessionBindingRegistry {
917
948
  findTurn(bindingId: unknown, turnId: unknown): TurnRecord | null {
918
949
  if (typeof bindingId !== 'string' || typeof turnId !== 'string') return null
919
950
  if (!TURN_ID_RE.test(turnId)) return null
920
- const rows = this.turns.get(bindingId)
921
- if (!rows) return null
922
- return rows.find(row => row.turnId === turnId) ?? null
951
+ const own = this.turns.get(bindingId)?.find(row => row.turnId === turnId) ?? null
952
+ if (own) return own
953
+ // 6.49.1: the key is per DRAFT (app 6.9.487/488), and a re-sent draft can arrive
954
+ // through a NEW binding on the same target: the phone attaches on every Send, and
955
+ // a draft parked after `target_busy` is drained through a binding of the drainer's
956
+ // own. A ledger scoped to one binding answered null there and the prompt was
957
+ // delivered a second time (QA, 2026-09-16, reproduced against this registry). The
958
+ // target is the unit the human sees, so the ledger is read across every binding
959
+ // that ever held it.
960
+ const record = this.records.get(bindingId)
961
+ if (!record) return null
962
+ const key = record.binding.targetKey
963
+ for (const [id, other] of this.records) {
964
+ if (id === bindingId || other.binding.targetKey !== key) continue
965
+ const hit = this.turns.get(id)?.find(row => row.turnId === turnId)
966
+ if (hit) return hit
967
+ }
968
+ return null
969
+ }
970
+
971
+ /**
972
+ * Would a new attach to this thread be refused `target_busy` right now?
973
+ *
974
+ * ONE predicate for the attach path and for the queue drainer's `attachable`
975
+ * probe, so the drainer never holds a turn behind a lease that `create` would
976
+ * supersede, and never attempts one that `create` would refuse. See
977
+ * `IDLE_LEASE_YIELD_MS`.
978
+ */
979
+ attachBlocked(provider: unknown, nativeThreadId: unknown, now: number): boolean {
980
+ if (!this.available()) return false
981
+ if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId)) return false
982
+ const id = this.targets.get(makeTargetKey(provider, nativeThreadId))
983
+ if (id === undefined) return false
984
+ const record = this.records.get(id)
985
+ return record ? holderBlocksAttach(record, now) : false
923
986
  }
924
987
 
925
988
  /**
@@ -1201,6 +1264,17 @@ function blocksTarget(binding: NativeBinding, now: number): boolean {
1201
1264
  return !isExpired(binding, now)
1202
1265
  }
1203
1266
 
1267
+ /**
1268
+ * Does this holder refuse a new attach? Pinned: always. Unpinned and alive: only
1269
+ * while younger than `IDLE_LEASE_YIELD_MS` (`updatedAt` moves on every pin and
1270
+ * unpin, so this is "since its last turn ended, or since it attached").
1271
+ */
1272
+ function holderBlocksAttach(record: BindingRecord, now: number): boolean {
1273
+ if (!blocksTarget(record.binding, now)) return false
1274
+ if (isPinned(record.binding)) return true
1275
+ return !(Number.isFinite(now) && now - record.updatedAt >= IDLE_LEASE_YIELD_MS)
1276
+ }
1277
+
1204
1278
  /** Evict retained-but-dead records, oldest first. Never touches a blocking one. */
1205
1279
  function evictOldestDisposable(
1206
1280
  records: Map<string, BindingRecord>,
@@ -11,6 +11,7 @@ import { homedir } from 'node:os'
11
11
  import { join } from 'node:path'
12
12
  import { createInterface } from 'node:readline'
13
13
  import { promisify } from 'node:util'
14
+ import { unwrapPeerMessage } from './session-stream-events.js'
14
15
 
15
16
  const execFileAsync = promisify(execFile)
16
17
 
@@ -1740,9 +1741,15 @@ export async function parseAgentSession(
1740
1741
  }
1741
1742
  if (obj.type === 'user') {
1742
1743
  userCount += 1
1743
- collectTurn(text, tail, obj.isMeta === true || obj.isCompactSummary === true)
1744
- if (!firstPrompt) firstPrompt = firstLineTitle(text)
1745
- if (!title) title = firstLineTitle(text)
1744
+ // 6.49.1: a live Continue arrives as a peer-inbox row (`isMeta`, `origin.kind`
1745
+ // 'peer') wrapped in Claude Code's "Another Claude session sent a message"
1746
+ // frame. It IS the user's prompt (dictated on the glasses), so it is the
1747
+ // turn's query and never an injected row; the frame is stripped.
1748
+ const peer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? unwrapPeerMessage(text) : null
1749
+ const query = peer ?? text
1750
+ collectTurn(query, tail, peer === null && (obj.isMeta === true || obj.isCompactSummary === true))
1751
+ if (!firstPrompt) firstPrompt = firstLineTitle(query)
1752
+ if (!title) title = firstLineTitle(query)
1746
1753
  } else {
1747
1754
  assistantCount += 1
1748
1755
  rememberAssistant(text, tail)
@@ -5,7 +5,7 @@
5
5
  // this file is the ONE place that knows where Claude Code keeps these things. All of
6
6
  // it is read-only: nothing here writes to `~/.claude`.
7
7
 
8
- import { lstatSync, readdirSync, readFileSync } from 'node:fs'
8
+ import { lstatSync, readdirSync, readFileSync, statSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
10
  import { claudeSessionsDir, REGISTRY_FILENAME } from './claude-session-registry.js'
11
11
  import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
@@ -24,7 +24,12 @@ import {
24
24
  * The transport rides a protocol Claude Code documents in a help string, not a
25
25
  * contract, and the first days are for reading the health row on one Mac. A literal
26
26
  * '1' turns it on; anything else, including absence, keeps every Continue on the
27
- * 6.48.2 path. Control's env allowlist carries the key so Update Server keeps it.
27
+ * 6.48.2 path.
28
+ *
29
+ * WHERE TO SET IT. `~/.cos-glasses/.env` (read at boot by `env.ts`, the plist wins
30
+ * when both carry it). Control's `providerEnvironmentKeys` allowlist does NOT carry
31
+ * this key through 0.5.233, so a value set only in the LaunchAgent plist is dropped
32
+ * by the next Install/Repair/Update Server; Control 0.5.234 is to add it.
28
33
  */
29
34
  export function continueLiveEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
30
35
  return env.COS_CONTINUE_LIVE === '1'
@@ -34,11 +39,23 @@ export function continueLiveEnabled(env: NodeJS.ProcessEnv = process.env): boole
34
39
  const MAX_RECORD_BYTES = 64 * 1024
35
40
  /** `<pid>.<64 hex>.key`, the sibling of `<pid>.json`. */
36
41
  const KEY_FILENAME = /^(\d+)\.[0-9a-f]{64}\.key$/
37
- /** Enough of the tail to hold the acceptance row for any prompt we could have sent. */
42
+ /** The default tail when the caller cannot say how much was appended since its send.
43
+ * The caller normally can (`transcriptSize` below), and then asks for at least the
44
+ * bytes written since plus slack, so a megabyte tool result landing after the
45
+ * acceptance row cannot push the row out of the read. */
38
46
  const ACCEPTANCE_TAIL_BYTES = 256 * 1024
47
+ /** Never read more than this in one verification, whatever was appended. */
48
+ const ACCEPTANCE_TAIL_MAX_BYTES = 16 * 1024 * 1024
49
+
50
+ /** Is a process with this pid alive (signal 0)? EPERM counts as alive: it exists, it is not ours. */
51
+ export function pidAlive(pid: number): boolean {
52
+ try { process.kill(pid, 0); return true } catch (error) {
53
+ return (error as NodeJS.ErrnoException | undefined)?.code === 'EPERM'
54
+ }
55
+ }
39
56
 
40
- /** One pass over `~/.claude/sessions`, records only. Never throws. */
41
- export function readPeerInboxRecords(dir: string = claudeSessionsDir()): PeerInboxRecord[] {
57
+ /** One pass over `~/.claude/sessions`, live records only. Never throws. */
58
+ export function readPeerInboxRecords(dir: string = claudeSessionsDir(), alive: (pid: number) => boolean = pidAlive): PeerInboxRecord[] {
42
59
  let names: string[]
43
60
  try { names = readdirSync(dir) } catch { return [] }
44
61
  const out: PeerInboxRecord[] = []
@@ -53,6 +70,10 @@ export function readPeerInboxRecords(dir: string = claudeSessionsDir()): PeerInb
53
70
  const pid = Number(raw.pid)
54
71
  const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId.trim().toLowerCase() : ''
55
72
  if (!Number.isInteger(pid) || pid <= 0 || !sessionId) continue
73
+ // Liveness (6.49.1): a record whose pid is gone is a stale file the reaper has not
74
+ // reached; choosing it would send the turn to a dead socket and then to the spawn
75
+ // path, silently, on a session that may have a live window under another pid.
76
+ if (!alive(pid)) continue
56
77
  out.push({
57
78
  pid,
58
79
  sessionId,
@@ -60,6 +81,7 @@ export function readPeerInboxRecords(dir: string = claudeSessionsDir()): PeerInb
60
81
  peerProtocol: typeof raw.peerProtocol === 'number' && Number.isInteger(raw.peerProtocol) ? raw.peerProtocol : null,
61
82
  entrypoint: typeof raw.entrypoint === 'string' ? raw.entrypoint : null,
62
83
  status: typeof raw.status === 'string' ? raw.status : null,
84
+ startedAt: typeof raw.startedAt === 'number' && Number.isFinite(raw.startedAt) ? raw.startedAt : null,
63
85
  })
64
86
  } catch {
65
87
  // A torn read or a record the reaper just unlinked. Ordinary.
@@ -95,10 +117,19 @@ export function readPeerToken(record: PeerInboxRecord, dir: string = claudeSessi
95
117
  return null
96
118
  }
97
119
 
98
- /** The newest rows of the session's transcript, parsed; [] when there is no file. */
99
- export function readAcceptanceTail(sessionId: string, nativeHeadDeps: NativeHeadDeps): TranscriptRowLike[] {
120
+ /** The transcript's size in bytes, or null when there is no file. */
121
+ export function readTranscriptSize(sessionId: string, nativeHeadDeps: NativeHeadDeps): number | null {
122
+ const path = transcriptPathFor('claude', sessionId, nativeHeadDeps)
123
+ if (!path) return null
124
+ try { return statSync(path).size } catch { return null }
125
+ }
126
+
127
+ /** The newest rows of the session's transcript, parsed; [] when there is no file.
128
+ * `minBytes` widens the read past the default tail (never narrows it). */
129
+ export function readAcceptanceTail(sessionId: string, nativeHeadDeps: NativeHeadDeps, minBytes?: number): TranscriptRowLike[] {
100
130
  const path = transcriptPathFor('claude', sessionId, nativeHeadDeps)
101
- const lines = readTranscriptTailLines(path, ACCEPTANCE_TAIL_BYTES)
131
+ const want = typeof minBytes === 'number' && Number.isFinite(minBytes) ? Math.ceil(minBytes) : 0
132
+ const lines = readTranscriptTailLines(path, Math.min(ACCEPTANCE_TAIL_MAX_BYTES, Math.max(ACCEPTANCE_TAIL_BYTES, want)))
102
133
  if (!lines) return []
103
134
  const rows: TranscriptRowLike[] = []
104
135
  for (const line of lines) {
@@ -129,13 +160,14 @@ export function liveDeliveryStats(): LiveDeliveryStats {
129
160
  * counted as an attempt, so the health row stays quiet while the flag is off.
130
161
  */
131
162
  export function makeLiveTurnDeliverer(nativeHeadDeps: NativeHeadDeps) {
132
- return async (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number }): Promise<PeerDeliveryResult> => {
163
+ return async (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number; clientTurnId?: string }): Promise<PeerDeliveryResult> => {
133
164
  const result = await deliverOverPeerInbox(
134
165
  { ...request, enabled: continueLiveEnabled() },
135
166
  {
136
167
  records: () => readPeerInboxRecords(),
137
168
  token: record => readPeerToken(record),
138
- transcriptTail: id => readAcceptanceTail(id, nativeHeadDeps),
169
+ transcriptTail: (id, minBytes) => readAcceptanceTail(id, nativeHeadDeps, minBytes),
170
+ transcriptSize: id => readTranscriptSize(id, nativeHeadDeps),
139
171
  isCosOwnedPid: pid => cosSpawnedPids().has(pid),
140
172
  log: (level, event, detail) => {
141
173
  const line = `[${event}] ${JSON.stringify(detail)}`
@@ -68,24 +68,43 @@ export const PEER_CONNECT_TIMEOUT_MS = 1_500
68
68
 
69
69
  /** How long to wait for the session's transcript to show acceptance.
70
70
  *
71
- * SIX SECONDS IS THE ACCEPTANCE WINDOW, NOT THE REPLY WINDOW. The enqueue row is
72
- * written when the message is taken, not when the turn finishes, and the measured
73
- * latency for that row was under a second on an idle session. Six leaves room for a
74
- * session busy enough to be slow at its own bookkeeping without leaving the queue
75
- * blocked on a socket that is never going to answer. */
76
- export const PEER_VERIFY_TIMEOUT_MS = 6_000
71
+ * THE ACCEPTANCE WINDOW, NOT THE REPLY WINDOW. The enqueue row is written when the
72
+ * message is taken, not when the turn finishes, and the measured latency for that
73
+ * row was under a second on an idle session (257 ms on the first device delivery).
74
+ * Four seconds leaves room for a session busy at its own bookkeeping and still
75
+ * sits inside the phone's 10 s request budget with the gate, the head read and a
76
+ * 1.5 s connect in front of it. THE ONE SOURCE: the turn route passes this same
77
+ * constant (6.49.1; it used to carry its own 4 s while this file defaulted to 6 s,
78
+ * and a QA mutation deleting the route's field went unnoticed). */
79
+ export const PEER_VERIFY_TIMEOUT_MS = 4_000
77
80
 
78
81
  /** Transcript poll interval while waiting for acceptance. */
79
82
  export const PEER_VERIFY_POLL_MS = 250
80
83
 
81
- /** Rows older than this before the send cannot be evidence of it. Covers clock skew
82
- * between the transcript's ISO stamps and our own clock. */
84
+ /** Rows older than this before the send cannot be evidence of it.
85
+ *
86
+ * The transcript's ISO stamps and our clock are the SAME clock (one Mac, one
87
+ * kernel), so this is not skew cover. It is the width of the window in which a
88
+ * prompt typed at the desk with the same first 120 characters would be mistaken for
89
+ * ours, traded against a row stamped a beat before our `now()` by a writer that
90
+ * timestamps before it appends. Two seconds keeps the second and makes the first
91
+ * need a same-words prompt inside two seconds of the lens send. */
83
92
  export const PEER_ACCEPTANCE_BACKDATE_MS = 2_000
84
93
 
85
94
  /** Enough of the prompt to identify the row without depending on the whole of it
86
95
  * surviving verbatim (a long paste may be elided in some writers). */
87
96
  export const PEER_MARKER_CHARS = 120
88
97
 
98
+ /** Bytes read PAST the transcript's size at send time when looking for acceptance.
99
+ *
100
+ * The acceptance rows are small and land within a second of the send, but the rows
101
+ * written AFTER them are not: a single tool result can be over a megabyte (18 rows
102
+ * above 256 KiB in one real session, max 1.1 MB), and a fixed tail read would lose
103
+ * the evidence behind one of them on the memo's re-check. So the caller notes the
104
+ * file size before the send and every read covers from there to the end, plus this
105
+ * slack for a row already mid-write at the moment of the size read. */
106
+ export const PEER_ACCEPTANCE_READ_SLACK_BYTES = 64 * 1024
107
+
89
108
  /** One live session as the registry describes it. */
90
109
  export interface PeerInboxRecord {
91
110
  pid: number
@@ -95,6 +114,8 @@ export interface PeerInboxRecord {
95
114
  peerProtocol: number | null
96
115
  entrypoint: string | null
97
116
  status: string | null
117
+ /** Epoch ms the process started (registry `startedAt`), for the newest-window rule (6.49.1). */
118
+ startedAt?: number | null
98
119
  }
99
120
 
100
121
  export type PeerDeliveryReason =
@@ -102,7 +123,6 @@ export type PeerDeliveryReason =
102
123
  | 'disabled'
103
124
  | 'not_claude'
104
125
  | 'no_record'
105
- | 'no_socket'
106
126
  | 'protocol_unsupported'
107
127
  | 'connect_failed'
108
128
  | 'write_failed'
@@ -153,9 +173,16 @@ export function selectPeerInbox(
153
173
  const theirs = matches.filter(r => !isCosOwnedPid(r.pid))
154
174
  const pool = theirs.length > 0 ? theirs : []
155
175
  if (pool.length === 0) return null
156
- // Newest record wins when the user somehow has two windows on one session: the
157
- // higher pid is the one started most recently, which is the one they are looking at.
158
- const chosen = [...pool].sort((a, b) => b.pid - a.pid)[0]
176
+ // Newest record wins when the user somehow has two windows on one session: the one
177
+ // started most recently is the one they are looking at. BY START TIME, not pid
178
+ // (6.49.1): macOS recycles pids, and this Mac's counter wrapped twice in one day
179
+ // (registry on 2026-09-16: 82615 started before 45962 before 10487). A record with
180
+ // no start time sorts last, then by pid as before.
181
+ const chosen = [...pool].sort((a, b) => {
182
+ const sa = typeof a.startedAt === 'number' && Number.isFinite(a.startedAt) ? a.startedAt : -1
183
+ const sb = typeof b.startedAt === 'number' && Number.isFinite(b.startedAt) ? b.startedAt : -1
184
+ return sb !== sa ? sb - sa : b.pid - a.pid
185
+ })[0]
159
186
  return chosen ?? null
160
187
  }
161
188
 
@@ -192,14 +219,17 @@ export function peerAcceptanceMarker(prompt: string): string {
192
219
  * permission policy, lost with a dying process, or accepted with the transcript row
193
220
  * still on its way. Three of those must not get a second copy, and the fourth turns
194
221
  * into `delivered` on the next look. So the caller refuses the turn as retryable, and
195
- * the retry comes back HERE first: the transcript is checked from the ORIGINAL send
196
- * time (a late row counts), nothing is written to the socket again, and only once the
197
- * memo is older than `SUSPECT_TTL_MS` does the turn go to the spawn path -- the one
198
- * place a duplicate remains possible, and it is named in the health row when it does.
222
+ * when the retry reaches this module (the route's occupancy gate runs first, so a
223
+ * session busy with the turn we sent answers `native_thread_working` until it is
224
+ * done) the transcript is checked from the ORIGINAL send time AND from the file size
225
+ * at the original send (a late row counts, however much was written after it),
226
+ * nothing is written to the socket again, and only once the memo is older than
227
+ * `SUSPECT_TTL_MS` does the turn go to the spawn path -- the one place a duplicate
228
+ * remains possible, and it is named in the health row when it does.
199
229
  */
200
230
  export const SUSPECT_TTL_MS = 60_000
201
231
 
202
- interface SuspectSend { marker: string; sentAtMs: number }
232
+ interface SuspectSend { marker: string; sentAtMs: number; sizeAtSend: number | null }
203
233
  const suspects = new Map<string, SuspectSend>()
204
234
 
205
235
  function suspectKey(sessionId: string, marker: string): string {
@@ -276,8 +306,13 @@ export interface PeerInboxDeps {
276
306
  records: () => readonly PeerInboxRecord[] | Promise<readonly PeerInboxRecord[]>
277
307
  /** The session's peer token, or null when unreadable. Absence is not a failure. */
278
308
  token: (record: PeerInboxRecord) => string | null | Promise<string | null>
279
- /** The tail of the session's transcript, newest last. */
280
- transcriptTail: (sessionId: string) => readonly TranscriptRowLike[] | Promise<readonly TranscriptRowLike[]>
309
+ /** The tail of the session's transcript, newest last. `minBytes`, when given, is
310
+ * how far back the read must reach (the bytes appended since the send plus slack);
311
+ * the reader may read more, never less. */
312
+ transcriptTail: (sessionId: string, minBytes?: number) => readonly TranscriptRowLike[] | Promise<readonly TranscriptRowLike[]>
313
+ /** The transcript's size in bytes right now, or null when unknown. Optional: without
314
+ * it every read is the reader's default tail. */
315
+ transcriptSize?: (sessionId: string) => number | null | Promise<number | null>
281
316
  /** Pids COS itself spawned, so a resume child never receives the turn meant for the window. */
282
317
  isCosOwnedPid?: (pid: number) => boolean
283
318
  now?: () => number
@@ -293,21 +328,45 @@ export interface PeerInboxRequest {
293
328
  prompt: string
294
329
  enabled: boolean
295
330
  verifyTimeoutMs?: number
331
+ /** The client's idempotency key for this draft (6.49.1). When given, the suspect memo
332
+ * is keyed by it, so two drafts sharing their first 120 characters cannot collide and
333
+ * the same words under a NEW key are a new send, not a retry. The marker is still
334
+ * what is searched for in the transcript. */
335
+ clientTurnId?: string
296
336
  }
297
337
 
298
338
  function defaultSleep(ms: number): Promise<void> {
299
339
  return new Promise(resolve => setTimeout(resolve, ms))
300
340
  }
301
341
 
302
- /** Open the socket, write the frames, close. Rejects on anything that is not a clean write. */
342
+ /** Where a send failed. Before `connect` fired nothing was written, whatever the code. */
343
+ export type PeerSendPhase = 'connect' | 'write'
344
+
345
+ /** A rejection from `sendOverPeerSocket`, tagged with the phase it failed in. */
346
+ export interface PeerSendError extends Error {
347
+ phase: PeerSendPhase
348
+ code?: string
349
+ }
350
+
351
+ function tagPhase(error: Error, phase: PeerSendPhase): PeerSendError {
352
+ const tagged = error as PeerSendError
353
+ tagged.phase = phase
354
+ return tagged
355
+ }
356
+
357
+ /** Open the socket, write the frames, close. Rejects on anything that is not a clean
358
+ * write, and every rejection carries `phase`: `connect` means no byte left this
359
+ * process (a missing path, a refused or hung socket, a file that is not a socket, a
360
+ * permission refusal), `write` means some may have. */
303
361
  export function sendOverPeerSocket(socketPath: string, frames: readonly string[]): Promise<void> {
304
362
  return new Promise((resolve, reject) => {
305
363
  let settled = false
364
+ let phase: PeerSendPhase = 'connect'
306
365
  const finish = (error?: Error): void => {
307
366
  if (settled) return
308
367
  settled = true
309
368
  try { socket.destroy() } catch { /* already gone */ }
310
- if (error) reject(error)
369
+ if (error) reject(tagPhase(error, phase))
311
370
  else resolve()
312
371
  }
313
372
  const socket = connect(socketPath)
@@ -316,6 +375,7 @@ export function sendOverPeerSocket(socketPath: string, frames: readonly string[]
316
375
  socket.on('error', error => { clearTimeout(timer); finish(error instanceof Error ? error : new Error('socket_error')) })
317
376
  socket.on('connect', () => {
318
377
  clearTimeout(timer)
378
+ phase = 'write'
319
379
  try {
320
380
  // One write, so a half-written pair cannot leave an auth line without its
321
381
  // message on a connection the receiver closes for silence.
@@ -368,7 +428,6 @@ export async function deliverOverPeerInbox(
368
428
  }
369
429
  const target = selectPeerInbox(request.sessionId, records, deps.isCosOwnedPid)
370
430
  if (!target) return done(false, 'no_record', null, null)
371
- if (!target.socketPath) return done(false, 'no_socket', null, target.pid)
372
431
  // An unknown protocol is refused rather than attempted: the frames below are the
373
432
  // ones protocol 1 documents, and guessing at a successor is how a turn gets eaten.
374
433
  if (target.peerProtocol !== null && target.peerProtocol !== PEER_INBOX_PROTOCOL) {
@@ -376,13 +435,12 @@ export async function deliverOverPeerInbox(
376
435
  }
377
436
 
378
437
  const marker = peerAcceptanceMarker(request.prompt)
379
- const key = suspectKey(request.sessionId, marker)
438
+ const key = suspectKey(request.sessionId, request.clientTurnId ? `id:${request.clientTurnId}` : marker)
380
439
 
381
440
  // A retry of a send that was never verified: look before writing anything.
382
441
  const suspect = suspects.get(key)
383
442
  if (suspect) {
384
- let rows: readonly TranscriptRowLike[] = []
385
- try { rows = await deps.transcriptTail(request.sessionId) } catch { rows = [] }
443
+ const rows = await readSince(request.sessionId, suspect.sizeAtSend)
386
444
  const seen = peerAcceptanceIn(rows, marker, suspect.sentAtMs)
387
445
  if (seen) {
388
446
  suspects.delete(key)
@@ -396,28 +454,45 @@ export async function deliverOverPeerInbox(
396
454
  let token: string | null = null
397
455
  try { token = await deps.token(target) } catch { token = null }
398
456
 
457
+ // The file size BEFORE the frame goes out, so every later read can start there.
458
+ let sizeAtSend: number | null = null
459
+ try { sizeAtSend = (await deps.transcriptSize?.(request.sessionId)) ?? null } catch { sizeAtSend = null }
399
460
  const sentAt = now()
400
461
  try {
401
- await send(target.socketPath, peerInboxFrames(target.sessionId, request.prompt, token))
462
+ await send(target.socketPath!, peerInboxFrames(target.sessionId, request.prompt, token))
402
463
  } catch (error) {
464
+ const phase = (error as Partial<PeerSendError> | undefined)?.phase
403
465
  const code = (error as NodeJS.ErrnoException | undefined)?.code
404
- // ENOENT and ECONNREFUSED are the stale-socket shapes: the pid is gone and the
405
- // file outlived it. Both are ordinary, nothing was written, and both mean "spawn
406
- // the child". Anything else may have written a partial frame, so it is a suspect.
407
- if (code === 'ENOENT' || code === 'ECONNREFUSED') return done(false, 'connect_failed', null, target.pid)
408
- suspects.set(key, { marker, sentAtMs: sentAt })
466
+ // Before `connect` fired nothing was written: a stale path (ENOENT), a dead
467
+ // listener (ECONNREFUSED), a hung one (connect_timeout), a file that is not a
468
+ // socket, a permission refusal. All ordinary, all mean "spawn the child" now. A
469
+ // failure AFTER connect may have written a partial frame, so it is a suspect.
470
+ // (The code check stays for a `send` seam that does not tag the phase.)
471
+ if (phase === 'connect' || code === 'ENOENT' || code === 'ECONNREFUSED') return done(false, 'connect_failed', null, target.pid)
472
+ suspects.set(key, { marker, sentAtMs: sentAt, sizeAtSend })
409
473
  return done(false, 'write_failed', null, target.pid)
410
474
  }
411
475
 
412
476
  const deadline = sentAt + Math.max(0, request.verifyTimeoutMs ?? PEER_VERIFY_TIMEOUT_MS)
413
477
  for (;;) {
414
- let rows: readonly TranscriptRowLike[] = []
415
- try { rows = await deps.transcriptTail(request.sessionId) } catch { rows = [] }
478
+ const rows = await readSince(request.sessionId, sizeAtSend)
416
479
  const seen = peerAcceptanceIn(rows, marker, sentAt)
417
480
  if (seen) return done(true, 'delivered', seen, target.pid)
418
481
  if (now() >= deadline) break
419
482
  await sleep(PEER_VERIFY_POLL_MS)
420
483
  }
421
- suspects.set(key, { marker, sentAtMs: sentAt })
484
+ suspects.set(key, { marker, sentAtMs: sentAt, sizeAtSend })
422
485
  return done(false, 'unverified', null, target.pid)
486
+
487
+ /** The rows appended since the send (plus the reader's own default tail). Never throws. */
488
+ async function readSince(sessionId: string, since: number | null): Promise<readonly TranscriptRowLike[]> {
489
+ let minBytes: number | undefined
490
+ if (since !== null) {
491
+ try {
492
+ const size = (await deps.transcriptSize?.(sessionId)) ?? null
493
+ if (size !== null && size >= since) minBytes = size - since + PEER_ACCEPTANCE_READ_SLACK_BYTES
494
+ } catch { minBytes = undefined }
495
+ }
496
+ try { return await deps.transcriptTail(sessionId, minBytes) } catch { return [] }
497
+ }
423
498
  }
@@ -24,7 +24,12 @@ export type SessionStreamVerb = 'read' | 'edit' | 'write' | 'bash' | 'search' |
24
24
  export type SessionStreamState = 'working' | 'idle' | 'done'
25
25
 
26
26
  export type SessionStreamDraft =
27
- | { kind: 'tool'; verb: SessionStreamVerb; target: string; detail: string }
27
+ // `call` (6.49.1) is the provider's own id for the call (`toolu_…`), so a result
28
+ // can be paired with exactly the step that produced it; `outcome` is set only on a
29
+ // SEEDED step, whose result is already in the transcript when the client opens.
30
+ // Both are extra fields on an existing kind: a shipped client strips what it does
31
+ // not know and renders the step as before.
32
+ | { kind: 'tool'; verb: SessionStreamVerb; target: string; detail: string; call?: string; outcome?: ToolOutcome }
28
33
  // The user's own words for the turn being worked on. the user: "we should see the query
29
34
  // that the user has versus it just being a blank slate where it says working. That
30
35
  // way, the user at least knows what the agent is actively working on."
@@ -46,7 +51,7 @@ export type SessionStreamDraft =
46
51
  * What a finished tool produced, in the words a lens line has room for.
47
52
  *
48
53
  * `ok` says whether the tool itself reported failure; `detail` is the token the
49
- * client shows at the end of the step line (`+14 -2`, `41 passed`, `17 hits`,
54
+ * client shows at the end of the step line (`+14 -2`, `41 lines`, `17 hits`,
50
55
  * `exit 1`). Derived from the transcript's own `toolUseResult`, which is shape-keyed
51
56
  * (a Read result carries `file.numLines`, a Bash result `stdout`), so no tool name
52
57
  * and no hook is needed: the same row that announces the call carries its answer a
@@ -55,6 +60,17 @@ export type SessionStreamDraft =
55
60
  export interface ToolOutcome {
56
61
  ok: boolean
57
62
  detail: string
63
+ /** The `tool_use_id` this answers (6.49.1), when the provider names one. Without
64
+ * it a client pairs by order (oldest step still waiting), which is right for a
65
+ * single call and wrong for two parallel calls that finish out of order. */
66
+ call?: string
67
+ }
68
+
69
+ /** A tool-call id as the providers write them; anything else is not carried. */
70
+ const CALL_ID_RE = /^[A-Za-z0-9_-]{4,96}$/
71
+
72
+ function callId(value: unknown): string | undefined {
73
+ return typeof value === 'string' && CALL_ID_RE.test(value) ? value : undefined
58
74
  }
59
75
 
60
76
  /** A draft plus the transport's stamps. This is the JSON on the wire. */
@@ -317,6 +333,13 @@ export function outcomeForToolResult(
317
333
  if (out === 0) return { ok: true, detail: `stderr ${err} line${err === 1 ? '' : 's'}` }
318
334
  return { ok: true, detail: `${out} line${out === 1 ? '' : 's'}` }
319
335
  }
336
+ // Grep in CONTENT mode (6.49.1): the real row is `{ mode: 'content', numFiles: 0,
337
+ // filenames: [], content, numLines, totalLines }`, numFiles ALWAYS 0 there, so the
338
+ // file count below read "0 files" for a grep with hits (18 of 18 rows measured).
339
+ // The hits are the lines.
340
+ if (r.mode === 'content' && typeof r.numLines === 'number') {
341
+ return { ok: true, detail: r.numLines === 0 ? 'none' : `${r.numLines} hit${r.numLines === 1 ? '' : 's'}` }
342
+ }
320
343
  // Grep / Glob: counts when the tool gives them.
321
344
  if (typeof r.numFiles === 'number') return { ok: true, detail: `${r.numFiles} file${r.numFiles === 1 ? '' : 's'}` }
322
345
  if (typeof r.numMatches === 'number') return { ok: true, detail: `${r.numMatches} hit${r.numMatches === 1 ? '' : 's'}` }
@@ -352,18 +375,24 @@ export function outcomeDrafts(record: Record<string, unknown>, message: Record<s
352
375
  : Array.isArray(inner)
353
376
  ? inner.map(part => (asRecord(part)?.text as string | undefined) ?? '').join(' ')
354
377
  : ''
355
- const outcome = outcomeForToolResult(block.is_error === true, text, record.toolUseResult)
378
+ // `toolUseResult` in the transcript, `tool_use_result` on `claude -p`'s stream-json
379
+ // stdout, which the producer feeds through this same parser for every COS-spawned
380
+ // turn (6.49.1: only the first was read, so every spawn-path Continue and every
381
+ // drained follow-up showed an empty outcome for Read/Bash/Edit/Write).
382
+ const outcome = outcomeForToolResult(block.is_error === true, text, record.toolUseResult ?? record.tool_use_result)
383
+ const call = callId(block.tool_use_id)
356
384
  // A tool result means the turn is working by definition: the model called a
357
385
  // tool and is about to read what came back.
358
- out.push({ kind: 'status', state: 'working', tool_outcome: outcome })
386
+ out.push({ kind: 'status', state: 'working', tool_outcome: call ? { ...outcome, call } : outcome })
359
387
  }
360
388
  return out
361
389
  }
362
390
 
363
- function toolDraft(name: unknown, input: unknown): SessionStreamDraft {
391
+ function toolDraft(name: unknown, input: unknown, id?: unknown): SessionStreamDraft {
364
392
  const verb = verbForToolName(name)
365
393
  const target = targetForTool(name, input)
366
394
  const readable = typeof name === 'string' ? oneLine(name, TARGET_MAX_CHARS) : ''
395
+ const call = callId(id)
367
396
  return {
368
397
  kind: 'tool',
369
398
  verb,
@@ -372,7 +401,34 @@ function toolDraft(name: unknown, input: unknown): SessionStreamDraft {
372
401
  // empty line the reader cannot interpret.
373
402
  target: verb === 'other' || target === '' ? (readable || target) : target,
374
403
  detail: detailForTool(name, input),
404
+ ...(call ? { call } : {}),
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Fold each outcome onto the seeded step it answers (6.49.1).
410
+ *
411
+ * The seed drops status drafts (they describe a past moment), which through 6.49.0
412
+ * dropped the OUTCOMES with them: seven steps arrived with no result, the client's
413
+ * order-based pairing then hung every LIVE result on the oldest seeded step, and the
414
+ * newest tool read as running forever (QA, 2026-09-16, reproduced through the real
415
+ * parser and reducer). Pairs by `call` when both sides carry it, else by order; the
416
+ * outcome status drafts are removed since their content now rides on the step.
417
+ */
418
+ export function foldSeedOutcomes(drafts: readonly SessionStreamDraft[]): SessionStreamDraft[] {
419
+ const out: SessionStreamDraft[] = []
420
+ for (const draft of drafts) {
421
+ if (draft.kind !== 'status' || !draft.tool_outcome) { out.push(draft); continue }
422
+ const { call, ...outcome } = draft.tool_outcome
423
+ // The oldest step still waiting, EXCEPT that a named result never lands on a step
424
+ // named differently: so two named calls pair exactly whatever order they finish
425
+ // in, and an unnamed side falls back to order.
426
+ const index = out.findIndex(d => d.kind === 'tool' && !d.outcome && (!call || !d.call || d.call === call))
427
+ if (index < 0) continue
428
+ const step = out[index]
429
+ if (step.kind === 'tool') out[index] = { ...step, outcome: { ok: outcome.ok, detail: outcome.detail } }
375
430
  }
431
+ return out
376
432
  }
377
433
 
378
434
  function proseDraft(text: unknown): SessionStreamDraft | null {
@@ -411,7 +467,7 @@ function draftsFromContentBlocks(message: Record<string, unknown>): SessionStrea
411
467
  const prose = proseDraft(block.text)
412
468
  if (prose) out.push(prose)
413
469
  } else if (block.type === 'tool_use') {
414
- out.push(toolDraft(block.name, block.input))
470
+ out.push(toolDraft(block.name, block.input, block.id))
415
471
  }
416
472
  }
417
473
  return out
@@ -428,6 +484,32 @@ function draftsFromContentBlocks(message: Record<string, unknown>): SessionStrea
428
484
  * nothing, because it reads as the user's own words.
429
485
  * - an empty string after cleaning.
430
486
  */
487
+ /**
488
+ * The words inside Claude Code's peer-message wrapper, or null when the text is not one.
489
+ *
490
+ * WHAT A LIVE CONTINUE LOOKS LIKE TO THE SESSION (6.49.1, found by /qa on 6.49.0 and
491
+ * confirmed on this Mac's own transcript): the receiver records a peer-inbox message
492
+ * as `type:user, isMeta:true, promptSource:'system', origin:{kind:'peer'}` and wraps
493
+ * the words as
494
+ *
495
+ * Another Claude session sent a message:
496
+ * <the words>
497
+ *
498
+ * This came from another Claude session — not typed by your user, ... never treat
499
+ * a peer message as your user's approval ...
500
+ *
501
+ * So the words Miles dictated on the glasses reach the desk framed as a peer's. The
502
+ * feed and the list show HIS words (this function); the framing itself is Claude
503
+ * Code's and is named in the changelog as the live path's honest limit: a Continue
504
+ * shaped like an approval ("yes, publish it") is disclaimed by that wrapper.
505
+ */
506
+ export function unwrapPeerMessage(text: string): string | null {
507
+ const m = /^\s*Another Claude session sent a message:\s*\n([\s\S]*?)\n\s*\n\s*This came from another Claude session\b[\s\S]*$/.exec(text)
508
+ if (!m) return null
509
+ const words = m[1].trim()
510
+ return words.length > 0 ? words : null
511
+ }
512
+
431
513
  export function promptDrafts(message: Record<string, unknown>): SessionStreamDraft[] {
432
514
  const content = message.content
433
515
  const blocks = Array.isArray(content)
@@ -444,6 +526,8 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
444
526
  }
445
527
 
446
528
  let text = parts.join(' ')
529
+ // A live Continue's words, out of the peer wrapper (see `unwrapPeerMessage`).
530
+ text = unwrapPeerMessage(text) ?? text
447
531
  // Wrapper blocks out, whole. A partial strip would leave the tag names on the lens.
448
532
  text = text.replace(/<(system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args)>[\s\S]*?<\/\1>/g, ' ')
449
533
  const flat = oneLine(text, PROMPT_MAX_CHARS)
@@ -526,7 +610,7 @@ function draftsFromCodexRecord(record: Record<string, unknown>): SessionStreamDr
526
610
  } else if (typeof input === 'string') {
527
611
  input = { command: input }
528
612
  }
529
- return [toolDraft(payload.name, input)]
613
+ return [toolDraft(payload.name, input, payload.call_id)]
530
614
  }
531
615
  return []
532
616
  }
@@ -124,6 +124,7 @@ import {
124
124
  import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
125
125
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
126
126
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
127
+ import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
127
128
 
128
129
  /**
129
130
  * Read side of the binding lease store.
@@ -341,7 +342,7 @@ export interface AgentSessionBindingsDeps {
341
342
  * absent, or when it answers anything but `ok`, the spawn path below runs unchanged.
342
343
  * Only `reason` is read here; the vocabulary is the pure module's.
343
344
  */
344
- deliverLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null }>
345
+ deliverLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number; clientTurnId?: string }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null }>
345
346
 
346
347
  /**
347
348
  * The self-recursion ledger. Defaults to the real process-wide one.
@@ -570,7 +571,7 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
570
571
  delivery_ambiguous:
571
572
  'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
572
573
  live_unverified:
573
- 'Sent to the open session, but not yet confirmed. COS will check again in a moment.',
574
+ 'Sent, not yet confirmed. Send again: COS checks before sending twice.',
574
575
  turn_failed:
575
576
  'COS could not run this turn. Nothing was sent. You can try again.',
576
577
 
@@ -1286,13 +1287,17 @@ export const TURN_SENT_LIVE_COPY = 'Sent to the open session. It will show where
1286
1287
  /**
1287
1288
  * How long the route waits for the session's transcript to show acceptance.
1288
1289
  *
1289
- * FOUR SECONDS, INSIDE THE CLIENT'S TEN. The phone's `fetchJSON` aborts at 10 s; the
1290
- * gate ahead of this point costs about a second, the connect up to 1.5 s, so four
1291
- * leaves margin. Measured acceptance on an idle session was under one second, so the
1290
+ * FOUR SECONDS, INSIDE THE CLIENT'S BUDGET. The lens's turn POST allows 25 s since app
1291
+ * 6.9.487 (every other call keeps `fetchJSON`'s 10 s); the gate ahead of this point
1292
+ * costs about a second, the connect up to 1.5 s, so four leaves margin even for the
1293
+ * older ten. Measured acceptance on an idle session was under one second, so the
1292
1294
  * budget only bites when the receiver is holding or dying, and those end as
1293
1295
  * `live_unverified`, which the client may retry.
1296
+ *
1297
+ * THE SAME CONSTANT the transport defaults to (6.49.1): two numbers for one window
1298
+ * meant a mutation deleting the route's field silently changed the budget.
1294
1299
  */
1295
- export const LIVE_VERIFY_BUDGET_MS = 4_000
1300
+ export const LIVE_VERIFY_BUDGET_MS = PEER_VERIFY_TIMEOUT_MS
1296
1301
 
1297
1302
  /**
1298
1303
  * The 202 copy. Says QUEUED and not sent, because at this instant the provider has
@@ -1908,6 +1913,24 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1908
1913
  res.set('Cache-Control', 'private, no-store')
1909
1914
 
1910
1915
  const turnId = mintId()
1916
+ // 6.49.1: where a turn's time goes, BEFORE the client's clock runs out. Miles's
1917
+ // first three sends on 2026-09-16 read "lost track" on the lens with no server-side
1918
+ // trace at all; this line is the trace. One `[turn timing]` per request at every
1919
+ // exit that ANSWERS the client (the refusals and the live 200 through `respond`,
1920
+ // the ledger replay and the 202 at their own `res.status` calls), with the stage
1921
+ // ms and the first-byte-to-answer total, so a slow gate, a slow head read, a slow
1922
+ // live hop and a request that never arrived are four different lines rather than
1923
+ // one absence. Never the prompt.
1924
+ const stageClock = process.hrtime.bigint()
1925
+ const stages: string[] = []
1926
+ let lastStage = stageClock
1927
+ const stage = (name: string): void => {
1928
+ const now = process.hrtime.bigint()
1929
+ stages.push(`${name}=${Number((now - lastStage) / 1_000_000n)}ms`)
1930
+ lastStage = now
1931
+ }
1932
+ const turnTiming = (outcome: string): string =>
1933
+ `[agent-session-bindings] turn timing turnId=${turnId} outcome=${outcome} total=${Number((process.hrtime.bigint() - stageClock) / 1_000_000n)}ms ${stages.join(' ')}`
1911
1934
  /** The target we hold a claim on, released in the finally. */
1912
1935
  let claimedKey: string | null = null
1913
1936
  // Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
@@ -1950,7 +1973,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1950
1973
  let queued = false
1951
1974
 
1952
1975
  const respond = (status: number, payload: Record<string, unknown>): void => {
1953
- if (!res.headersSent) res.status(status).json(payload)
1976
+ if (!res.headersSent) {
1977
+ res.status(status).json(payload)
1978
+ const outcome = typeof payload.outcome === 'string' ? payload.outcome : String(status)
1979
+ const reason = typeof payload.reason === 'string' ? `/${payload.reason}` : ''
1980
+ const via = typeof payload.via === 'string' ? `/${payload.via}` : ''
1981
+ console.log(turnTiming(`${outcome}${reason}${via}`))
1982
+ }
1954
1983
  // Remembered ONLY when the prompt may have reached the provider. A
1955
1984
  // pre-delivery refusal (stale epoch, malformed body, busy target) must stay
1956
1985
  // re-evaluatable: the binding may be fine by the time the client retries, and
@@ -2081,6 +2110,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2081
2110
  const { status, ...rest } = already.result as Record<string, unknown>
2082
2111
  if (!res.headersSent) {
2083
2112
  res.status(typeof status === 'number' ? status : 200).json({ ...rest, replayed: true })
2113
+ console.log(turnTiming(`replayed/${typeof rest.outcome === 'string' ? rest.outcome : String(status)}`))
2084
2114
  }
2085
2115
  return
2086
2116
  }
@@ -2124,9 +2154,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2124
2154
  // open, and it is terminal here rather than a warning because COS has no
2125
2155
  // cross-process lock that could fence a live desktop writer.
2126
2156
  const verdict = runOccupancy(binding.provider, binding.nativeThreadId)
2157
+ stage('gate')
2127
2158
  if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
2128
2159
 
2129
2160
  const head = await readHead(binding.provider, binding.nativeThreadId)
2161
+ stage('head')
2130
2162
  if (head === null) return refuseTurn('native_head_unavailable')
2131
2163
  preTurnHeadDigest = head.digest
2132
2164
 
@@ -2150,6 +2182,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2150
2182
  // A failed pin is FATAL, per the registry's own caller contract: an unpinned
2151
2183
  // binding can expire or be detached mid-turn, which defeats the lease.
2152
2184
  const pinned = deps.bindings.pin!(bindingId, turnId, now)
2185
+ stage('pin')
2153
2186
  if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
2154
2187
  pinnedBindingId = bindingId
2155
2188
 
@@ -2173,6 +2206,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2173
2206
  sessionId: binding.nativeThreadId,
2174
2207
  prompt,
2175
2208
  verifyTimeoutMs: LIVE_VERIFY_BUDGET_MS,
2209
+ clientTurnId,
2176
2210
  })
2177
2211
  } catch (error) {
2178
2212
  // A throw is a bug in the transport, not a fact about the session; the
@@ -2180,6 +2214,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2180
2214
  console.error(`[agent-session-bindings] live delivery threw: ${error instanceof Error ? error.message : error}`)
2181
2215
  live = null
2182
2216
  }
2217
+ stage(`live(${live?.reason ?? 'threw'})`)
2183
2218
  if (live?.ok) {
2184
2219
  console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
2185
2220
  respond(200, {
@@ -2199,8 +2234,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2199
2234
  // Something may be in the session. Hold, never spawn over it.
2200
2235
  return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
2201
2236
  }
2202
- // disabled / not_claude / no_record / no_socket / protocol_unsupported /
2203
- // connect_failed / suspect_expired: nothing reached a session. Spawn as always.
2237
+ // disabled / not_claude / no_record / protocol_unsupported / connect_failed /
2238
+ // suspect_expired: nothing reached a session. Spawn as always.
2204
2239
  }
2205
2240
 
2206
2241
  // THE QUEUE POINT. Every gate is now behind us — body, replay, queued-prompt,
@@ -2224,6 +2259,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2224
2259
  reason: null,
2225
2260
  reasonCopy: TURN_QUEUED_COPY,
2226
2261
  })
2262
+ // The request's own timing line (6.49.1). `respond` cannot write it after this:
2263
+ // its `headersSent` guard is what keeps the ledger-only outcome below from
2264
+ // answering twice. QA found the two exits Miles actually hits (this 202 and the
2265
+ // replay above) were the two with no line.
2266
+ console.log(turnTiming('queued'))
2227
2267
 
2228
2268
  let delivery: Delivery
2229
2269
  deliveryAttempted = true
@@ -79,7 +79,7 @@ import {
79
79
  transcriptWatcherDegraded,
80
80
  readTranscriptSeedLines,
81
81
  } from '../lib/session-transcript-watcher.js'
82
- import { draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
82
+ import { foldSeedOutcomes, draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
83
83
  import type { SessionStreamState } from '../lib/session-stream-events.js'
84
84
  import { RING_EPOCH, replaySessionStream, ringBounds } from '../lib/session-stream-bus.js'
85
85
  import { claudeSessionsDir, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
@@ -387,7 +387,9 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
387
387
  if (!replayable && path !== null && startOffset > 0) {
388
388
  try {
389
389
  const lines = await readTranscriptSeedLines(path, startOffset)
390
- const drafts = lines.flatMap(line => draftsFromLine(provider, line))
390
+ // 6.49.1: each seeded step carries its own outcome (`foldSeedOutcomes`), since
391
+ // the status drafts that carried them live are dropped from the seed below.
392
+ const drafts = foldSeedOutcomes(lines.flatMap(line => draftsFromLine(provider, line)))
391
393
  // Status drafts are dropped from the seed: they describe the state at some past
392
394
  // moment and the opening status above is the CURRENT one. Replaying an old
393
395
  // `done` after it would tell the client the live session had finished.