@gotcos/glasses-server 6.48.2 → 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.
@@ -0,0 +1,498 @@
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
+ * 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
80
+
81
+ /** Transcript poll interval while waiting for acceptance. */
82
+ export const PEER_VERIFY_POLL_MS = 250
83
+
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. */
92
+ export const PEER_ACCEPTANCE_BACKDATE_MS = 2_000
93
+
94
+ /** Enough of the prompt to identify the row without depending on the whole of it
95
+ * surviving verbatim (a long paste may be elided in some writers). */
96
+ export const PEER_MARKER_CHARS = 120
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
+
108
+ /** One live session as the registry describes it. */
109
+ export interface PeerInboxRecord {
110
+ pid: number
111
+ /** FULL session id. A prefix is never enough here; see the header. */
112
+ sessionId: string
113
+ socketPath: string | null
114
+ peerProtocol: number | null
115
+ entrypoint: string | null
116
+ status: string | null
117
+ /** Epoch ms the process started (registry `startedAt`), for the newest-window rule (6.49.1). */
118
+ startedAt?: number | null
119
+ }
120
+
121
+ export type PeerDeliveryReason =
122
+ | 'delivered'
123
+ | 'disabled'
124
+ | 'not_claude'
125
+ | 'no_record'
126
+ | 'protocol_unsupported'
127
+ | 'connect_failed'
128
+ | 'write_failed'
129
+ | 'unverified'
130
+ | 'suspect_expired'
131
+
132
+ /** How the transcript proved it. `enqueue` is the fast path; `user_row` covers a
133
+ * session that admitted the turn straight into a running turn. */
134
+ export type PeerAcceptanceKind = 'enqueue' | 'user_row'
135
+
136
+ export interface PeerDeliveryResult {
137
+ ok: boolean
138
+ reason: PeerDeliveryReason
139
+ /** Null unless `ok`. */
140
+ verifiedBy: PeerAcceptanceKind | null
141
+ /** Wall clock from first connect attempt to verdict, for the health row. */
142
+ elapsedMs: number
143
+ /** The pid the frame was addressed to, for the audit line. Null when none was chosen. */
144
+ pid: number | null
145
+ }
146
+
147
+ /**
148
+ * Which live record, if any, should receive this turn.
149
+ *
150
+ * FULL-ID EQUALITY, AND THE NON-COS RECORD WINS. A COS-spawned resume child
151
+ * registers under the SAME session id as the tab it resumed (6.48.1 found this the
152
+ * hard way), so an id can name two live processes: the user's window and a child of
153
+ * ours. Delivering into our own short-lived child would paint nothing and race the
154
+ * child's own exit, so a record whose pid the ownership store claims is skipped
155
+ * whenever another record is available -- and skipped entirely rather than preferred,
156
+ * because a turn delivered to a process that is about to exit is worse than one that
157
+ * falls back to a fresh child.
158
+ */
159
+ export function selectPeerInbox(
160
+ sessionId: string,
161
+ records: readonly PeerInboxRecord[],
162
+ isCosOwnedPid: (pid: number) => boolean = () => false,
163
+ ): PeerInboxRecord | null {
164
+ const id = String(sessionId || '').trim()
165
+ if (id.length === 0) return null
166
+ const matches = records.filter(r =>
167
+ r.sessionId === id
168
+ && typeof r.socketPath === 'string'
169
+ && r.socketPath.length > 0
170
+ && Number.isInteger(r.pid)
171
+ && r.pid > 0)
172
+ if (matches.length === 0) return null
173
+ const theirs = matches.filter(r => !isCosOwnedPid(r.pid))
174
+ const pool = theirs.length > 0 ? theirs : []
175
+ if (pool.length === 0) return null
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]
186
+ return chosen ?? null
187
+ }
188
+
189
+ /**
190
+ * The two lines that go on the wire, in order.
191
+ *
192
+ * The auth line is omitted entirely when no token was readable, rather than sent
193
+ * empty: an empty token is a failed auth attempt, and a missing one is the
194
+ * unauthenticated path Claude Code documents as acceptable.
195
+ */
196
+ export function peerInboxFrames(sessionId: string, prompt: string, token: string | null): string[] {
197
+ const frames: string[] = []
198
+ if (typeof token === 'string' && token.length > 0) {
199
+ frames.push(JSON.stringify({ type: 'auth', token }))
200
+ }
201
+ frames.push(JSON.stringify({
202
+ type: 'user',
203
+ session_id: sessionId,
204
+ message: { role: 'user', content: prompt },
205
+ }))
206
+ return frames
207
+ }
208
+
209
+ /** The identifying slice of the prompt, as it will appear in the transcript row. */
210
+ export function peerAcceptanceMarker(prompt: string): string {
211
+ return String(prompt ?? '').slice(0, PEER_MARKER_CHARS)
212
+ }
213
+
214
+ /**
215
+ * A send whose acceptance was never seen.
216
+ *
217
+ * NEVER RESENT, NEVER SPAWNED OVER, until it expires. A frame that was written but not
218
+ * verified is in one of four places: dropped by the receiver, held behind its
219
+ * permission policy, lost with a dying process, or accepted with the transcript row
220
+ * still on its way. Three of those must not get a second copy, and the fourth turns
221
+ * into `delivered` on the next look. So the caller refuses the turn as retryable, and
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.
229
+ */
230
+ export const SUSPECT_TTL_MS = 60_000
231
+
232
+ interface SuspectSend { marker: string; sentAtMs: number; sizeAtSend: number | null }
233
+ const suspects = new Map<string, SuspectSend>()
234
+
235
+ function suspectKey(sessionId: string, marker: string): string {
236
+ return `${sessionId}\u0000${marker}`
237
+ }
238
+
239
+ /** Test seam. */
240
+ export function __resetPeerInboxSuspects(): void {
241
+ suspects.clear()
242
+ }
243
+
244
+ /** One transcript row, reduced to what acceptance needs. */
245
+ export interface TranscriptRowLike {
246
+ type?: unknown
247
+ operation?: unknown
248
+ content?: unknown
249
+ message?: unknown
250
+ timestamp?: unknown
251
+ sessionId?: unknown
252
+ }
253
+
254
+ function rowText(row: TranscriptRowLike): string {
255
+ if (typeof row.content === 'string') return row.content
256
+ const message = row.message
257
+ if (message && typeof message === 'object') {
258
+ const content = (message as { content?: unknown }).content
259
+ if (typeof content === 'string') return content
260
+ if (Array.isArray(content)) {
261
+ return content
262
+ .map(part => (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string')
263
+ ? (part as { text: string }).text
264
+ : '')
265
+ .join(' ')
266
+ }
267
+ }
268
+ return ''
269
+ }
270
+
271
+ function rowTimeMs(row: TranscriptRowLike): number | null {
272
+ if (typeof row.timestamp !== 'string') return null
273
+ const ms = Date.parse(row.timestamp)
274
+ return Number.isFinite(ms) ? ms : null
275
+ }
276
+
277
+ /**
278
+ * Did these rows accept our message?
279
+ *
280
+ * PURE, so the contract can drive it over recorded rows. An `enqueue` row is the
281
+ * proof Claude Code writes the moment it takes a message; a `user` row carrying the
282
+ * same text is the same proof one step later. Rows that predate the send cannot be
283
+ * evidence of it -- without that clause a retry of the same prompt would "verify"
284
+ * against the previous delivery's own row and report a send that never happened.
285
+ */
286
+ export function peerAcceptanceIn(
287
+ rows: readonly TranscriptRowLike[],
288
+ marker: string,
289
+ sentAtMs: number,
290
+ ): PeerAcceptanceKind | null {
291
+ if (!marker) return null
292
+ const floor = sentAtMs - PEER_ACCEPTANCE_BACKDATE_MS
293
+ for (let i = rows.length - 1; i >= 0; i--) {
294
+ const row = rows[i]
295
+ const at = rowTimeMs(row)
296
+ if (at !== null && at < floor) continue
297
+ if (!rowText(row).includes(marker)) continue
298
+ if (row.type === 'queue-operation' && row.operation === 'enqueue') return 'enqueue'
299
+ if (row.type === 'user') return 'user_row'
300
+ }
301
+ return null
302
+ }
303
+
304
+ export interface PeerInboxDeps {
305
+ /** Live registry records. The caller owns the read so this file stays pure of paths. */
306
+ records: () => readonly PeerInboxRecord[] | Promise<readonly PeerInboxRecord[]>
307
+ /** The session's peer token, or null when unreadable. Absence is not a failure. */
308
+ token: (record: PeerInboxRecord) => string | null | Promise<string | null>
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>
316
+ /** Pids COS itself spawned, so a resume child never receives the turn meant for the window. */
317
+ isCosOwnedPid?: (pid: number) => boolean
318
+ now?: () => number
319
+ sleep?: (ms: number) => Promise<void>
320
+ /** Test seam. The real one opens the unix socket. */
321
+ send?: (socketPath: string, frames: readonly string[]) => Promise<void>
322
+ log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
323
+ }
324
+
325
+ export interface PeerInboxRequest {
326
+ provider: string
327
+ sessionId: string
328
+ prompt: string
329
+ enabled: boolean
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
336
+ }
337
+
338
+ function defaultSleep(ms: number): Promise<void> {
339
+ return new Promise(resolve => setTimeout(resolve, ms))
340
+ }
341
+
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. */
361
+ export function sendOverPeerSocket(socketPath: string, frames: readonly string[]): Promise<void> {
362
+ return new Promise((resolve, reject) => {
363
+ let settled = false
364
+ let phase: PeerSendPhase = 'connect'
365
+ const finish = (error?: Error): void => {
366
+ if (settled) return
367
+ settled = true
368
+ try { socket.destroy() } catch { /* already gone */ }
369
+ if (error) reject(tagPhase(error, phase))
370
+ else resolve()
371
+ }
372
+ const socket = connect(socketPath)
373
+ const timer = setTimeout(() => finish(new Error('connect_timeout')), PEER_CONNECT_TIMEOUT_MS)
374
+ timer.unref?.()
375
+ socket.on('error', error => { clearTimeout(timer); finish(error instanceof Error ? error : new Error('socket_error')) })
376
+ socket.on('connect', () => {
377
+ clearTimeout(timer)
378
+ phase = 'write'
379
+ try {
380
+ // One write, so a half-written pair cannot leave an auth line without its
381
+ // message on a connection the receiver closes for silence.
382
+ socket.write(frames.map(line => `${line}\n`).join(''), error => {
383
+ if (error) { finish(error); return }
384
+ // The receiver reads to end-of-line and acts; nothing is written back on
385
+ // this protocol, so the send is complete once the bytes are flushed.
386
+ socket.end()
387
+ finish()
388
+ })
389
+ } catch (error) {
390
+ finish(error instanceof Error ? error : new Error('write_failed'))
391
+ }
392
+ })
393
+ })
394
+ }
395
+
396
+ /**
397
+ * Try to put this turn into the running session. Never throws.
398
+ *
399
+ * The caller treats `ok: false` as "use the spawn path", which is what 6.48.2 does
400
+ * today for every turn, so a false here costs nothing that was not already the cost.
401
+ */
402
+ export async function deliverOverPeerInbox(
403
+ request: PeerInboxRequest,
404
+ deps: PeerInboxDeps,
405
+ ): Promise<PeerDeliveryResult> {
406
+ const now = deps.now ?? (() => Date.now())
407
+ const sleep = deps.sleep ?? defaultSleep
408
+ const send = deps.send ?? sendOverPeerSocket
409
+ const log = deps.log ?? (() => {})
410
+ const startedAt = now()
411
+ const done = (ok: boolean, reason: PeerDeliveryReason, verifiedBy: PeerAcceptanceKind | null, pid: number | null): PeerDeliveryResult => {
412
+ const result: PeerDeliveryResult = { ok, reason, verifiedBy, elapsedMs: Math.max(0, now() - startedAt), pid }
413
+ log(ok ? 'info' : 'warn', 'peer_inbox.delivery', { ...result, session: request.sessionId.slice(0, 8) })
414
+ return result
415
+ }
416
+
417
+ if (!request.enabled) return done(false, 'disabled', null, null)
418
+ // Codex and Cursor sessions have no Claude Code inbox. They are not a failure here,
419
+ // they are simply not this transport's business.
420
+ if (request.provider !== 'claude') return done(false, 'not_claude', null, null)
421
+ if (typeof request.prompt !== 'string' || request.prompt.trim().length === 0) return done(false, 'no_record', null, null)
422
+
423
+ let records: readonly PeerInboxRecord[]
424
+ try {
425
+ records = await deps.records()
426
+ } catch {
427
+ return done(false, 'no_record', null, null)
428
+ }
429
+ const target = selectPeerInbox(request.sessionId, records, deps.isCosOwnedPid)
430
+ if (!target) return done(false, 'no_record', null, null)
431
+ // An unknown protocol is refused rather than attempted: the frames below are the
432
+ // ones protocol 1 documents, and guessing at a successor is how a turn gets eaten.
433
+ if (target.peerProtocol !== null && target.peerProtocol !== PEER_INBOX_PROTOCOL) {
434
+ return done(false, 'protocol_unsupported', null, target.pid)
435
+ }
436
+
437
+ const marker = peerAcceptanceMarker(request.prompt)
438
+ const key = suspectKey(request.sessionId, request.clientTurnId ? `id:${request.clientTurnId}` : marker)
439
+
440
+ // A retry of a send that was never verified: look before writing anything.
441
+ const suspect = suspects.get(key)
442
+ if (suspect) {
443
+ const rows = await readSince(request.sessionId, suspect.sizeAtSend)
444
+ const seen = peerAcceptanceIn(rows, marker, suspect.sentAtMs)
445
+ if (seen) {
446
+ suspects.delete(key)
447
+ return done(true, 'delivered', seen, target.pid)
448
+ }
449
+ if (now() - suspect.sentAtMs < SUSPECT_TTL_MS) return done(false, 'unverified', null, target.pid)
450
+ suspects.delete(key)
451
+ return done(false, 'suspect_expired', null, target.pid)
452
+ }
453
+
454
+ let token: string | null = null
455
+ try { token = await deps.token(target) } catch { token = null }
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 }
460
+ const sentAt = now()
461
+ try {
462
+ await send(target.socketPath!, peerInboxFrames(target.sessionId, request.prompt, token))
463
+ } catch (error) {
464
+ const phase = (error as Partial<PeerSendError> | undefined)?.phase
465
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
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 })
473
+ return done(false, 'write_failed', null, target.pid)
474
+ }
475
+
476
+ const deadline = sentAt + Math.max(0, request.verifyTimeoutMs ?? PEER_VERIFY_TIMEOUT_MS)
477
+ for (;;) {
478
+ const rows = await readSince(request.sessionId, sizeAtSend)
479
+ const seen = peerAcceptanceIn(rows, marker, sentAt)
480
+ if (seen) return done(true, 'delivered', seen, target.pid)
481
+ if (now() >= deadline) break
482
+ await sleep(PEER_VERIFY_POLL_MS)
483
+ }
484
+ suspects.set(key, { marker, sentAtMs: sentAt, sizeAtSend })
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
+ }
498
+ }