@gotcos/glasses-server 6.48.1 → 6.49.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,23 @@
1
+ ## 6.49.0
2
+
3
+ A Continue lands in the session you left open, and the live feed says what every step produced.
4
+
5
+ - **The running session takes the turn.** Two of Miles's Continues from the lens on 2026-09-15 were delivered, answered, and invisible: the resume child appended them to the transcript, and the Desktop tab that was open on that session never re-reads its file, so the desk showed nothing until he resumed. The server now writes the turn into the session's own inbox first. Claude Code publishes one per live process in `~/.claude/sessions/<pid>.json` (`messagingSocketPath`, `peerProtocol: 1`) and documents the two-line format in its help text; a Desktop tab and a `claude` in iTerm carry the same record, so there is one path, not two. Measured on this Mac: an idle session took the message as a real turn and answered in about three seconds, painted in its own window. Off until `COS_CONTINUE_LIVE=1`; every refusal takes the 6.48.2 path.
6
+ - **Delivered means the transcript says so.** Bytes on a socket prove nothing: the receiver drops a frame whose `session_id` is not its own, may hold one behind its permission policy, or may be shutting down. So the turn is `delivered` only when the session's transcript shows the `queue-operation: enqueue` row (or the user row) carrying it, which Claude Code writes the instant it accepts a message and before the turn runs. The frame is pinned to the full session id (a pid is recycled; a prefix is ambiguous), a COS-spawned resume child sharing the id is never the target, and a wrong token is not a failure because Claude Code accepts unauthenticated peers on this platform (measured) and the pin is the protection.
7
+ - **Never twice.** A frame written but not seen accepted is a suspect, not a failure: the route answers `live_unverified` (retryable, not ledgered), the retry re-reads the transcript from the ORIGINAL send before doing anything, the socket is never written twice for one turn, and only after 60 s without an acceptance row does the resume child run. The one duplicate this leaves open is a session whose permission mode holds peer messages for approval, approved by hand after the child already answered; that is a Deny, and it is named here rather than hidden. The attach gate runs in full before the live hop: a busy Desktop holder is still refused first and queued, and the queue drains into the live session when the turn ends.
8
+ - **Outcomes on the live feed.** Each tool result in the transcript now emits a `status` draft carrying `tool_outcome: { ok, detail }`, derived from the result's own shape and never from its content: `235 lines`, `+14 -2`, `41 lines`, `3 files`, `17 hits`, `200 · 13 KB`, `exit 1`, `denied`, `interrupted`. A client that predates it takes a repeated `status: working` as a no-op (nothing appended, no step counted, no gap), so 6.9.485 renders unchanged; the next EHPK and Control read it to show the result at the end of each step line and the step still running as the live row.
9
+ - `/api/health` gains `continueLive` (enabled, attempts, delivered, fallbacks by reason, last reason). Control's env allowlist carries `COS_CONTINUE_LIVE` from its next build so Update Server keeps it.
10
+ - Tests: the pure inbox module (full-id selection, the COS-child skip, the pinned frames, acceptance from the enqueue and user rows with the time floor, the suspect memo's three rules, every refusal never sends), the route (live completes without a spawn and replays; nothing-sent reasons spawn exactly as 6.48.2; unverified holds and never spawns; a throwing transport spawns; the gate runs first; absent dep is the 6.48.2 route), the outcome tokens from twenty-one recorded result shapes, the derived-status stamp keeping the outcome. Mutation gate: twelve new cases, all killed on a green baseline.
11
+ - Requires nothing new on the clients. Claude Code 2.1.273 measured; the inbox protocol is read from the registry record and refused when it is not `1`.
12
+
13
+ ## 6.48.2
14
+
15
+ Continue and Fork work again on a session whose transcript opens with a big row. Reported from the lens on 6.9.482 (Miles, 2026-09-15): Continue on a Claude session answered "Can't continue. Fork thread instead" while the row said the session was attachable.
16
+
17
+ - The attach route reads the session's working directory out of the transcript's first rows. It read a fixed 512 KiB, and a session whose first prompt carried pasted screenshots opens with one row of about 1 MB, so the window held four bookkeeping rows and a torn fifth, no `cwd`, and every Continue and Fork on that session was refused `target_unresolvable`. The read now widens (512 KiB, 2 MiB, 8 MiB, 16 MiB) while the window keeps ending inside a row with no `cwd` seen, and stops as soon as the whole file has been read or a row boundary shows the transcript records none.
18
+ - The first `cwd` in the transcript is the workspace. Two different values used to refuse the attach as a disagreement; on the release Mac every such transcript was one session whose shell had `cd`-ed into a subfolder inside a turn, and Claude stamps each row with the process cwd of the moment. The directory the session started in keys its project folder and is the only place `--resume` finds it, so it is the answer. On the release Mac 738 of 758 transcripts resolve (before: 703); the 20 that do not are scratch sessions whose temporary directory has since been deleted, which is a refusal by design.
19
+ - Tests: the first-cwd rule for Claude and Codex, the torn-window signal, the widening sequence against the production shape (four bookkeeping rows, a 950 KB image row, then the cwd), a short file read once, a first row past the cap refused at the cap. Mutation gate: the widening and the first-cwd rule.
20
+
1
21
  ## 6.48.1
2
22
 
3
23
  One derivation everywhere, a Desktop turn ends when the engine says it ended, a queued follow-up drains then instead of on the 30 s idle window, and the live feed carries the same state every row does. No client change is required: the next Control and EHPK builds read the new keys, and today's consumers ignore them.
package/README.md CHANGED
@@ -200,6 +200,20 @@ after a reconnect and seeds afresh when it holds nothing, `?seed=turn` opens at
200
200
  current prompt). `COS_SESSION_HOOK_SSE=0` omits the extra status fields and the live
201
201
  state drafts; the `cursor`, `epoch` and `id:` line stay.
202
202
 
203
+ Since 6.49.0 a Continue can land in the running session itself, so the Desktop tab or
204
+ the `claude` in a terminal that you left open shows the turn and answers it with its own
205
+ context, instead of a resume child writing to the transcript behind that window. It is
206
+ off until `COS_CONTINUE_LIVE=1`. The server writes the turn to the session's own inbox
207
+ (the socket Claude Code publishes in `~/.claude/sessions/<pid>.json`, in the line format
208
+ its help text documents), pinned to the full session id, and calls it delivered only
209
+ when the session's transcript shows the message accepted; every other outcome takes the
210
+ 6.48.2 path unchanged. A frame written but not seen accepted holds the turn as
211
+ `live_unverified` (retryable) and is never written twice; only after 60 s without an
212
+ acceptance row does the resume child run. `/api/health` reports `continueLive`
213
+ (enabled, attempts, delivered, fallbacks by reason). The live stream also carries a
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.
216
+
203
217
  ## Configuration
204
218
 
205
219
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.48.1",
3
+ "version": "6.49.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
@@ -36,6 +36,7 @@ import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
36
36
  import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
37
37
  import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
38
38
  import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
39
+ import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
39
40
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
40
41
  import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
