@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.
@@ -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."
@@ -34,9 +39,40 @@ export type SessionStreamDraft =
34
39
  // predates this renders exactly as it did before rather than breaking.
35
40
  | { kind: 'prompt'; text: string }
36
41
  | { kind: 'prose'; text: string }
37
- | { kind: 'status'; state: SessionStreamState }
42
+ // 6.49.0: `tool_outcome` rides on a status draft, never on a new kind. A shipped
43
+ // client drops an unknown kind BEFORE seq accounting and paints a gap row for it
44
+ // (the B2 finding); the same client takes a repeated `status: working` as a no-op
45
+ // (`event.state !== status` is false, nothing appended, no step counted). So the
46
+ // result of a tool arrives on the one channel every client already survives.
47
+ | { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome }
38
48
  | { kind: 'heartbeat' }
39
49
 
50
+ /**
51
+ * What a finished tool produced, in the words a lens line has room for.
52
+ *
53
+ * `ok` says whether the tool itself reported failure; `detail` is the token the
54
+ * client shows at the end of the step line (`+14 -2`, `41 lines`, `17 hits`,
55
+ * `exit 1`). Derived from the transcript's own `toolUseResult`, which is shape-keyed
56
+ * (a Read result carries `file.numLines`, a Bash result `stdout`), so no tool name
57
+ * and no hook is needed: the same row that announces the call carries its answer a
58
+ * few lines later, in order. Nothing here is prompt text or file content.
59
+ */
60
+ export interface ToolOutcome {
61
+ ok: boolean
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
74
+ }
75
+
40
76
  /** A draft plus the transport's stamps. This is the JSON on the wire. */
41
77
  export type SessionStreamEvent = SessionStreamDraft & { seq: number; at: number }
42
78
 
@@ -243,10 +279,120 @@ export function detailForTool(name: unknown, input: unknown): string {
243
279
  return ''
244
280
  }
245
281
 
