@skrr-ai/cli 0.1.84 → 0.1.86

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.
@@ -265,8 +265,13 @@ class AgentsChat extends base_command_1.BaseCommand {
265
265
  'so folding both doubles the answer: pick ONE lane. Inside a `session.output`, ' +
266
266
  '`envelopes[].__seq` is the dedupe key and the ordering key — the same envelope is ' +
267
267
  'delivered by more than one room, and a re-delivery repeats its `__seq`. A ' +
268
- '`stream.gap` event means envelopes in that seq range never arrived and the streamed ' +
269
- 'text is short; `run.result` and the persisted message remain authoritative. Tool ' +
268
+ '`stream.gap` event means envelopes in that seq range never arrived and replay from ' +
269
+ 'the server could not recover them; a hole replay fills, or one that held only ' +
270
+ 'invisible envelopes (`service`, `status`), emits nothing. When the persisted message ' +
271
+ 'could be checked, `verified` is true and `missing` names what the stream is short of ' +
272
+ '(`text`, `tool`), and the event arrives just before the terminal event; `verified: ' +
273
+ 'false` means it could not be checked and the text MAY be short. `run.result` and the ' +
274
+ 'persisted message remain authoritative. Tool ' +
270
275
  'calls arrive as `tool.started` / `tool.completed`, once per `toolUseId`, on cloud ' +
271
276
  'and local-harness runs alike. A `session.notice` with `notice: ' +
272
277
  '"harness-session-reset"` means the local harness could not resume its previous ' +
@@ -376,7 +381,9 @@ class AgentsChat extends base_command_1.BaseCommand {
376
381
  this.requireAuth();
377
382
  const { args, flags } = await this.parse(AgentsChat);
378
383
  if (flags.quick && flags.task)
379
- this.error('--task requires a runtime session; remove --quick to bind this turn.', { exit: 2 });
384
+ this.error('--task requires a runtime session; remove --quick to bind this turn.', {
385
+ exit: 2,
386
+ });
380
387
  if (flags.json && !flags['no-stream'] && !flags.quick) {
381
388
  this.error('--json is only supported with --no-stream or --quick; use --jsonl for streaming.', {
382
389
  exit: 2,
@@ -40,6 +40,12 @@ class AgentsEndpointEnable extends base_command_1.BaseCommand {
40
40
  }
41
41
  (0, agent_endpoints_1.printAgentEndpoint)(this, endpoint);
42
42
  this.log('');
43
+ if (!(0, agent_endpoints_1.endpointWillRun)(endpoint)) {
44
+ // On is not the same as runnable: say so rather than end on a connect
45
+ // line that leads to a refused first call (OSK-13521).
46
+ this.log(endpoint.notice ||
47
+ `The endpoint is on, but it will not run a turn until ${endpoint.blocked_by?.code ?? 'its blocker'} clears.`);
48
+ }
43
49
  this.log('Connect a client with: skrr agents endpoint connect ' + args.id + ' --space <space-id>');
44
50
  }
45
51
  }
@@ -66,6 +66,9 @@ class StoreAcquisitionsApprove extends base_command_1.BaseCommand {
66
66
  exit: 1,
67
67
  });
68
68
  }
69
+ if (request.invalidReason === 'buyer_terms_missing') {
70
+ this.error(`This request was recorded without buyer terms, so it cannot be approved. Decline it with \`skrr store acquisitions decline ${request.id}\`; the agent's next attempt opens a new request that carries the terms.`, { exit: 1 });
71
+ }
69
72
  if (!request.buyerTerms) {
70
73
  this.error('Buyer terms are not configured, so this purchase cannot be approved.', {
71
74
  exit: 1,
@@ -23,11 +23,14 @@ export interface AgentEndpoint {
23
23
  enabled_at?: string | null;
24
24
  switchable: boolean;
25
25
  ready: boolean;
26
- /** The gate that would refuse a turn right now (see docs/cli/SKRR_AGENT_ENDPOINTS.md). */
27
- blocked_by: {
28
- code: string;
29
- message: string;
30
- } | null;
26
+ /**
27
+ * The gate that would refuse a turn right now (see docs/cli/SKRR_AGENT_ENDPOINTS.md).
28
+ * Includes the agent-worker runtime admission endpoint turns run on — e.g.
29
+ * `RUNTIME_METERING_BACKLOG_BLOCKED` — with the next step in `remedy`.
30
+ */
31
+ blocked_by: AgentEndpointBlocker | null;
32
+ /** Set by `enable` when the endpoint is on but a turn would still be refused. */
33
+ notice?: string;
31
34
  release?: {
32
35
  id: string;
33
36
  version_label: string | null;
@@ -44,6 +47,12 @@ export interface AgentEndpoint {
44
47
  };
45
48
  workspace_id?: string | null;
46
49
  }
50
+ export interface AgentEndpointBlocker {
51
+ code: string;
52
+ message: string;
53
+ remedy?: string | null;
54
+ details?: Record<string, unknown>;
55
+ }
47
56
  export declare function getAgentEndpoint(agentId: string): Promise<AgentEndpoint>;
48
57
  /** Turn a Store instance's endpoint on or off. Owner only, enforced server-side. */
49
58
  export declare function setAgentEndpointEnabled(agentId: string, enabled: boolean): Promise<AgentEndpoint>;
@@ -56,6 +65,11 @@ export declare const AGENT_ENDPOINT_SCOPE = "agents:invoke";
56
65
  export declare function mcpClientCommand(endpoint: AgentEndpoint, secret: string): string;
57
66
  /** One human line for the state an endpoint is in. */
58
67
  export declare function describeEndpointState(endpoint: AgentEndpoint): string;
68
+ /**
69
+ * Whether the endpoint would run a turn now. `ready` alone is the answer; this
70
+ * exists so a caller never reads `enabled` as "it works" (OSK-13521).
71
+ */
72
+ export declare function endpointWillRun(endpoint: AgentEndpoint): boolean;
59
73
  /** The human rendering every `agents endpoint` command prints. */
60
74
  export declare function printAgentEndpoint(cmd: {
61
75
  log: (line: string) => void;
@@ -5,6 +5,7 @@ exports.getAgentEndpoint = getAgentEndpoint;
5
5
  exports.setAgentEndpointEnabled = setAgentEndpointEnabled;
6
6
  exports.mcpClientCommand = mcpClientCommand;
7
7
  exports.describeEndpointState = describeEndpointState;
8
+ exports.endpointWillRun = endpointWillRun;
8
9
  exports.printAgentEndpoint = printAgentEndpoint;
9
10
  const api_fetch_1 = require("./api-fetch");
10
11
  async function getAgentEndpoint(agentId) {
@@ -40,11 +41,21 @@ function describeEndpointState(endpoint) {
40
41
  }
41
42
  return 'on';
42
43
  }
44
+ /**
45
+ * Whether the endpoint would run a turn now. `ready` alone is the answer; this
46
+ * exists so a caller never reads `enabled` as "it works" (OSK-13521).
47
+ */
48
+ function endpointWillRun(endpoint) {
49
+ return endpoint.enabled && endpoint.ready && !endpoint.blocked_by;
50
+ }
43
51
  /** The human rendering every `agents endpoint` command prints. */
44
52
  function printAgentEndpoint(cmd, endpoint) {
45
53
  cmd.log(`Agent ${endpoint.agent_id}${endpoint.agent_name ? ` (${endpoint.agent_name})` : ''}`);
46
54
  cmd.log(`Kind ${endpoint.kind.replace(/_/g, ' ')}`);
47
55
  cmd.log(`Endpoint ${describeEndpointState(endpoint)}`);
56
+ if (endpoint.blocked_by?.remedy) {
57
+ cmd.log(`Next step ${endpoint.blocked_by.remedy}`);
58
+ }
48
59
  if (endpoint.url) {
49
60
  cmd.log(`URL ${endpoint.url} (MCP, ${endpoint.transport})`);
50
61
  cmd.log(`Key scope ${endpoint.scope}`);
@@ -99,6 +99,13 @@ export type AgenticStreamOptions = {
99
99
  replayEnvelopes?: (from: number) => Promise<EnvelopeReplayPage>;
100
100
  /** Bounded live catch-up cadence while a run is active. Default 2s. */
101
101
  envelopeReplayIntervalMs?: number;
102
+ /**
103
+ * Where debug-level records go — a seq hole that replay filled, or one that
104
+ * was never filled but provably carried nothing the reader sees. Default:
105
+ * stderr when `DEBUG` is set, otherwise nowhere. Never stdout: `--jsonl`
106
+ * owns that stream and its contract does not include diagnostics.
107
+ */
108
+ debug?: (line: string) => void;
102
109
  /** Silence before the first durable-record probe. Default 20s. */
103
110
  idleTerminalProbeMs?: number;
104
111
  /** How long to keep trying to attach before reporting a detach. Default 30s. */
@@ -335,16 +342,19 @@ export declare function jitteredRetryDelay(retryAfterMs: number, random?: () =>
335
342
  * is the more dangerous of the two to leave silent, because a truncated live
336
343
  * answer looks like a complete short answer.
337
344
  *
338
- * The web client already detects this (`client_envelope_seq_gap_detected_total`)
339
- * and rehydrates the missing range from the envelope ring. The CLI had neither
340
- * the detection nor the backfill, so the only correction it could make was at
341
- * the terminal — which it does: `run.result` and the persisted message are
342
- * authoritative and already correct here. What was missing was any admission,
343
- * WHILE READING, that the text on screen is not all of it.
345
+ * The web client detects this (`client_envelope_seq_gap_detected_total`) and
346
+ * rehydrates the missing range from the envelope ring; the CLI now does the
347
+ * same through `replayEnvelopes` (the canonical
348
+ * `GET /api/agentic/sessions/:id/envelopes`), holding later frames behind a
349
+ * hole until replay fills it.
344
350
  *
345
- * Tracks the highest seq applied and reports the first gap. Deliberately does
346
- * not try to reconstruct: a client that guesses at missing text is worse than
347
- * one that says it is missing.
351
+ * A hole replay fills is not a gap at all. One it cannot fill is judged at the
352
+ * terminal against the persisted message, and is warned about only when the
353
+ * stream is proven short of text or of a tool call (OSK-13518): the commonest
354
+ * hole — `service` / `slash-commands-available` envelopes a daemon published
355
+ * before the viewer joined the room — carries nothing the reader sees. Still
356
+ * never reconstructs: a client that guesses at missing text is worse than one
357
+ * that says it is missing.
348
358
  */
349
359
  export type SeqGap = {
350
360
  from: number;
@@ -358,8 +368,23 @@ export type SeqGap = {
358
368
  */
359
369
  export declare function projectSessionNotices(envelopes: unknown[]): AgenticStreamEvent[];
360
370
  export declare function detectSeqGap(highestApplied: number, incoming: number): SeqGap | null;
371
+ /** What a lost range is proven to have cost the reader. */
372
+ export type SeqGapLoss = 'text' | 'tool';
361
373
  /**
362
- * The notice a reader sees when chunks went missing.
374
+ * The notice a reader sees when chunks went missing AND stayed missing.
375
+ *
376
+ * A hole in `__seq` is not by itself a loss: most of the envelopes a daemon
377
+ * publishes before the viewer is admitted to the room — `service`
378
+ * ("Spawning Codex session"), `slash-commands-available`, `status` — carry
379
+ * nothing the reader sees, and the replay endpoint returns them anyway
380
+ * (OSK-13518). So this sentence is only ever built for a range that replay
381
+ * could NOT fill, and it says exactly as much as is known about it:
382
+ *
383
+ * - `missing` given: the range was checked against the persisted message and
384
+ * the stream is proven short of `text`, or a `tool` call's start never
385
+ * arrived (its end did). The sentence says which.
386
+ * - `missing` absent: nothing could check it — no record, or the record could
387
+ * not be read — so it says "may", never "is".
363
388
  *
364
389
  * It names `run.result` only when this client will actually fill `run.result`
365
390
  * from the persisted message (`recordBackfill`). The sentence used to promise
@@ -367,9 +392,20 @@ export declare function detectSeqGap(highestApplied: number, incoming: number):
367
392
  * that DID arrive — so the one run told to trust `run.result` was the run whose
368
393
  * `run.result.text` was empty, with the answer only in the record (OSK-12132).
369
394
  */
370
- export declare function seqGapNotice(gap: SeqGap, options?: {
395
+ export declare function seqGapNotice(gaps: SeqGap | readonly SeqGap[], options?: {
371
396
  recordBackfill?: boolean;
397
+ missing?: readonly SeqGapLoss[];
372
398
  }): string;
399
+ /**
400
+ * Did the stream show everything the record holds?
401
+ *
402
+ * Whitespace-insensitive containment, not equality: the record may join text
403
+ * blocks with blank lines the envelopes never carried, and on some paths it
404
+ * holds only the last block of an answer the stream showed in full (OSK-7703).
405
+ * Both of those are "nothing missing". What is missing is persisted text the
406
+ * stream never showed — and that is the only thing this answers yes to.
407
+ */
408
+ export declare function streamShowsRecordedText(streamed: string, recorded: string): boolean;
373
409
  /**
374
410
  * The permission mode a `run.started` reports, and where it came from
375
411
  * (OSK-12089). `null` when the event carries no mode report at all — a cloud
@@ -392,7 +428,19 @@ export declare class AgenticStreamClient {
392
428
  private replayGeneration;
393
429
  private replayFailures;
394
430
  private finishingReplay;
431
+ /** A frame was held while a replay read was already in flight (see catchUpEnvelopes). */
432
+ private heldDuringFlight;
433
+ /** A user-visible `stream.gap` was emitted; at most one per stream. */
395
434
  private reportedGap;
435
+ /**
436
+ * Seq ranges passed over WITHOUT recovery. Not yet a loss: whether they cost
437
+ * the reader anything is decided against the record at the terminal.
438
+ */
439
+ private readonly lostRanges;
440
+ /** Daemon tool calls whose `tool-call-start` was applied. */
441
+ private readonly startedDaemonCalls;
442
+ /** A `tool-call-end` arrived whose start never did — proof a tool envelope was lost. */
443
+ private orphanToolEnd;
396
444
  private readonly seenEnvelopes;
397
445
  /**
398
446
  * Lifecycle events already emitted, by their own identity (OSK-7632/7689/7709).
@@ -494,15 +542,34 @@ export declare class AgenticStreamClient {
494
542
  probeTerminal(reason: string): Promise<boolean>;
495
543
  cancel(reason?: string): void;
496
544
  close(): void;
545
+ private debug;
546
+ /**
547
+ * Record a hole being passed over — the moment replay has given up on it.
548
+ *
549
+ * Reaching here means the range is NOT recoverable: with a replay path every
550
+ * held frame waits behind the hole until the canonical endpoint fills it, and
551
+ * is only released past it on an explicit retention gap, repeated replay
552
+ * failure, or the terminal. What it is not yet is a LOSS. A hole made of
553
+ * `service` / `slash-commands-available` / `status` envelopes costs the
554
+ * reader nothing, and a warning about it is false (OSK-13518). So when the
555
+ * record can be read at the terminal, the verdict waits for it
556
+ * ({@link settleGapVerdict}). Only a client with no record to consult says
557
+ * something now, and then only what it knows: "may be incomplete".
558
+ */
559
+ private noteSeqGap;
560
+ private emitGap;
561
+ /** Track daemon tool starts, and notice an end whose start never arrived. */
562
+ private noteDaemonToolEnvelopes;
497
563
  /**
498
- * Report the first hole in the live envelope sequence, once.
564
+ * Decide, against the persisted message, whether unrecovered ranges cost the
565
+ * reader anything — and say so only if they did.
499
566
  *
500
- * Once, not per gap: a stream that has already lost its beginning tends to
501
- * lose more, and one honest sentence is the useful form of that. The terminal
502
- * result is authoritative and reconciled separately, so this never changes
503
- * what the run reports — only what the reader is told while it is happening.
567
+ * `recorded` undefined means the record could not be read: unverifiable, so
568
+ * the hedged notice. Otherwise the stream is short of text exactly when the
569
+ * record holds text the stream never showed, and short of a tool call exactly
570
+ * when an end arrived without its start. Neither → a debug record and silence.
504
571
  */
505
- private reportSeqGap;
572
+ private settleGapVerdict;
506
573
  /** Keep later live frames behind missing earlier frames while canonical replay catches up. */
507
574
  private receiveSessionOutput;
508
575
  private flushPendingEnvelopes;
@@ -19,6 +19,7 @@ exports.jitteredRetryDelay = jitteredRetryDelay;
19
19
  exports.projectSessionNotices = projectSessionNotices;
20
20
  exports.detectSeqGap = detectSeqGap;
21
21
  exports.seqGapNotice = seqGapNotice;
22
+ exports.streamShowsRecordedText = streamShowsRecordedText;
22
23
  exports.describeRunPermission = describeRunPermission;
23
24
  exports.toWebSocketOrigin = toWebSocketOrigin;
24
25
  const node_readline_1 = require("node:readline");
@@ -571,7 +572,20 @@ function detectSeqGap(highestApplied, incoming) {
571
572
  return { from: highestApplied + 1, to: incoming - 1 };
572
573
  }
573
574
  /**
574
- * The notice a reader sees when chunks went missing.
575
+ * The notice a reader sees when chunks went missing AND stayed missing.
576
+ *
577
+ * A hole in `__seq` is not by itself a loss: most of the envelopes a daemon
578
+ * publishes before the viewer is admitted to the room — `service`
579
+ * ("Spawning Codex session"), `slash-commands-available`, `status` — carry
580
+ * nothing the reader sees, and the replay endpoint returns them anyway
581
+ * (OSK-13518). So this sentence is only ever built for a range that replay
582
+ * could NOT fill, and it says exactly as much as is known about it:
583
+ *
584
+ * - `missing` given: the range was checked against the persisted message and
585
+ * the stream is proven short of `text`, or a `tool` call's start never
586
+ * arrived (its end did). The sentence says which.
587
+ * - `missing` absent: nothing could check it — no record, or the record could
588
+ * not be read — so it says "may", never "is".
575
589
  *
576
590
  * It names `run.result` only when this client will actually fill `run.result`
577
591
  * from the persisted message (`recordBackfill`). The sentence used to promise
@@ -579,15 +593,44 @@ function detectSeqGap(highestApplied, incoming) {
579
593
  * that DID arrive — so the one run told to trust `run.result` was the run whose
580
594
  * `run.result.text` was empty, with the answer only in the record (OSK-12132).
581
595
  */
582
- function seqGapNotice(gap, options = {}) {
583
- const count = gap.to - gap.from + 1;
584
- const where = options.recordBackfill === false
596
+ function seqGapNotice(gaps, options = {}) {
597
+ const ranges = Array.isArray(gaps) ? gaps : [gaps];
598
+ const count = ranges.reduce((n, g) => n + (g.to - g.from + 1), 0);
599
+ const where = ranges.map((g) => (g.from === g.to ? `${g.from}` : `${g.from}-${g.to}`)).join(', ');
600
+ const head = `[${count} stream chunk${count === 1 ? '' : 's'} (seq ${where}) never arrived ` +
601
+ 'and could not be recovered';
602
+ const record = options.recordBackfill === false
585
603
  ? 'The full answer is in the persisted message.'
586
604
  : 'The full answer is in the persisted message and in run.result.';
587
- return (`[${count} stream chunk${count === 1 ? '' : 's'} (seq ${gap.from}` +
588
- `${count === 1 ? '' : `-${gap.to}`}) never arrived — the text above is incomplete. ` +
589
- `${where}]`);
605
+ const missing = options.missing;
606
+ if (!missing) {
607
+ return `${head} — the text above may be incomplete. ${record}]`;
608
+ }
609
+ const lostText = missing.includes('text');
610
+ const lostTool = missing.includes('tool');
611
+ if (!lostText) {
612
+ return `${head} — a tool call above is missing its start; the answer text is complete. The persisted message has the whole turn.]`;
613
+ }
614
+ const what = lostTool ? 'text and tool calls above are' : 'text above is';
615
+ return `${head} — the streamed ${what} incomplete. ${record}]`;
590
616
  }
617
+ /**
618
+ * Did the stream show everything the record holds?
619
+ *
620
+ * Whitespace-insensitive containment, not equality: the record may join text
621
+ * blocks with blank lines the envelopes never carried, and on some paths it
622
+ * holds only the last block of an answer the stream showed in full (OSK-7703).
623
+ * Both of those are "nothing missing". What is missing is persisted text the
624
+ * stream never showed — and that is the only thing this answers yes to.
625
+ */
626
+ function streamShowsRecordedText(streamed, recorded) {
627
+ const squash = (s) => s.replace(/\s+/g, '');
628
+ return squash(streamed).includes(squash(recorded));
629
+ }
630
+ const defaultStreamDebug = (line) => {
631
+ if (process.env.DEBUG)
632
+ process.stderr.write(`[agentic-stream debug] ${line}\n`);
633
+ };
591
634
  /** How long, at most, a gapped run waits for the record before settling anyway. */
592
635
  const GAP_BACKFILL_ATTEMPTS = 3;
593
636
  const GAP_BACKFILL_RETRY_MS = 400;
@@ -635,7 +678,19 @@ class AgenticStreamClient {
635
678
  replayGeneration = 0;
636
679
  replayFailures = 0;
637
680
  finishingReplay = false;
681
+ /** A frame was held while a replay read was already in flight (see catchUpEnvelopes). */
682
+ heldDuringFlight = false;
683
+ /** A user-visible `stream.gap` was emitted; at most one per stream. */
638
684
  reportedGap = false;
685
+ /**
686
+ * Seq ranges passed over WITHOUT recovery. Not yet a loss: whether they cost
687
+ * the reader anything is decided against the record at the terminal.
688
+ */
689
+ lostRanges = [];
690
+ /** Daemon tool calls whose `tool-call-start` was applied. */
691
+ startedDaemonCalls = new Set();
692
+ /** A `tool-call-end` arrived whose start never did — proof a tool envelope was lost. */
693
+ orphanToolEnd = false;
639
694
  // Envelope keys already applied, so a re-delivered `session:output` frame is
640
695
  // not appended twice (OSK-4834). Cleared on `execution:stream-reset`.
641
696
  seenEnvelopes = new Set();
@@ -868,34 +923,113 @@ class AgenticStreamClient {
868
923
  this.readline?.close();
869
924
  this.readline = null;
870
925
  }
926
+ debug(line) {
927
+ try {
928
+ (this.options.debug ?? defaultStreamDebug)(`session ${this.options.sessionId}: ${line}`);
929
+ }
930
+ catch {
931
+ // A diagnostic sink must never break the stream it describes.
932
+ }
933
+ }
871
934
  /**
872
- * Report the first hole in the live envelope sequence, once.
935
+ * Record a hole being passed over — the moment replay has given up on it.
873
936
  *
874
- * Once, not per gap: a stream that has already lost its beginning tends to
875
- * lose more, and one honest sentence is the useful form of that. The terminal
876
- * result is authoritative and reconciled separately, so this never changes
877
- * what the run reports — only what the reader is told while it is happening.
937
+ * Reaching here means the range is NOT recoverable: with a replay path every
938
+ * held frame waits behind the hole until the canonical endpoint fills it, and
939
+ * is only released past it on an explicit retention gap, repeated replay
940
+ * failure, or the terminal. What it is not yet is a LOSS. A hole made of
941
+ * `service` / `slash-commands-available` / `status` envelopes costs the
942
+ * reader nothing, and a warning about it is false (OSK-13518). So when the
943
+ * record can be read at the terminal, the verdict waits for it
944
+ * ({@link settleGapVerdict}). Only a client with no record to consult says
945
+ * something now, and then only what it knows: "may be incomplete".
878
946
  */
879
- reportSeqGap(envelopes) {
947
+ noteSeqGap(envelopes) {
880
948
  for (const envelope of envelopes) {
881
949
  const seq = Number(envelope?.__seq);
882
950
  if (!Number.isFinite(seq) || seq <= 0)
883
951
  continue;
884
952
  const gap = detectSeqGap(this.highestSeq, seq);
885
- if (gap && !this.reportedGap) {
886
- this.reportedGap = true;
887
- this.emit({
888
- type: 'stream.gap',
889
- sessionId: this.options.sessionId,
890
- fromSeq: gap.from,
891
- toSeq: gap.to,
892
- message: seqGapNotice(gap, { recordBackfill: Boolean(this.options.reconcileTerminal) }),
893
- });
953
+ if (gap) {
954
+ this.lostRanges.push(gap);
955
+ this.debug(`seq ${gap.from}-${gap.to} passed over unrecovered`);
956
+ if (!this.options.reconcileTerminal && !this.reportedGap) {
957
+ // Said WHILE READING, which is the only moment it helps (OSK-7631)
958
+ // — and hedged, because nothing will ever check it.
959
+ this.emitGap([gap]);
960
+ }
894
961
  }
895
962
  if (seq > this.highestSeq)
896
963
  this.highestSeq = seq;
897
964
  }
898
965
  }
966
+ emitGap(ranges, missing) {
967
+ if (this.reportedGap || ranges.length === 0)
968
+ return;
969
+ this.reportedGap = true;
970
+ this.emit({
971
+ type: 'stream.gap',
972
+ sessionId: this.options.sessionId,
973
+ fromSeq: ranges[0].from,
974
+ toSeq: ranges[ranges.length - 1].to,
975
+ ...(ranges.length > 1
976
+ ? { ranges: ranges.map((g) => ({ fromSeq: g.from, toSeq: g.to })) }
977
+ : {}),
978
+ verified: Boolean(missing),
979
+ ...(missing ? { missing } : {}),
980
+ message: seqGapNotice(ranges, {
981
+ recordBackfill: Boolean(this.options.reconcileTerminal),
982
+ ...(missing ? { missing } : {}),
983
+ }),
984
+ });
985
+ }
986
+ /** Track daemon tool starts, and notice an end whose start never arrived. */
987
+ noteDaemonToolEnvelopes(envelopes) {
988
+ for (const envelope of envelopes) {
989
+ const ev = envelope?.ev;
990
+ if (!ev || typeof ev.call !== 'string')
991
+ continue;
992
+ if (ev.t === 'tool-call-start')
993
+ this.startedDaemonCalls.add(ev.call);
994
+ else if (ev.t === 'tool-call-end' && !this.startedDaemonCalls.has(ev.call)) {
995
+ this.orphanToolEnd = true;
996
+ }
997
+ }
998
+ }
999
+ /**
1000
+ * Decide, against the persisted message, whether unrecovered ranges cost the
1001
+ * reader anything — and say so only if they did.
1002
+ *
1003
+ * `recorded` undefined means the record could not be read: unverifiable, so
1004
+ * the hedged notice. Otherwise the stream is short of text exactly when the
1005
+ * record holds text the stream never showed, and short of a tool call exactly
1006
+ * when an end arrived without its start. Neither → a debug record and silence.
1007
+ */
1008
+ settleGapVerdict(streamedText, recorded) {
1009
+ if (this.lostRanges.length === 0 || this.reportedGap)
1010
+ return;
1011
+ const ranges = this.lostRanges.map((g) => `${g.from}-${g.to}`).join(', ');
1012
+ if (!recorded) {
1013
+ this.debug(`seq ${ranges} unrecovered and the record was unreadable; warning unverified`);
1014
+ this.emitGap(this.lostRanges);
1015
+ return;
1016
+ }
1017
+ const missing = [];
1018
+ if (recorded.text && !streamShowsRecordedText(streamedText, recorded.text))
1019
+ missing.push('text');
1020
+ if (this.orphanToolEnd)
1021
+ missing.push('tool');
1022
+ if (missing.length === 0) {
1023
+ this.debug(`seq ${ranges} unrecovered but carried nothing visible (record matches stream)`);
1024
+ return;
1025
+ }
1026
+ this.emitGap(this.lostRanges, missing);
1027
+ // The notice says the text above is short; what follows it is the whole
1028
+ // answer from the record, printed in full rather than as a tail appended
1029
+ // to a fragment the reader now knows is wrong.
1030
+ if (missing.includes('text'))
1031
+ this.renderedText = '';
1032
+ }
899
1033
  /** Keep later live frames behind missing earlier frames while canonical replay catches up. */
900
1034
  receiveSessionOutput(event) {
901
1035
  if (this.closed || this.settled)
@@ -912,6 +1046,11 @@ class AgenticStreamClient {
912
1046
  }
913
1047
  else if (seq > this.highestSeq && !this.pendingEnvelopes.has(seq)) {
914
1048
  this.pendingEnvelopes.set(seq, { envelope, event });
1049
+ // A read already in flight may have been issued BEFORE this frame was
1050
+ // published, and so before the earlier seqs it is waiting on were
1051
+ // stamped. Its answer proves nothing about them; see catchUpEnvelopes.
1052
+ if (this.replayFlight && event.source !== 'replay')
1053
+ this.heldDuringFlight = true;
915
1054
  }
916
1055
  }
917
1056
  this.flushPendingEnvelopes(false);
@@ -942,6 +1081,7 @@ class AgenticStreamClient {
942
1081
  clearTimeout(this.replayTimer);
943
1082
  this.replayTimer = null;
944
1083
  const generation = this.replayGeneration;
1084
+ this.heldDuringFlight = false;
945
1085
  this.replayFlight = (async () => {
946
1086
  try {
947
1087
  for (let page = 0; page < 4; page += 1) {
@@ -950,6 +1090,14 @@ class AgenticStreamClient {
950
1090
  if (this.closed || this.settled || generation !== this.replayGeneration)
951
1091
  return;
952
1092
  this.replayFailures = 0;
1093
+ if (this.pendingEnvelopes.size && result.envelopes.length) {
1094
+ const seqs = result.envelopes
1095
+ .map((e) => Number(e?.__seq))
1096
+ .filter((n) => Number.isSafeInteger(n) && n > from);
1097
+ if (seqs.length) {
1098
+ this.debug(`seq ${Math.min(...seqs)}-${Math.max(...seqs)} backfilled by replay`);
1099
+ }
1100
+ }
953
1101
  this.receiveSessionOutput({
954
1102
  type: 'session.output',
955
1103
  sessionId: this.options.sessionId,
@@ -981,7 +1129,14 @@ class AgenticStreamClient {
981
1129
  })().finally(() => {
982
1130
  this.replayFlight = null;
983
1131
  if (!this.closed && !this.settled && !this.finishingReplay) {
984
- this.replayTimer = setTimeout(() => void this.catchUpEnvelopes(), this.options.envelopeReplayIntervalMs ?? 2000);
1132
+ // The server stamps and persists an envelope BEFORE it fans it out, so
1133
+ // a read issued after a live frame arrived is guaranteed to see every
1134
+ // earlier seq. A frame held while a stale read was in flight therefore
1135
+ // gets one immediate re-read, instead of sitting behind a pre-join
1136
+ // hole for a whole poll interval (OSK-13518). Bounded: the flag is
1137
+ // cleared when that read starts and set again only by a NEW live frame.
1138
+ const again = this.heldDuringFlight && this.pendingEnvelopes.size > 0;
1139
+ this.replayTimer = setTimeout(() => void this.catchUpEnvelopes(), again ? 0 : (this.options.envelopeReplayIntervalMs ?? 2000));
985
1140
  }
986
1141
  });
987
1142
  return this.replayFlight;
@@ -1004,10 +1159,11 @@ class AgenticStreamClient {
1004
1159
  if (fresh.length === 0)
1005
1160
  return; // a pure re-delivery — drop it whole
1006
1161
  const scoped = { ...event, envelopes: fresh };
1007
- // Say what never arrived, once, before folding this batch in
1008
- // (OSK-7631). Checked against the FRESH set: a re-delivery carries old
1162
+ // Note what never arrived before folding this batch in (OSK-7631,
1163
+ // OSK-13518). Checked against the FRESH set: a re-delivery carries old
1009
1164
  // seqs and would read as a gap that never happened.
1010
- this.reportSeqGap(fresh);
1165
+ this.noteSeqGap(fresh);
1166
+ this.noteDaemonToolEnvelopes(fresh);
1011
1167
  const envelopeText = envelopesText(scoped);
1012
1168
  if (envelopeText) {
1013
1169
  this.accumulated += envelopeText;
@@ -1120,6 +1276,9 @@ class AgenticStreamClient {
1120
1276
  this.replayGeneration += 1;
1121
1277
  this.pendingEnvelopes.clear();
1122
1278
  this.reportedGap = false;
1279
+ this.lostRanges.length = 0;
1280
+ this.startedDaemonCalls.clear();
1281
+ this.orphanToolEnd = false;
1123
1282
  // A reset restarts the stream, so envelope seqs may repeat; forget the
1124
1283
  // seen keys or the fresh post-reset text would be deduped away (OSK-4834).
1125
1284
  this.seenEnvelopes.clear();
@@ -1562,13 +1721,19 @@ class AgenticStreamClient {
1562
1721
  return;
1563
1722
  this.settled = true;
1564
1723
  this.lastEvent = event;
1565
- // A stream that lost chunks told its reader the full answer would be in
1566
- // `run.result`. The terminal event does not always carry it — a failed
1567
- // turn's `execution:error` has no `result` — so read it from the record
1568
- // before settling, or the promise is false exactly when it was made
1569
- // (OSK-12132).
1570
- if (this.reportedGap && this.options.reconcileTerminal) {
1571
- void this.readRecordedText().then((recorded) => this.settle(status, event, recorded));
1724
+ // A stream that passed over unrecovered chunks reads the record before
1725
+ // settling, for two reasons. The verdict: whether those chunks cost the
1726
+ // reader anything is a comparison with the persisted message, and only a
1727
+ // proven loss is said out loud (OSK-13518). And the answer: the terminal
1728
+ // event does not always carry it — a failed turn's `execution:error` has
1729
+ // no `result` — so `run.result` is filled from the record, or the notice's
1730
+ // promise is false exactly when it was made (OSK-12132).
1731
+ if (this.lostRanges.length && this.options.reconcileTerminal) {
1732
+ const streamedText = this.accumulated;
1733
+ void this.readRecordedText().then((recorded) => {
1734
+ this.settleGapVerdict(streamedText, recorded);
1735
+ this.settle(status, event, recorded?.text || undefined);
1736
+ });
1572
1737
  return;
1573
1738
  }
1574
1739
  this.settle(status, event, undefined);
@@ -1583,19 +1748,25 @@ class AgenticStreamClient {
1583
1748
  const reconcile = this.options.reconcileTerminal;
1584
1749
  if (!reconcile)
1585
1750
  return undefined;
1751
+ // A terminal record with no text is still a reading — "the turn persisted
1752
+ // no answer" — and is kept in case a later attempt finds none either.
1753
+ let empty;
1586
1754
  for (let attempt = 0; attempt < GAP_BACKFILL_ATTEMPTS; attempt += 1) {
1587
1755
  if (attempt > 0)
1588
1756
  await new Promise((r) => setTimeout(r, GAP_BACKFILL_RETRY_MS));
1589
1757
  try {
1590
1758
  const terminal = await reconcile();
1591
- if (terminal && typeof terminal.text === 'string' && terminal.text)
1592
- return terminal.text;
1759
+ if (terminal && typeof terminal.text === 'string' && terminal.text) {
1760
+ return { text: terminal.text };
1761
+ }
1762
+ if (terminal)
1763
+ empty = { text: '' };
1593
1764
  }
1594
1765
  catch {
1595
1766
  // No evidence either way; try again, then settle on the stream.
1596
1767
  }
1597
1768
  }
1598
- return undefined;
1769
+ return empty;
1599
1770
  }
1600
1771
  settle(status, event, recordedText) {
1601
1772
  // `execution:complete` carries the final answer as `result`, and it is