41
42
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
@@ -749,6 +750,10 @@ app.use('/api', createAgentSessionBindingsRouter({
749
750
  },
750
751
  nativeHead: (provider, threadId) => nativeHead(provider, threadId, nativeHeadDeps),
751
752
  deliverAttachedTurn: deliverAttachedTurnForRoute as never,
753
+ // 6.49.0: the running session first (Desktop tab or iTerm, same registry record),
754
+ // the resume child second. Reads `COS_CONTINUE_LIVE` per call, so the flag reports
755
+ // on /api/health without a restart and every refusal falls back to the 6.48.2 path.
756
+ deliverLiveTurn: makeLiveTurnDeliverer(nativeHeadDeps),
752
757
  forkThread: forkThreadForRoute,
753
758
  // The fork's real spawn directory. Separate from `resolveTarget` above, which
754
759
  // deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
@@ -61,9 +61,20 @@ export interface AttachedWorkspaceDeps {
61
61
  cursorSpawnWorkspace?: (threadId: string) => string | null
62
62
  }
63
63
 
64
- /** Enough to reach a Claude message row or the Codex session meta row. */
64
+ /**
65
+ * The first window read for the cwd: enough to reach a Claude message row or the Codex
66
+ * session meta row in the common case. NOT enough on its own (6.48.2): a session whose
67
+ * first prompt carries pasted screenshots opens with a single row of ~1 MB, so 512 KiB
68
+ * held four bookkeeping rows and a torn fifth, no cwd, and every Continue on that
69
+ * session was refused `target_unresolvable` (Miles, 2026-09-15, from the lens; 55 of
70
+ * 758 transcripts on the release Mac). When a window ends inside a row before any cwd
71
+ * was seen, the read widens up to WORKSPACE_SCAN_BYTES_MAX.
72
+ */
65
73
  export const WORKSPACE_SCAN_BYTES = 512 * 1024
66
74
 
75
+ /** The widest head read: a first prompt with ten screenshots fits; a 13 GB rollout does not get read. */
76
+ export const WORKSPACE_SCAN_BYTES_MAX = 16 * 1024 * 1024
77
+
67
78
  /** Rows to inspect before giving up. Bounds a pathological single-line file. */
68
79
  export const WORKSPACE_SCAN_ROWS = 400
69
80
 
@@ -72,19 +83,37 @@ export function fingerprint(value: string): string {
72
83
  }
73
84
 
74
85
  /**
75
- * Pull the recorded cwd out of transcript text.
86
+ * Pull the recorded cwd out of transcript text: the FIRST row that records one.
76
87
  *
77
88
  * Claude puts it at the row's top level; Codex puts it in the session meta row's
78
89
  * `payload`. A torn trailing line is NORMAL — these files are appended to — so a
79
90
  * parse failure on any single row is skipped rather than fatal.
80
91
  *
81
- * Returns null when no row records one, and DISAGREEMENT is also null: a transcript
82
- * whose rows claim two different working directories is not something to guess at.
92
+ * THE FIRST cwd IS THE WORKSPACE (6.48.2). Until 6.48.1 two different values in the
93
+ * window refused the attach as a disagreement. On the release Mac every such
94
+ * transcript was one session whose shell `cd`-ed inside a turn (`operations/scripts`,
95
+ * a weekly folder): Claude stamps each row with the process cwd of the moment, all
96
+ * under the directory the session was started in. That starting directory is the
97
+ * workspace: it keys the transcript's project folder, it is the only place
98
+ * `claude --resume` finds the session, and it is what the first row records.
99
+ *
100
+ * Returns null only when no row in the window records one.
83
101
  */
84
102
  export function cwdFromTranscript(text: string): string | null {
85
- const seen = new Set<string>()
103
+ return scanForCwd(text).cwd
104
+ }
105
+
106
+ /**
107
+ * The scan with its outcome: the cwd, or whether the window ended INSIDE a row before
108
+ * any cwd was seen (a first prompt of pasted screenshots is one row of ~1 MB), which
109
+ * tells the resolver that a wider read can still answer.
110
+ */
111
+ export function scanForCwd(text: string): { cwd: string | null; endedMidRow: boolean; rows: number } {
86
112
  let rows = 0
87
- for (const line of text.split('\n')) {
113
+ const lines = text.split('\n')
114
+ // A window that does not end with a newline ended inside whatever row came last.
115
+ const endedMidRow = text.length > 0 && !text.endsWith('\n')
116
+ for (const line of lines) {
88
117
  if (rows >= WORKSPACE_SCAN_ROWS) break
89
118
  const trimmed = line.trim()
90
119
  if (!trimmed) continue
@@ -94,18 +123,14 @@ export function cwdFromTranscript(text: string): string | null {
94
123
  if (!row || typeof row !== 'object' || Array.isArray(row)) continue
95
124
  const record = row as Record<string, unknown>
96
125
  const direct = record.cwd
97
- if (typeof direct === 'string' && direct.length > 0) seen.add(direct)
126
+ if (typeof direct === 'string' && direct.length > 0) return { cwd: direct, endedMidRow: false, rows }
98
127
  const payload = record.payload
99
128
  if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
100
129
  const nested = (payload as Record<string, unknown>).cwd
101
- if (typeof nested === 'string' && nested.length > 0) seen.add(nested)
130
+ if (typeof nested === 'string' && nested.length > 0) return { cwd: nested, endedMidRow: false, rows }
102
131
  }
103
- // One consistent answer found early is enough; keep scanning only while it is
104
- // still the only one, so a disagreement is detected rather than short-circuited.
105
- if (seen.size > 1) return null
106
132
  }
107
- if (seen.size !== 1) return null
108
- return [...seen][0]!
133
+ return { cwd: null, endedMidRow, rows }
109
134
  }
110
135
 
111
136
  /**
@@ -145,17 +170,27 @@ export function resolveAttachedWorkspace(
145
170
  }
146
171
  if (provider !== 'claude' && provider !== 'codex') return null
147
172
  let path: string | null
148
- let text: string | null
173
+ let cwd: string | null = null
149
174
  try {
150
175
  path = deps.transcriptPath(provider, threadId)
151
176
  if (path === null) return null
152
- text = deps.readHead(path, WORKSPACE_SCAN_BYTES)
177
+ // Widen the head read while the window keeps ending inside a row with no cwd seen:
178
+ // 512 KiB, 2 MiB, 8 MiB, then the cap. A window that ended on a row boundary with
179
+ // no cwd is a transcript that records none, and widening would not change that.
180
+ let window = WORKSPACE_SCAN_BYTES
181
+ for (;;) {
182
+ const text = deps.readHead(path, window)
183
+ if (text === null) return null
184
+ const scan = scanForCwd(text)
185
+ cwd = scan.cwd
186
+ // The whole file came back (shorter than the window): there is nothing wider to read.
187
+ const wholeFile = Buffer.byteLength(text, 'utf8') < window
188
+ if (cwd !== null || wholeFile || !scan.endedMidRow || scan.rows >= WORKSPACE_SCAN_ROWS || window >= WORKSPACE_SCAN_BYTES_MAX) break
189
+ window = Math.min(window * 4, WORKSPACE_SCAN_BYTES_MAX)
190
+ }
153
191
  } catch {
154
192
  return null
155
193
  }
156
- if (text === null) return null
157
-
158
- const cwd = cwdFromTranscript(text)
159
194
  if (cwd === null) return null
160
195
  // A relative cwd would resolve against the SERVER's working directory, which is
161
196
  // not a location we control and is never what the user meant.
@@ -0,0 +1,156 @@
1
+ // The real world behind `session-peer-inbox.ts`: the registry directory, the key
2
+ // file beside each record, the session's transcript, and the flag.
3
+ //
4
+ // Kept apart from the pure module so the contract there never touches a path, and so
5
+ // this file is the ONE place that knows where Claude Code keeps these things. All of
6
+ // it is read-only: nothing here writes to `~/.claude`.
7
+
8
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { claudeSessionsDir, REGISTRY_FILENAME } from './claude-session-registry.js'
11
+ import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
12
+ import { cosSpawnedPids } from './agent-session-ownership-store.js'
13
+ import { readTranscriptTailLines } from './thread-turn-queue-store.js'
14
+ import {
15
+ deliverOverPeerInbox,
16
+ type PeerDeliveryResult,
17
+ type PeerInboxRecord,
18
+ type TranscriptRowLike,
19
+ } from './session-peer-inbox.js'
20
+
21
+ /**
22
+ * `COS_CONTINUE_LIVE`. OFF by default in 6.49.0.
23
+ *
24
+ * The transport rides a protocol Claude Code documents in a help string, not a
25
+ * contract, and the first days are for reading the health row on one Mac. A literal
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.
28
+ */
29
+ export function continueLiveEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
30
+ return env.COS_CONTINUE_LIVE === '1'
31
+ }
32
+
33
+ /** Registry files are small; anything larger is not one. */
34
+ const MAX_RECORD_BYTES = 64 * 1024
35
+ /** `<pid>.<64 hex>.key`, the sibling of `<pid>.json`. */
36
+ 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. */
38
+ const ACCEPTANCE_TAIL_BYTES = 256 * 1024
39
+
40
+ /** One pass over `~/.claude/sessions`, records only. Never throws. */
41
+ export function readPeerInboxRecords(dir: string = claudeSessionsDir()): PeerInboxRecord[] {
42
+ let names: string[]
43
+ try { names = readdirSync(dir) } catch { return [] }
44
+ const out: PeerInboxRecord[] = []
45
+ for (const name of names) {
46
+ if (!REGISTRY_FILENAME.test(name)) continue
47
+ const full = join(dir, name)
48
+ try {
49
+ // lstat, not stat: a symlink here would let anything on the filesystem be read.
50
+ const link = lstatSync(full)
51
+ if (!link.isFile() || link.size > MAX_RECORD_BYTES) continue
52
+ const raw = JSON.parse(readFileSync(full, 'utf-8')) as Record<string, unknown>
53
+ const pid = Number(raw.pid)
54
+ const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId.trim().toLowerCase() : ''
55
+ if (!Number.isInteger(pid) || pid <= 0 || !sessionId) continue
56
+ out.push({
57
+ pid,
58
+ sessionId,
59
+ socketPath: typeof raw.messagingSocketPath === 'string' && raw.messagingSocketPath.length > 0 ? raw.messagingSocketPath : null,
60
+ peerProtocol: typeof raw.peerProtocol === 'number' && Number.isInteger(raw.peerProtocol) ? raw.peerProtocol : null,
61
+ entrypoint: typeof raw.entrypoint === 'string' ? raw.entrypoint : null,
62
+ status: typeof raw.status === 'string' ? raw.status : null,
63
+ })
64
+ } catch {
65
+ // A torn read or a record the reaper just unlinked. Ordinary.
66
+ continue
67
+ }
68
+ }
69
+ return out
70
+ }
71
+
72
+ /**
73
+ * The session's peer token, or null.
74
+ *
75
+ * Auth is optional on this platform (see the pure module's header), so null is the
76
+ * common, acceptable answer whenever the file is absent, unreadable, or not ours.
77
+ * The value is returned to the caller and never logged.
78
+ */
79
+ export function readPeerToken(record: PeerInboxRecord, dir: string = claudeSessionsDir()): string | null {
80
+ let names: string[]
81
+ try { names = readdirSync(dir) } catch { return null }
82
+ for (const name of names) {
83
+ const m = KEY_FILENAME.exec(name)
84
+ if (!m || Number(m[1]) !== record.pid) continue
85
+ try {
86
+ const full = join(dir, name)
87
+ const link = lstatSync(full)
88
+ if (!link.isFile() || link.size > 4096) return null
89
+ const parsed = JSON.parse(readFileSync(full, 'utf-8')) as { peerToken?: unknown }
90
+ return typeof parsed.peerToken === 'string' && parsed.peerToken.length > 0 ? parsed.peerToken : null
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+ return null
96
+ }
97
+
98
+ /** The newest rows of the session's transcript, parsed; [] when there is no file. */
99
+ export function readAcceptanceTail(sessionId: string, nativeHeadDeps: NativeHeadDeps): TranscriptRowLike[] {
100
+ const path = transcriptPathFor('claude', sessionId, nativeHeadDeps)
101
+ const lines = readTranscriptTailLines(path, ACCEPTANCE_TAIL_BYTES)
102
+ if (!lines) return []
103
+ const rows: TranscriptRowLike[] = []
104
+ for (const line of lines) {
105
+ if (!line.startsWith('{')) continue
106
+ try { rows.push(JSON.parse(line) as TranscriptRowLike) } catch { /* torn tail line */ }
107
+ }
108
+ return rows
109
+ }
110
+
111
+ export interface LiveDeliveryStats {
112
+ attempts: number
113
+ delivered: number
114
+ /** By reason, so the health row says WHY the spawn path was taken. */
115
+ fallbacks: Record<string, number>
116
+ lastReason: string | null
117
+ lastAt: number | null
118
+ }
119
+
120
+ const stats: LiveDeliveryStats = { attempts: 0, delivered: 0, fallbacks: {}, lastReason: null, lastAt: null }
121
+
122
+ /** For `/api/health`. A copy, never the live object. */
123
+ export function liveDeliveryStats(): LiveDeliveryStats {
124
+ return { ...stats, fallbacks: { ...stats.fallbacks } }
125
+ }
126
+
127
+ /**
128
+ * The route's dep: try the running session, report honestly. `disabled` is not
129
+ * counted as an attempt, so the health row stays quiet while the flag is off.
130
+ */
131
+ export function makeLiveTurnDeliverer(nativeHeadDeps: NativeHeadDeps) {
132
+ return async (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number }): Promise<PeerDeliveryResult> => {
133
+ const result = await deliverOverPeerInbox(
134
+ { ...request, enabled: continueLiveEnabled() },
135
+ {
136
+ records: () => readPeerInboxRecords(),
137
+ token: record => readPeerToken(record),
138
+ transcriptTail: id => readAcceptanceTail(id, nativeHeadDeps),
139
+ isCosOwnedPid: pid => cosSpawnedPids().has(pid),
140
+ log: (level, event, detail) => {
141
+ const line = `[${event}] ${JSON.stringify(detail)}`
142
+ if (level === 'warn') console.warn(line)
143
+ else console.log(line)
144
+ },
145
+ },
146
+ )
147
+ if (result.reason !== 'disabled') {
148
+ stats.attempts += 1
149
+ if (result.ok) stats.delivered += 1
150
+ else stats.fallbacks[result.reason] = (stats.fallbacks[result.reason] ?? 0) + 1
151
+ stats.lastReason = result.reason
152
+ stats.lastAt = Date.now()
153
+ }
154
+ return result
155
+ }
156
+ }
@@ -0,0 +1,423 @@
1
+ // Delivering a queued turn INTO the running Claude session, instead of only into
2
+ // its transcript (6.49.0).
3
+ //
4
+ // THE PROBLEM THIS SOLVES. A Continue from the glasses reaches the session by
5
+ // spawning `claude -p --resume <id>`, which appends the turn and its reply to the
6
+ // session file and exits. That is correct and it is durable, but the window Miles
7
+ // left open -- a Desktop tab, or a `claude` running in iTerm -- read that file when
8
+ // it opened and never reads it again, so the turn he sent from the lens is invisible
9
+ // at the desk until he resumes the session. Two of his Continues on 2026-09-15 landed
10
+ // exactly that way: delivered, answered, and not painted anywhere he was looking.
11
+ //
12
+ // WHAT CLAUDE CODE ACTUALLY OFFERS. Every live session publishes an inbox in its
13
+ // registry record (`~/.claude/sessions/<pid>.json`): `messagingSocketPath`,
14
+ // `peerProtocol`, `peerFeatures`. The wire format is not reverse-engineered here --
15
+ // Claude Code documents it in its own help text, which is where these two lines come
16
+ // from verbatim:
17
+ //
18
+ // { echo '{"type":"auth","token":"'"$CLAUDE_CODE_MESSAGING_TOKEN"'"}';
19
+ // echo '{"type":"user","message":{"role":"user","content":"hello"}}'; } | socat - UNIX-CONNECT:<sock>
20
+ //
21
+ // Line-delimited JSON, one connection per message. Measured on this Mac
22
+ // (2026-09-16 13:10, throwaway session `ec2e55b0` in a scratch cwd): the message
23
+ // landed as a real user turn, the idle session woke, answered in about three
24
+ // seconds, and the terminal it was running in painted the whole exchange. That is
25
+ // the continuity this module is for.
26
+ //
27
+ // ONE MECHANISM, BOTH SURFACES. The registry census that afternoon showed ten live
28
+ // records -- `claude-desktop`, `cli` and `sdk-cli` -- carrying the SAME fields and
29
+ // the same `peerProtocol: 1`. Claude Code does not distinguish a Desktop tab from a
30
+ // terminal for messaging, so neither does this file. There is no Desktop branch and
31
+ // no iTerm branch to drift apart.
32
+ //
33
+ // AUTH IS OPTIONAL, AND THAT IS A REASON FOR THE PIN, NOT AGAINST IT. A canary with
34
+ // a deliberately wrong token still delivered; Claude Code says so itself ("peers will
35
+ // send unauthenticated (accepted: auth is optional on this platform)"). So the token
36
+ // is sent when the key file is readable -- forward-compatible if that ever tightens --
37
+ // and its absence is not a failure. What actually protects the delivery is the
38
+ // `session_id` pin: the receiver drops any frame whose `session_id` is not its own
39
+ // (`[uds-messaging] Dropping ... message: session_id mismatch`). Pids are recycled by
40
+ // the OS and a socket path is just a pid; the pin is what stops a turn meant for a
41
+ // dead session landing in whatever now owns that number. Every frame carries it, and
42
+ // selection matches the FULL session id -- never the 8-char prefix the wire uses
43
+ // elsewhere, because a prefix is exactly the ambiguity the pin exists to remove.
44
+ //
45
+ // A SEND IS NOT A DELIVERY. Writing bytes into a socket proves nothing: the receiver
46
+ // may drop the frame on the pin, hold it behind its own permission policy, or be
47
+ // shutting down. So this module reports `delivered` only when the session's own
48
+ // transcript shows it accepted the message. Claude Code writes a `queue-operation`
49
+ // row with `operation: 'enqueue'` and the verbatim content the instant it accepts one
50
+ // -- before the turn runs -- so acceptance is provable in milliseconds even when the
51
+ // session is mid-turn and will not answer for minutes. Anything else is `unverified`,
52
+ // and unverified falls back to the spawn path that works today.
53
+ //
54
+ // FAILS TOWARD THE PATH THAT ALREADY WORKS. Every refusal in this file -- flag off,
55
+ // no record, no socket, unknown protocol, connect error, write error, no acceptance
56
+ // row -- returns `ok: false` with a reason, and the caller spawns the resume child
57
+ // exactly as 6.48.2 does. This module can only ever make delivery BETTER; it has no
58
+ // branch that can make it worse.
59
+
60
+ import { connect } from 'node:net'
61
+
62
+ /** The only protocol this file knows how to speak. Anything else falls back. */
63
+ export const PEER_INBOX_PROTOCOL = 1
64
+
65
+ /** How long to wait for the connection itself. A live socket accepts immediately;
66
+ * a stale path fails with ENOENT/ECONNREFUSED in microseconds. */
67
+ export const PEER_CONNECT_TIMEOUT_MS = 1_500
68
+
69
+ /** How long to wait for the session's transcript to show acceptance.
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
77
+
78
+ /** Transcript poll interval while waiting for acceptance. */
79
+ export const PEER_VERIFY_POLL_MS = 250
80
+
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. */
83
+ export const PEER_ACCEPTANCE_BACKDATE_MS = 2_000
84
+
85
+ /** Enough of the prompt to identify the row without depending on the whole of it
86
+ * surviving verbatim (a long paste may be elided in some writers). */
87
+ export const PEER_MARKER_CHARS = 120
88
+
89
+ /** One live session as the registry describes it. */
90
+ export interface PeerInboxRecord {
91
+ pid: number
92
+ /** FULL session id. A prefix is never enough here; see the header. */
93
+ sessionId: string
94
+ socketPath: string | null
95
+ peerProtocol: number | null
96
+ entrypoint: string | null
97
+ status: string | null
98
+ }
99
+
100
+ export type PeerDeliveryReason =
101
+ | 'delivered'
102
+ | 'disabled'
103
+ | 'not_claude'
104
+ | 'no_record'
105
+ | 'no_socket'
106
+ | 'protocol_unsupported'
107
+ | 'connect_failed'
108
+ | 'write_failed'
109
+ | 'unverified'
110
+ | 'suspect_expired'
111
+
112
+ /** How the transcript proved it. `enqueue` is the fast path; `user_row` covers a
113
+ * session that admitted the turn straight into a running turn. */
114
+ export type PeerAcceptanceKind = 'enqueue' | 'user_row'
115
+
116
+ export interface PeerDeliveryResult {
117
+ ok: boolean
118
+ reason: PeerDeliveryReason
119
+ /** Null unless `ok`. */
120
+ verifiedBy: PeerAcceptanceKind | null
121
+ /** Wall clock from first connect attempt to verdict, for the health row. */
122
+ elapsedMs: number
123
+ /** The pid the frame was addressed to, for the audit line. Null when none was chosen. */
124
+ pid: number | null
125
+ }
126
+
127
+ /**
128
+ * Which live record, if any, should receive this turn.
129
+ *
130
+ * FULL-ID EQUALITY, AND THE NON-COS RECORD WINS. A COS-spawned resume child
131
+ * registers under the SAME session id as the tab it resumed (6.48.1 found this the
132
+ * hard way), so an id can name two live processes: the user's window and a child of
133
+ * ours. Delivering into our own short-lived child would paint nothing and race the
134
+ * child's own exit, so a record whose pid the ownership store claims is skipped
135
+ * whenever another record is available -- and skipped entirely rather than preferred,
136
+ * because a turn delivered to a process that is about to exit is worse than one that
137
+ * falls back to a fresh child.
138
+ */
139
+ export function selectPeerInbox(
140
+ sessionId: string,
141
+ records: readonly PeerInboxRecord[],
142
+ isCosOwnedPid: (pid: number) => boolean = () => false,
143
+ ): PeerInboxRecord | null {
144
+ const id = String(sessionId || '').trim()
145
+ if (id.length === 0) return null
146
+ const matches = records.filter(r =>
147
+ r.sessionId === id
148
+ && typeof r.socketPath === 'string'
149
+ && r.socketPath.length > 0
150
+ && Number.isInteger(r.pid)
151
+ && r.pid > 0)
152
+ if (matches.length === 0) return null
153
+ const theirs = matches.filter(r => !isCosOwnedPid(r.pid))
154
+ const pool = theirs.length > 0 ? theirs : []
155
+ 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]
159
+ return chosen ?? null
160
+ }
161
+
162
+ /**
163
+ * The two lines that go on the wire, in order.
164
+ *
165
+ * The auth line is omitted entirely when no token was readable, rather than sent
166
+ * empty: an empty token is a failed auth attempt, and a missing one is the
167
+ * unauthenticated path Claude Code documents as acceptable.
168
+ */
169
+ export function peerInboxFrames(sessionId: string, prompt: string, token: string | null): string[] {
170
+ const frames: string[] = []
171
+ if (typeof token === 'string' && token.length > 0) {
172
+ frames.push(JSON.stringify({ type: 'auth', token }))
173
+ }
174
+ frames.push(JSON.stringify({
175
+ type: 'user',
176
+ session_id: sessionId,
177
+ message: { role: 'user', content: prompt },
178
+ }))
179
+ return frames
180
+ }
181
+
182
+ /** The identifying slice of the prompt, as it will appear in the transcript row. */
183
+ export function peerAcceptanceMarker(prompt: string): string {
184
+ return String(prompt ?? '').slice(0, PEER_MARKER_CHARS)
185
+ }
186
+
187
+ /**
188
+ * A send whose acceptance was never seen.
189
+ *
190
+ * NEVER RESENT, NEVER SPAWNED OVER, until it expires. A frame that was written but not
191
+ * verified is in one of four places: dropped by the receiver, held behind its
192
+ * permission policy, lost with a dying process, or accepted with the transcript row
193
+ * still on its way. Three of those must not get a second copy, and the fourth turns
194
+ * 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.
199
+ */
200
+ export const SUSPECT_TTL_MS = 60_000
201
+
202
+ interface SuspectSend { marker: string; sentAtMs: number }
203
+ const suspects = new Map<string, SuspectSend>()
204
+
205
+ function suspectKey(sessionId: string, marker: string): string {
206
+ return `${sessionId}\u0000${marker}`
207
+ }
208
+
209
+ /** Test seam. */
210
+ export function __resetPeerInboxSuspects(): void {
211
+ suspects.clear()
212
+ }
213
+
214
+ /** One transcript row, reduced to what acceptance needs. */
215
+ export interface TranscriptRowLike {
216
+ type?: unknown
217
+ operation?: unknown
218
+ content?: unknown
219
+ message?: unknown
220
+ timestamp?: unknown
221
+ sessionId?: unknown
222
+ }
223
+
224
+ function rowText(row: TranscriptRowLike): string {
225
+ if (typeof row.content === 'string') return row.content
226
+ const message = row.message
227
+ if (message && typeof message === 'object') {
228
+ const content = (message as { content?: unknown }).content
229
+ if (typeof content === 'string') return content
230
+ if (Array.isArray(content)) {
231
+ return content
232
+ .map(part => (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string')
233
+ ? (part as { text: string }).text
234
+ : '')
235
+ .join(' ')
236
+ }
237
+ }
238
+ return ''
239
+ }
240
+
241
+ function rowTimeMs(row: TranscriptRowLike): number | null {
242
+ if (typeof row.timestamp !== 'string') return null
243
+ const ms = Date.parse(row.timestamp)
244
+ return Number.isFinite(ms) ? ms : null
245
+ }
246
+
247
+ /**
248
+ * Did these rows accept our message?
249
+ *
250
+ * PURE, so the contract can drive it over recorded rows. An `enqueue` row is the
251
+ * proof Claude Code writes the moment it takes a message; a `user` row carrying the
252
+ * same text is the same proof one step later. Rows that predate the send cannot be
253
+ * evidence of it -- without that clause a retry of the same prompt would "verify"
254
+ * against the previous delivery's own row and report a send that never happened.
255
+ */
256
+ export function peerAcceptanceIn(
257
+ rows: readonly TranscriptRowLike[],
258
+ marker: string,
259
+ sentAtMs: number,
260
+ ): PeerAcceptanceKind | null {
261
+ if (!marker) return null
262
+ const floor = sentAtMs - PEER_ACCEPTANCE_BACKDATE_MS
263
+ for (let i = rows.length - 1; i >= 0; i--) {
264
+ const row = rows[i]
265
+ const at = rowTimeMs(row)
266
+ if (at !== null && at < floor) continue
267
+ if (!rowText(row).includes(marker)) continue
268
+ if (row.type === 'queue-operation' && row.operation === 'enqueue') return 'enqueue'
269
+ if (row.type === 'user') return 'user_row'
270
+ }
271
+ return null
272
+ }
273
+
274
+ export interface PeerInboxDeps {
275
+ /** Live registry records. The caller owns the read so this file stays pure of paths. */
276
+ records: () => readonly PeerInboxRecord[] | Promise<readonly PeerInboxRecord[]>
277
+ /** The session's peer token, or null when unreadable. Absence is not a failure. */
278
+ 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[]>
281
+ /** Pids COS itself spawned, so a resume child never receives the turn meant for the window. */
282
+ isCosOwnedPid?: (pid: number) => boolean
283
+ now?: () => number
284
+ sleep?: (ms: number) => Promise<void>
285
+ /** Test seam. The real one opens the unix socket. */
286
+ send?: (socketPath: string, frames: readonly string[]) => Promise<void>
287
+ log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
288
+ }
289
+
290
+ export interface PeerInboxRequest {
291
+ provider: string
292
+ sessionId: string
293
+ prompt: string
294
+ enabled: boolean
295
+ verifyTimeoutMs?: number
296
+ }
297
+
298
+ function defaultSleep(ms: number): Promise<void> {
299
+ return new Promise(resolve => setTimeout(resolve, ms))
300
+ }
301
+
302
+ /** Open the socket, write the frames, close. Rejects on anything that is not a clean write. */
303
+ export function sendOverPeerSocket(socketPath: string, frames: readonly string[]): Promise<void> {
304
+ return new Promise((resolve, reject) => {
305
+ let settled = false
306
+ const finish = (error?: Error): void => {
307
+ if (settled) return
308
+ settled = true
309
+ try { socket.destroy() } catch { /* already gone */ }
310
+ if (error) reject(error)
311
+ else resolve()
312
+ }
313
+ const socket = connect(socketPath)
314
+ const timer = setTimeout(() => finish(new Error('connect_timeout')), PEER_CONNECT_TIMEOUT_MS)
315
+ timer.unref?.()
316
+ socket.on('error', error => { clearTimeout(timer); finish(error instanceof Error ? error : new Error('socket_error')) })
317
+ socket.on('connect', () => {
318
+ clearTimeout(timer)
319
+ try {
320
+ // One write, so a half-written pair cannot leave an auth line without its
321
+ // message on a connection the receiver closes for silence.
322
+ socket.write(frames.map(line => `${line}\n`).join(''), error => {
323
+ if (error) { finish(error); return }
324
+ // The receiver reads to end-of-line and acts; nothing is written back on
325
+ // this protocol, so the send is complete once the bytes are flushed.
326
+ socket.end()
327
+ finish()
328
+ })
329
+ } catch (error) {
330
+ finish(error instanceof Error ? error : new Error('write_failed'))
331
+ }
332
+ })
333
+ })
334
+ }
335
+
336
+ /**
337
+ * Try to put this turn into the running session. Never throws.
338
+ *
339
+ * The caller treats `ok: false` as "use the spawn path", which is what 6.48.2 does
340
+ * today for every turn, so a false here costs nothing that was not already the cost.
341
+ */
342
+ export async function deliverOverPeerInbox(
343
+ request: PeerInboxRequest,
344
+ deps: PeerInboxDeps,
345
+ ): Promise<PeerDeliveryResult> {
346
+ const now = deps.now ?? (() => Date.now())
347
+ const sleep = deps.sleep ?? defaultSleep
348
+ const send = deps.send ?? sendOverPeerSocket
349
+ const log = deps.log ?? (() => {})
350
+ const startedAt = now()
351
+ const done = (ok: boolean, reason: PeerDeliveryReason, verifiedBy: PeerAcceptanceKind | null, pid: number | null): PeerDeliveryResult => {
352
+ const result: PeerDeliveryResult = { ok, reason, verifiedBy, elapsedMs: Math.max(0, now() - startedAt), pid }
353
+ log(ok ? 'info' : 'warn', 'peer_inbox.delivery', { ...result, session: request.sessionId.slice(0, 8) })
354
+ return result
355
+ }
356
+
357
+ if (!request.enabled) return done(false, 'disabled', null, null)
358
+ // Codex and Cursor sessions have no Claude Code inbox. They are not a failure here,
359
+ // they are simply not this transport's business.
360
+ if (request.provider !== 'claude') return done(false, 'not_claude', null, null)
361
+ if (typeof request.prompt !== 'string' || request.prompt.trim().length === 0) return done(false, 'no_record', null, null)
362
+
363
+ let records: readonly PeerInboxRecord[]
364
+ try {
365
+ records = await deps.records()
366
+ } catch {
367
+ return done(false, 'no_record', null, null)
368
+ }
369
+ const target = selectPeerInbox(request.sessionId, records, deps.isCosOwnedPid)
370
+ if (!target) return done(false, 'no_record', null, null)
371
+ if (!target.socketPath) return done(false, 'no_socket', null, target.pid)
372
+ // An unknown protocol is refused rather than attempted: the frames below are the
373
+ // ones protocol 1 documents, and guessing at a successor is how a turn gets eaten.
374
+ if (target.peerProtocol !== null && target.peerProtocol !== PEER_INBOX_PROTOCOL) {
375
+ return done(false, 'protocol_unsupported', null, target.pid)
376
+ }
377
+
378
+ const marker = peerAcceptanceMarker(request.prompt)
379
+ const key = suspectKey(request.sessionId, marker)
380
+
381
+ // A retry of a send that was never verified: look before writing anything.
382
+ const suspect = suspects.get(key)
383
+ if (suspect) {
384
+ let rows: readonly TranscriptRowLike[] = []
385
+ try { rows = await deps.transcriptTail(request.sessionId) } catch { rows = [] }
386
+ const seen = peerAcceptanceIn(rows, marker, suspect.sentAtMs)
387
+ if (seen) {
388
+ suspects.delete(key)
389
+ return done(true, 'delivered', seen, target.pid)
390
+ }
391
+ if (now() - suspect.sentAtMs < SUSPECT_TTL_MS) return done(false, 'unverified', null, target.pid)
392
+ suspects.delete(key)
393
+ return done(false, 'suspect_expired', null, target.pid)
394
+ }
395
+
396
+ let token: string | null = null
397
+ try { token = await deps.token(target) } catch { token = null }
398
+
399
+ const sentAt = now()
400
+ try {
401
+ await send(target.socketPath, peerInboxFrames(target.sessionId, request.prompt, token))
402
+ } catch (error) {
403
+ 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 })
409
+ return done(false, 'write_failed', null, target.pid)
410
+ }
411
+
412
+ const deadline = sentAt + Math.max(0, request.verifyTimeoutMs ?? PEER_VERIFY_TIMEOUT_MS)
413
+ for (;;) {
414
+ let rows: readonly TranscriptRowLike[] = []
415
+ try { rows = await deps.transcriptTail(request.sessionId) } catch { rows = [] }
416
+ const seen = peerAcceptanceIn(rows, marker, sentAt)
417
+ if (seen) return done(true, 'delivered', seen, target.pid)
418
+ if (now() >= deadline) break
419
+ await sleep(PEER_VERIFY_POLL_MS)
420
+ }
421
+ suspects.set(key, { marker, sentAtMs: sentAt })
422
+ return done(false, 'unverified', null, target.pid)
423
+ }
@@ -34,9 +34,29 @@ export type SessionStreamDraft =
34
34
  // predates this renders exactly as it did before rather than breaking.
35
35
  | { kind: 'prompt'; text: string }
36
36
  | { kind: 'prose'; text: string }
37
- | { kind: 'status'; state: SessionStreamState }
37
+ // 6.49.0: `tool_outcome` rides on a status draft, never on a new kind. A shipped
38
+ // client drops an unknown kind BEFORE seq accounting and paints a gap row for it
39
+ // (the B2 finding); the same client takes a repeated `status: working` as a no-op
40
+ // (`event.state !== status` is false, nothing appended, no step counted). So the
41
+ // result of a tool arrives on the one channel every client already survives.
42
+ | { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome }
38
43
  | { kind: 'heartbeat' }
39
44
 
45
+ /**
46
+ * What a finished tool produced, in the words a lens line has room for.
47
+ *
48
+ * `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`,
50
+ * `exit 1`). Derived from the transcript's own `toolUseResult`, which is shape-keyed
51
+ * (a Read result carries `file.numLines`, a Bash result `stdout`), so no tool name
52
+ * and no hook is needed: the same row that announces the call carries its answer a
53
+ * few lines later, in order. Nothing here is prompt text or file content.
54
+ */
55
+ export interface ToolOutcome {
56
+ ok: boolean
57
+ detail: string
58
+ }
59
+
40
60
  /** A draft plus the transport's stamps. This is the JSON on the wire. */
41
61
  export type SessionStreamEvent = SessionStreamDraft & { seq: number; at: number }
42
62
 
@@ -243,6 +263,103 @@ export function detailForTool(name: unknown, input: unknown): string {
243
263
  return ''
244
264
  }
245
265
 
266
+ /**
267
+ * The outcome token for one tool result, from the transcript row that carries it.
268
+ *
269
+ * SHAPE-KEYED, because the user row that holds a result names no tool: only the
270
+ * `tool_use_id` it answers. The structured `toolUseResult` is distinctive enough per
271
+ * tool (measured across this Mac's transcripts, 2026-09-16) that the shape is the
272
+ * name. Every branch yields a token from a COUNT or a STATUS, never from content:
273
+ * the client has 40 characters for it and the ledger keeps no output.
274
+ *
275
+ * Nothing worth saying yields `ok` with an empty detail, which the client renders
276
+ * as nothing. A result the model marked `is_error` is `ok: false` with the first
277
+ * words of the error, since `exit 1` or `not found` is the whole point of looking.
278
+ */
279
+ export function outcomeForToolResult(
280
+ isError: boolean,
281
+ resultText: string,
282
+ structured: unknown,
283
+ ): ToolOutcome {
284
+ const text = typeof resultText === 'string' ? resultText : ''
285
+ if (isError) {
286
+ const exit = /^\s*Exit code (\d+)/.exec(text)
287
+ if (exit) return { ok: false, detail: `exit ${exit[1]}` }
288
+ const tag = /<tool_use_error>\s*([^<\n]{1,40})/.exec(text)
289
+ if (tag) return { ok: false, detail: oneLine(tag[1], DETAIL_MAX_CHARS) }
290
+ const rejected = /rejected|doesn't want to proceed|denied/i.test(text)
291
+ return { ok: false, detail: rejected ? 'denied' : oneLine(text, DETAIL_MAX_CHARS) || 'error' }
292
+ }
293
+ const r = asRecord(structured)
294
+ if (r) {
295
+ // Read: `{ type: 'text', file: { numLines, totalLines } }`; an image `{ type: 'image' }`.
296
+ const file = asRecord(r.file)
297
+ if (file && typeof file.numLines === 'number') {
298
+ const total = typeof file.totalLines === 'number' && file.totalLines > file.numLines ? ` of ${file.totalLines}` : ''
299
+ return { ok: true, detail: `${file.numLines}${total} lines` }
300
+ }
301
+ if (r.type === 'image') return { ok: true, detail: 'image' }
302
+ // Write: `{ type: 'create' | 'update', filePath }`.
303
+ if (r.type === 'create') return { ok: true, detail: 'created' }
304
+ if (r.type === 'update' && typeof r.filePath === 'string') return { ok: true, detail: 'written' }
305
+ // Edit: `{ oldString, newString, structuredPatch }` and no `type`.
306
+ if (Array.isArray(r.structuredPatch) && typeof r.oldString === 'string') {
307
+ const removed = countLines(r.oldString)
308
+ const added = countLines(r.newString)
309
+ return { ok: true, detail: removed === 0 && added === 0 ? 'edited' : `+${added} -${removed}` }
310
+ }
311
+ // Bash: `{ stdout, stderr, interrupted }`.
312
+ if (typeof r.stdout === 'string' && typeof r.stderr === 'string') {
313
+ if (r.interrupted === true) return { ok: false, detail: 'interrupted' }
314
+ const out = countLines(r.stdout)
315
+ const err = countLines(r.stderr)
316
+ if (out === 0 && err === 0) return { ok: true, detail: 'no output' }
317
+ if (out === 0) return { ok: true, detail: `stderr ${err} line${err === 1 ? '' : 's'}` }
318
+ return { ok: true, detail: `${out} line${out === 1 ? '' : 's'}` }
319
+ }
320
+ // Grep / Glob: counts when the tool gives them.
321
+ if (typeof r.numFiles === 'number') return { ok: true, detail: `${r.numFiles} file${r.numFiles === 1 ? '' : 's'}` }
322
+ if (typeof r.numMatches === 'number') return { ok: true, detail: `${r.numMatches} hit${r.numMatches === 1 ? '' : 's'}` }
323
+ if (Array.isArray(r.filenames)) return { ok: true, detail: `${r.filenames.length} file${r.filenames.length === 1 ? '' : 's'}` }
324
+ // WebFetch: `{ code, bytes, durationMs }`. WebSearch: `{ searchCount, results }`.
325
+ if (typeof r.code === 'number' && typeof r.bytes === 'number') {
326
+ return { ok: r.code < 400, detail: `${r.code} · ${Math.max(1, Math.round(r.bytes / 1024))} KB` }
327
+ }
328
+ if (Array.isArray(r.results) && typeof r.searchCount === 'number') {
329
+ return { ok: true, detail: `${r.results.length} result${r.results.length === 1 ? '' : 's'}` }
330
+ }
331
+ // Agent / teammate spawn.
332
+ if (r.status === 'teammate_spawned' || typeof r.agent_id === 'string') return { ok: true, detail: 'spawned' }
333
+ }
334
+ // Grep's plain-text answer, when the structured form did not carry the count.
335
+ const found = /Found (\d+) (files?|matches?)/i.exec(text)
336
+ if (found) return { ok: true, detail: `${found[1]} ${found[2].startsWith('file') ? 'file' : 'hit'}${found[1] === '1' ? '' : 's'}` }
337
+ if (/^No (files|matches) found/i.test(text)) return { ok: true, detail: 'none' }
338
+ return { ok: true, detail: '' }
339
+ }
340
+
341
+ /** The result blocks of a user record, paired with the row's structured result. */
342
+ export function outcomeDrafts(record: Record<string, unknown>, message: Record<string, unknown>): SessionStreamDraft[] {
343
+ const content = message.content
344
+ if (!Array.isArray(content)) return []
345
+ const out: SessionStreamDraft[] = []
346
+ for (const raw of content) {
347
+ const block = asRecord(raw)
348
+ if (!block || block.type !== 'tool_result') continue
349
+ const inner = block.content
350
+ const text = typeof inner === 'string'
351
+ ? inner
352
+ : Array.isArray(inner)
353
+ ? inner.map(part => (asRecord(part)?.text as string | undefined) ?? '').join(' ')
354
+ : ''
355
+ const outcome = outcomeForToolResult(block.is_error === true, text, record.toolUseResult)
356
+ // A tool result means the turn is working by definition: the model called a
357
+ // tool and is about to read what came back.
358
+ out.push({ kind: 'status', state: 'working', tool_outcome: outcome })
359
+ }
360
+ return out
361
+ }
362
+
246
363
  function toolDraft(name: unknown, input: unknown): SessionStreamDraft {
247
364
  const verb = verbForToolName(name)
248
365
  const target = targetForTool(name, input)
@@ -351,10 +468,14 @@ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamD
351
468
  // the glasses have never seen it. Dropping it is what made the live view a blank
352
469
  // slate that said WORKING and nothing else.
353
470
  //
354
- // Tool results stay dropped -- the call was already announced.
471
+ // A tool result is not a prompt, but since 6.49.0 it is not dropped either: it
472
+ // becomes the OUTCOME of the call announced a few rows earlier, on a status draft
473
+ // (see `ToolOutcome`). The prompt path still sees nothing for it.
355
474
  if (type === 'user') {
356
475
  const message = asRecord(record.message)
357
- return message ? promptDrafts(message) : []
476
+ if (!message) return []
477
+ const outcomes = outcomeDrafts(record, message)
478
+ return outcomes.length > 0 ? outcomes : promptDrafts(message)
358
479
  }
359
480
 
360
481
  const role = typeof record.role === 'string' ? record.role : ''
@@ -577,10 +698,10 @@ export interface DerivedStatusFields {
577
698
  last_reply?: string
578
699
  }
579
700
 
580
- export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState } & Partial<DerivedStatusFields>
701
+ export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome } & Partial<DerivedStatusFields>
581
702
 