246
- function toolDraft(name: unknown, input: unknown): SessionStreamDraft {
282
+ /**
283
+ * The outcome token for one tool result, from the transcript row that carries it.
284
+ *
285
+ * SHAPE-KEYED, because the user row that holds a result names no tool: only the
286
+ * `tool_use_id` it answers. The structured `toolUseResult` is distinctive enough per
287
+ * tool (measured across this Mac's transcripts, 2026-09-16) that the shape is the
288
+ * name. Every branch yields a token from a COUNT or a STATUS, never from content:
289
+ * the client has 40 characters for it and the ledger keeps no output.
290
+ *
291
+ * Nothing worth saying yields `ok` with an empty detail, which the client renders
292
+ * as nothing. A result the model marked `is_error` is `ok: false` with the first
293
+ * words of the error, since `exit 1` or `not found` is the whole point of looking.
294
+ */
295
+ export function outcomeForToolResult(
296
+ isError: boolean,
297
+ resultText: string,
298
+ structured: unknown,
299
+ ): ToolOutcome {
300
+ const text = typeof resultText === 'string' ? resultText : ''
301
+ if (isError) {
302
+ const exit = /^\s*Exit code (\d+)/.exec(text)
303
+ if (exit) return { ok: false, detail: `exit ${exit[1]}` }
304
+ const tag = /<tool_use_error>\s*([^<\n]{1,40})/.exec(text)
305
+ if (tag) return { ok: false, detail: oneLine(tag[1], DETAIL_MAX_CHARS) }
306
+ const rejected = /rejected|doesn't want to proceed|denied/i.test(text)
307
+ return { ok: false, detail: rejected ? 'denied' : oneLine(text, DETAIL_MAX_CHARS) || 'error' }
308
+ }
309
+ const r = asRecord(structured)
310
+ if (r) {
311
+ // Read: `{ type: 'text', file: { numLines, totalLines } }`; an image `{ type: 'image' }`.
312
+ const file = asRecord(r.file)
313
+ if (file && typeof file.numLines === 'number') {
314
+ const total = typeof file.totalLines === 'number' && file.totalLines > file.numLines ? ` of ${file.totalLines}` : ''
315
+ return { ok: true, detail: `${file.numLines}${total} lines` }
316
+ }
317
+ if (r.type === 'image') return { ok: true, detail: 'image' }
318
+ // Write: `{ type: 'create' | 'update', filePath }`.
319
+ if (r.type === 'create') return { ok: true, detail: 'created' }
320
+ if (r.type === 'update' && typeof r.filePath === 'string') return { ok: true, detail: 'written' }
321
+ // Edit: `{ oldString, newString, structuredPatch }` and no `type`.
322
+ if (Array.isArray(r.structuredPatch) && typeof r.oldString === 'string') {
323
+ const removed = countLines(r.oldString)
324
+ const added = countLines(r.newString)
325
+ return { ok: true, detail: removed === 0 && added === 0 ? 'edited' : `+${added} -${removed}` }
326
+ }
327
+ // Bash: `{ stdout, stderr, interrupted }`.
328
+ if (typeof r.stdout === 'string' && typeof r.stderr === 'string') {
329
+ if (r.interrupted === true) return { ok: false, detail: 'interrupted' }
330
+ const out = countLines(r.stdout)
331
+ const err = countLines(r.stderr)
332
+ if (out === 0 && err === 0) return { ok: true, detail: 'no output' }
333
+ if (out === 0) return { ok: true, detail: `stderr ${err} line${err === 1 ? '' : 's'}` }
334
+ return { ok: true, detail: `${out} line${out === 1 ? '' : 's'}` }
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
+ }
343
+ // Grep / Glob: counts when the tool gives them.
344
+ if (typeof r.numFiles === 'number') return { ok: true, detail: `${r.numFiles} file${r.numFiles === 1 ? '' : 's'}` }
345
+ if (typeof r.numMatches === 'number') return { ok: true, detail: `${r.numMatches} hit${r.numMatches === 1 ? '' : 's'}` }
346
+ if (Array.isArray(r.filenames)) return { ok: true, detail: `${r.filenames.length} file${r.filenames.length === 1 ? '' : 's'}` }
347
+ // WebFetch: `{ code, bytes, durationMs }`. WebSearch: `{ searchCount, results }`.
348
+ if (typeof r.code === 'number' && typeof r.bytes === 'number') {
349
+ return { ok: r.code < 400, detail: `${r.code} · ${Math.max(1, Math.round(r.bytes / 1024))} KB` }
350
+ }
351
+ if (Array.isArray(r.results) && typeof r.searchCount === 'number') {
352
+ return { ok: true, detail: `${r.results.length} result${r.results.length === 1 ? '' : 's'}` }
353
+ }
354
+ // Agent / teammate spawn.
355
+ if (r.status === 'teammate_spawned' || typeof r.agent_id === 'string') return { ok: true, detail: 'spawned' }
356
+ }
357
+ // Grep's plain-text answer, when the structured form did not carry the count.
358
+ const found = /Found (\d+) (files?|matches?)/i.exec(text)
359
+ if (found) return { ok: true, detail: `${found[1]} ${found[2].startsWith('file') ? 'file' : 'hit'}${found[1] === '1' ? '' : 's'}` }
360
+ if (/^No (files|matches) found/i.test(text)) return { ok: true, detail: 'none' }
361
+ return { ok: true, detail: '' }
362
+ }
363
+
364
+ /** The result blocks of a user record, paired with the row's structured result. */
365
+ export function outcomeDrafts(record: Record<string, unknown>, message: Record<string, unknown>): SessionStreamDraft[] {
366
+ const content = message.content
367
+ if (!Array.isArray(content)) return []
368
+ const out: SessionStreamDraft[] = []
369
+ for (const raw of content) {
370
+ const block = asRecord(raw)
371
+ if (!block || block.type !== 'tool_result') continue
372
+ const inner = block.content
373
+ const text = typeof inner === 'string'
374
+ ? inner
375
+ : Array.isArray(inner)
376
+ ? inner.map(part => (asRecord(part)?.text as string | undefined) ?? '').join(' ')
377
+ : ''
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)
384
+ // A tool result means the turn is working by definition: the model called a
385
+ // tool and is about to read what came back.
386
+ out.push({ kind: 'status', state: 'working', tool_outcome: call ? { ...outcome, call } : outcome })
387
+ }
388
+ return out
389
+ }
390
+
391
+ function toolDraft(name: unknown, input: unknown, id?: unknown): SessionStreamDraft {
247
392
  const verb = verbForToolName(name)
248
393
  const target = targetForTool(name, input)
249
394
  const readable = typeof name === 'string' ? oneLine(name, TARGET_MAX_CHARS) : ''
395
+ const call = callId(id)
250
396
  return {
251
397
  kind: 'tool',
252
398
  verb,
@@ -255,9 +401,36 @@ function toolDraft(name: unknown, input: unknown): SessionStreamDraft {
255
401
  // empty line the reader cannot interpret.
256
402
  target: verb === 'other' || target === '' ? (readable || target) : target,
257
403
  detail: detailForTool(name, input),
404
+ ...(call ? { call } : {}),
258
405
  }
259
406
  }
260
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 } }
430
+ }
431
+ return out
432
+ }
433
+
261
434
  function proseDraft(text: unknown): SessionStreamDraft | null {
262
435
  if (typeof text !== 'string') return null
263
436
  const trimmed = text.trim()
@@ -294,7 +467,7 @@ function draftsFromContentBlocks(message: Record<string, unknown>): SessionStrea
294
467
  const prose = proseDraft(block.text)
295
468
  if (prose) out.push(prose)
296
469
  } else if (block.type === 'tool_use') {
297
- out.push(toolDraft(block.name, block.input))
470
+ out.push(toolDraft(block.name, block.input, block.id))
298
471
  }
