@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.
- package/CHANGELOG.md +27 -0
- package/README.md +25 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +14 -5
- package/server/lib/agent-session-binding-registry.ts +78 -4
- package/server/lib/agent-session-store.ts +10 -3
- package/server/lib/session-peer-inbox-deps.ts +188 -0
- package/server/lib/session-peer-inbox.ts +498 -0
- package/server/lib/session-stream-events.ts +216 -9
- package/server/lib/thread-turn-queue-store.ts +11 -1
- package/server/routes/agent-session-bindings.ts +119 -1
- package/server/routes/agent-session-stream.ts +4 -2
- package/server/routes/health.ts +24 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 -
|
|
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)
|
|
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
|
-
|
|
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.
|
package/server/routes/health.ts
CHANGED
|
@@ -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(),
|