582
703
  export function statusDraftWithDerived(
583
- draft: { kind: 'status'; state: SessionStreamState },
704
+ draft: { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome },
584
705
  derived: DerivedStatusFields | undefined,
585
706
  ): StatusDraftWithDerived {
586
707
  if (!derived) return draft
@@ -591,6 +712,8 @@ export function statusDraftWithDerived(
591
712
  return {
592
713
  kind: 'status',
593
714
  state,
715
+ // 6.49.0: the outcome is the draft's own fact, not the deriver's; it rides through.
716
+ ...(draft.tool_outcome ? { tool_outcome: draft.tool_outcome } : {}),
594
717
  agent_state: derived.agent_state,
595
718
  state_source: derived.state_source,
596
719
  state_since: derived.state_since,
@@ -153,12 +153,22 @@ export function transcriptTurnEnded(provider: SessionStreamProvider, path: strin
153
153
 
154
154
  /** The bounded tail as lines, with the same two open flags; null when it cannot be read. */
155
155
  function readTail(path: string | null): string[] | null {
156
+ return readTranscriptTailLines(path, TURN_END_TAIL_BYTES)
157
+ }
158
+
159
+ /**
160
+ * The last `maxBytes` of a transcript as lines, null when unreadable. Exported (6.49.0)
161
+ * so the live-delivery verifier reads the transcript the same way the drain does: the
162
+ * same open flags, the same fragment rule. Two readers with different rules would
163
+ * disagree about the same file on the day it mattered.
164
+ */
165
+ export function readTranscriptTailLines(path: string | null, maxBytes: number): string[] | null {
156
166
  if (!path || !existsSync(path)) return null
157
167
  try {
158
168
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
159
169
  try {
160
170
  const size = fstatSync(fd).size
161
- const start = Math.max(0, size - TURN_END_TAIL_BYTES)
171
+ const start = Math.max(0, size - maxBytes)
162
172
  const buf = Buffer.alloc(size - start)
163
173
  readSync(fd, buf, 0, buf.length, start)
164
174
  const lines = buf.toString('utf-8').split('\n')
@@ -333,6 +333,16 @@ export interface AgentSessionBindingsDeps {
333
333
  /** `deliverAttachedTurn` from `server/lib/attached-provider-adapter.ts`. */
334
334
  deliverAttachedTurn?: (request: AttachedTurnRequest) => Promise<unknown> | unknown
335
335
 
336
+ /**
337
+ * 6.49.0: put the turn INTO the running session before spawning a child for it.
338
+ *
339
+ * `makeLiveTurnDeliverer` from `server/lib/session-peer-inbox-deps.ts`. Optional so
340
+ * every existing test and every older wiring is byte-for-byte the 6.48.2 route; when
341
+ * absent, or when it answers anything but `ok`, the spawn path below runs unchanged.
342
+ * Only `reason` is read here; the vocabulary is the pure module's.
343
+ */
344
+ deliverLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null }>
345
+
336
346
  /**
337
347
  * The self-recursion ledger. Defaults to the real process-wide one.
338
348
  *
@@ -483,6 +493,10 @@ export type WriteRefusal =
483
493
  | 'provider_never_opened'
484
494
  | 'delivery_ambiguous'
485
495
  | 'turn_failed'
496
+ // 6.49.0: the live transport wrote a frame it could not see accepted. The turn is
497
+ // held as retryable and the retry re-reads before doing anything (see
498
+ // `session-peer-inbox.ts`); it is never a fence, because nothing here ran a child.
499
+ | 'live_unverified'
486
500
  // ------------------------------------------------------------------- fork
487
501
  //
488
502
  // Fork gets its OWN members rather than reusing the ones above, and the reason
@@ -555,6 +569,8 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
555
569
  'The assistant never opened the thread, so nothing was sent. You can try again.',
556
570
  delivery_ambiguous:
557
571
  'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
572
+ live_unverified:
573
+ 'Sent to the open session, but not yet confirmed. COS will check again in a moment.',
558
574
  turn_failed:
559
575
  'COS could not run this turn. Nothing was sent. You can try again.',
560
576
 
@@ -1265,6 +1281,18 @@ function plainBody(req: Request): Record<string, unknown> | null {
1265
1281
 
1266
1282
  export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
1267
1283
  export const TURN_SENT_COPY = 'Sent to the original thread.'
1284
+ /** 6.49.0: the turn went into the running session, where the desk will see it. */
1285
+ export const TURN_SENT_LIVE_COPY = 'Sent to the open session. It will show where you left it.'
1286
+ /**
1287
+ * How long the route waits for the session's transcript to show acceptance.
1288
+ *
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
1292
+ * budget only bites when the receiver is holding or dying, and those end as
1293
+ * `live_unverified`, which the client may retry.
1294
+ */
1295
+ export const LIVE_VERIFY_BUDGET_MS = 4_000
1268
1296
 
1269
1297
  /**
1270
1298
  * The 202 copy. Says QUEUED and not sent, because at this instant the provider has
@@ -2125,6 +2153,56 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2125
2153
  if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
2126
2154
  pinnedBindingId = bindingId
2127
2155
 
2156
+ // 6.49.0: LIVE FIRST. If the session's own process is running -- a Desktop tab,
2157
+ // a `claude` in iTerm, the same registry record either way -- the turn goes into
2158
+ // it and paints where the user left the window, instead of into a resume child
2159
+ // whose reply that window never shows. Before the 202 on purpose: a live delivery
2160
+ // is a `completed` turn (terminal, ledgered, replay-safe), and a frame that could
2161
+ // not be verified is a PRE-202 refusal, which the ledger does not record, so the
2162
+ // client's retry re-evaluates rather than replaying a stale "no". The gate above
2163
+ // still ran in full: this is the same admission, with a different last hop.
2164
+ //
2165
+ // The head is NOT acknowledged here. The session will move it when it answers,
2166
+ // on its own clock; the next interactive turn reads that as a change and asks,
2167
+ // which is the conservative side. Queued turns attach fresh and never notice.
2168
+ if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
2169
+ let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
2170
+ try {
2171
+ live = await deps.deliverLiveTurn({
2172
+ provider: binding.provider,
2173
+ sessionId: binding.nativeThreadId,
2174
+ prompt,
2175
+ verifyTimeoutMs: LIVE_VERIFY_BUDGET_MS,
2176
+ })
2177
+ } catch (error) {
2178
+ // A throw is a bug in the transport, not a fact about the session; the
2179
+ // spawn path is the answer to both.
2180
+ console.error(`[agent-session-bindings] live delivery threw: ${error instanceof Error ? error.message : error}`)
2181
+ live = null
2182
+ }
2183
+ if (live?.ok) {
2184
+ console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
2185
+ respond(200, {
2186
+ turnId,
2187
+ outcome: 'completed',
2188
+ deliveryState: 'delivered',
2189
+ via: 'live',
2190
+ retryable: false,
2191
+ changed: false,
2192
+ revision: null,
2193
+ reason: null,
2194
+ reasonCopy: TURN_SENT_LIVE_COPY,
2195
+ })
2196
+ return
2197
+ }
2198
+ if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
2199
+ // Something may be in the session. Hold, never spawn over it.
2200
+ return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
2201
+ }
2202
+ // disabled / not_claude / no_record / no_socket / protocol_unsupported /
2203
+ // connect_failed / suspect_expired: nothing reached a session. Spawn as always.
2204
+ }
2205
+
2128
2206
  // THE QUEUE POINT. Every gate is now behind us — body, replay, queued-prompt,
2129
2207
  // lease, target, epoch, fence, claim, occupancy, head baseline, pin — so a
2130
2208
  // refusal still reaches the user immediately and precisely. Only the spawn is
@@ -80,6 +80,29 @@ import {
80
80
  threadAttachHealthFields,
81
81
  } from '../lib/thread-attach-capability.js'
82
82
  import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
83
+ import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
84
+
85
+ /**
86
+ * 6.49.0: which last hop a Continue takes, and how often each fell back.
87
+ *
88
+ * `enabled` is the flag as read RIGHT NOW; the counters are this process's. A
89
+ * reader who sees `enabled: true` and `delivered: 0` with fallbacks under
90
+ * `no_record` has a session registry the server cannot see; under `unverified`,
91
+ * a session that is holding or dying. Both are diagnoses, not guesses.
92
+ */
93
+ export function continueLiveHealthFields(): Record<string, unknown> {
94
+ const stats = liveDeliveryStats()
95
+ return {
96
+ continueLive: {
97
+ enabled: continueLiveEnabled(),
98
+ attempts: stats.attempts,
99
+ delivered: stats.delivered,
100
+ fallbacks: stats.fallbacks,
101
+ lastReason: stats.lastReason,
102
+ lastAt: stats.lastAt,
103
+ },
104
+ }
105
+ }
83
106
 
84
107
  export const healthRouter = Router()
85
108
 
@@ -351,6 +374,7 @@ healthRouter.get('/health', async (_req, res) => {
351
374
  server_version: managedServerVersion(),
352
375
  ...threadAttachHealthFields(threadAttach),
353
376
  ...sessionHooksHealthFields(),
377
+ ...continueLiveHealthFields(),
354
378
  server_instance_id: getServerInstanceId(),
355
379
  boot_id: serverMetrics.bootId,
356
380
  generation_id: getServerGenerationId(),