299
472
  }
300
473
  return out
@@ -311,6 +484,32 @@ function draftsFromContentBlocks(message: Record<string, unknown>): SessionStrea
311
484
  * nothing, because it reads as the user's own words.
312
485
  * - an empty string after cleaning.
313
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
+
314
513
  export function promptDrafts(message: Record<string, unknown>): SessionStreamDraft[] {
315
514
  const content = message.content
316
515
  const blocks = Array.isArray(content)
@@ -327,6 +526,8 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
327
526
  }
328
527
 
329
528
  let text = parts.join(' ')
529
+ // A live Continue's words, out of the peer wrapper (see `unwrapPeerMessage`).
530
+ text = unwrapPeerMessage(text) ?? text
330
531
  // Wrapper blocks out, whole. A partial strip would leave the tag names on the lens.
331
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, ' ')
332
533
  const flat = oneLine(text, PROMPT_MAX_CHARS)
@@ -351,10 +552,14 @@ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamD
351
552
  // the glasses have never seen it. Dropping it is what made the live view a blank
352
553
  // slate that said WORKING and nothing else.
353
554
  //
354
- // Tool results stay dropped -- the call was already announced.
555
+ // A tool result is not a prompt, but since 6.49.0 it is not dropped either: it
556
+ // becomes the OUTCOME of the call announced a few rows earlier, on a status draft
557
+ // (see `ToolOutcome`). The prompt path still sees nothing for it.
355
558
  if (type === 'user') {
356
559
  const message = asRecord(record.message)
357
- return message ? promptDrafts(message) : []
560
+ if (!message) return []
561
+ const outcomes = outcomeDrafts(record, message)
562
+ return outcomes.length > 0 ? outcomes : promptDrafts(message)
358
563
  }
359
564
 
360
565
  const role = typeof record.role === 'string' ? record.role : ''
@@ -405,7 +610,7 @@ function draftsFromCodexRecord(record: Record<string, unknown>): SessionStreamDr
405
610
  } else if (typeof input === 'string') {
406
611
  input = { command: input }
407
612
  }
408
- return [toolDraft(payload.name, input)]
613
+ return [toolDraft(payload.name, input, payload.call_id)]
409
614
  }
410
615
  return []
411
616
  }
@@ -577,10 +782,10 @@ export interface DerivedStatusFields {
577
782
  last_reply?: string
578
783
  }
579
784
 
580
- export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState } & Partial<DerivedStatusFields>
785
+ export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome } & Partial<DerivedStatusFields>
581
786
 
582
787
  export function statusDraftWithDerived(
583
- draft: { kind: 'status'; state: SessionStreamState },
788
+ draft: { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome },
584
789
  derived: DerivedStatusFields | undefined,
585
790
  ): StatusDraftWithDerived {
586
791
  if (!derived) return draft
@@ -591,6 +796,8 @@ export function statusDraftWithDerived(
591
796
  return {
592
797
  kind: 'status',
593
798
  state,
799
+ // 6.49.0: the outcome is the draft's own fact, not the deriver's; it rides through.
800
+ ...(draft.tool_outcome ? { tool_outcome: draft.tool_outcome } : {}),
594
801
  agent_state: derived.agent_state,
595
802
  state_source: derived.state_source,
596
803
  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')
@@ -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.
@@ -333,6 +334,16 @@ export interface AgentSessionBindingsDeps {
333
334
  /** `deliverAttachedTurn` from `server/lib/attached-provider-adapter.ts`. */
334
335
  deliverAttachedTurn?: (request: AttachedTurnRequest) => Promise<unknown> | unknown
335
336
 
337
+ /**
338
+ * 6.49.0: put the turn INTO the running session before spawning a child for it.
339
+ *
340
+ * `makeLiveTurnDeliverer` from `server/lib/session-peer-inbox-deps.ts`. Optional so
341
+ * every existing test and every older wiring is byte-for-byte the 6.48.2 route; when
342
+ * absent, or when it answers anything but `ok`, the spawn path below runs unchanged.
343
+ * Only `reason` is read here; the vocabulary is the pure module's.
344
+ */
345
+ deliverLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number; clientTurnId?: string }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null }>
346
+
336
347
  /**
337
348
  * The self-recursion ledger. Defaults to the real process-wide one.
338
349
  *
@@ -483,6 +494,10 @@ export type WriteRefusal =
483
494
  | 'provider_never_opened'
484
495
  | 'delivery_ambiguous'
485
496
  | 'turn_failed'
497
+ // 6.49.0: the live transport wrote a frame it could not see accepted. The turn is
498
+ // held as retryable and the retry re-reads before doing anything (see
499
+ // `session-peer-inbox.ts`); it is never a fence, because nothing here ran a child.
500
+ | 'live_unverified'
486
501
  // ------------------------------------------------------------------- fork
487
502
  //
488
503
  // Fork gets its OWN members rather than reusing the ones above, and the reason
@@ -555,6 +570,8 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
555
570
  'The assistant never opened the thread, so nothing was sent. You can try again.',
556
571
  delivery_ambiguous:
557
572
  'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
573
+ live_unverified:
574
+ 'Sent, not yet confirmed. Send again: COS checks before sending twice.',
558
575
  turn_failed:
559
576
  'COS could not run this turn. Nothing was sent. You can try again.',
560
577
 
@@ -1265,6 +1282,22 @@ function plainBody(req: Request): Record<string, unknown> | null {
1265
1282
 
1266
1283
  export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
1267
1284
  export const TURN_SENT_COPY = 'Sent to the original thread.'
1285
+ /** 6.49.0: the turn went into the running session, where the desk will see it. */
1286
+ export const TURN_SENT_LIVE_COPY = 'Sent to the open session. It will show where you left it.'
1287
+ /**
1288
+ * How long the route waits for the session's transcript to show acceptance.
1289
+ *
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
1294
+ * budget only bites when the receiver is holding or dying, and those end as
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.
1299
+ */
1300
+ export const LIVE_VERIFY_BUDGET_MS = PEER_VERIFY_TIMEOUT_MS
1268
1301
 
1269
1302
  /**
1270
1303
  * The 202 copy. Says QUEUED and not sent, because at this instant the provider has
@@ -1880,6 +1913,24 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1880
1913
  res.set('Cache-Control', 'private, no-store')
1881
1914
 
1882
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(' ')}`
1883
1934
  /** The target we hold a claim on, released in the finally. */
1884
1935
  let claimedKey: string | null = null
1885
1936
  // Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
@@ -1922,7 +1973,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1922
1973
  let queued = false
1923
1974
 
1924
1975
  const respond = (status: number, payload: Record<string, unknown>): void => {
1925
- 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
+ }
1926
1983
  // Remembered ONLY when the prompt may have reached the provider. A
1927
1984
  // pre-delivery refusal (stale epoch, malformed body, busy target) must stay
1928
1985
  // re-evaluatable: the binding may be fine by the time the client retries, and
@@ -2053,6 +2110,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2053
2110
  const { status, ...rest } = already.result as Record<string, unknown>
2054
2111
  if (!res.headersSent) {
2055
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)}`))
2056
2114
  }
2057
2115
  return
2058
2116
  }
@@ -2096,9 +2154,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2096
2154
  // open, and it is terminal here rather than a warning because COS has no
2097
2155
  // cross-process lock that could fence a live desktop writer.
2098
2156
  const verdict = runOccupancy(binding.provider, binding.nativeThreadId)
2157
+ stage('gate')
2099
2158
  if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
2100
2159
 
2101
2160
  const head = await readHead(binding.provider, binding.nativeThreadId)
2161
+ stage('head')
2102
2162
  if (head === null) return refuseTurn('native_head_unavailable')
2103
2163
  preTurnHeadDigest = head.digest
2104
2164
 
@@ -2122,9 +2182,62 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2122
2182
  // A failed pin is FATAL, per the registry's own caller contract: an unpinned
2123
2183
  // binding can expire or be detached mid-turn, which defeats the lease.
2124
2184
  const pinned = deps.bindings.pin!(bindingId, turnId, now)
2185
+ stage('pin')
2125
2186
  if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
2126
2187
  pinnedBindingId = bindingId
2127
2188
 
2189
+ // 6.49.0: LIVE FIRST. If the session's own process is running -- a Desktop tab,
2190
+ // a `claude` in iTerm, the same registry record either way -- the turn goes into
2191
+ // it and paints where the user left the window, instead of into a resume child
2192
+ // whose reply that window never shows. Before the 202 on purpose: a live delivery
2193
+ // is a `completed` turn (terminal, ledgered, replay-safe), and a frame that could
2194
+ // not be verified is a PRE-202 refusal, which the ledger does not record, so the
2195
+ // client's retry re-evaluates rather than replaying a stale "no". The gate above
2196
+ // still ran in full: this is the same admission, with a different last hop.
2197
+ //
2198
+ // The head is NOT acknowledged here. The session will move it when it answers,
2199
+ // on its own clock; the next interactive turn reads that as a change and asks,
2200
+ // which is the conservative side. Queued turns attach fresh and never notice.
2201
+ if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
2202
+ let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
2203
+ try {
2204
+ live = await deps.deliverLiveTurn({
2205
+ provider: binding.provider,
2206
+ sessionId: binding.nativeThreadId,
2207
+ prompt,
2208
+ verifyTimeoutMs: LIVE_VERIFY_BUDGET_MS,
2209
+ clientTurnId,
2210
+ })
2211
+ } catch (error) {
2212
+ // A throw is a bug in the transport, not a fact about the session; the
2213
+ // spawn path is the answer to both.
2214
+ console.error(`[agent-session-bindings] live delivery threw: ${error instanceof Error ? error.message : error}`)
2215
+ live = null
2216
+ }
2217
+ stage(`live(${live?.reason ?? 'threw'})`)
2218
+ if (live?.ok) {
2219
+ console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
2220
+ respond(200, {
2221
+ turnId,
2222
+ outcome: 'completed',
2223
+ deliveryState: 'delivered',
2224
+ via: 'live',
2225
+ retryable: false,
2226
+ changed: false,
2227
+ revision: null,
2228
+ reason: null,
2229
+ reasonCopy: TURN_SENT_LIVE_COPY,
2230
+ })
2231
+ return
2232
+ }
2233
+ if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
2234
+ // Something may be in the session. Hold, never spawn over it.
2235
+ return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
2236
+ }
2237
+ // disabled / not_claude / no_record / protocol_unsupported / connect_failed /
2238
+ // suspect_expired: nothing reached a session. Spawn as always.
2239
+ }
2240
+
2128
2241
  // THE QUEUE POINT. Every gate is now behind us — body, replay, queued-prompt,
2129
2242
  // lease, target, epoch, fence, claim, occupancy, head baseline, pin — so a
2130
2243
  // refusal still reaches the user immediately and precisely. Only the spawn is
@@ -2146,6 +2259,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2146
2259
  reason: null,
2147
2260
  reasonCopy: TURN_QUEUED_COPY,
2148
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'))
2149
2267
 
2150
2268
  let delivery: Delivery
2151
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.
@@ -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(),