@skrr-ai/cli 0.1.71 → 0.1.73

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.
@@ -1322,6 +1322,7 @@ class BaseCommand extends core_1.Command {
1322
1322
  exit: 1,
1323
1323
  retryable: isRetryableFailure(e.status, body),
1324
1324
  details: body ?? undefined,
1325
+ requestId: stringProp(body, 'requestId') || e.requestId,
1325
1326
  });
1326
1327
  }
1327
1328
  else if ((0, outbox_1.isOutageFailure)(e)) {
@@ -14,6 +14,7 @@ const node_fs_1 = require("node:fs");
14
14
  const core_1 = require("@oclif/core");
15
15
  const data_provider_1 = require("@skrr-ai/data-provider");
16
16
  const base_command_1 = require("../../base-command");
17
+ const api_fetch_1 = require("../../lib/api-fetch");
17
18
  const agent_home_workspace_1 = require("../../lib/agent-home-workspace");
18
19
  const agentic_stream_1 = require("../../lib/agentic-stream");
19
20
  const web_url_1 = require("../../lib/web-url");
@@ -594,6 +595,7 @@ class AgentsChat extends base_command_1.BaseCommand {
594
595
  quiet,
595
596
  approveTools: flags['approve-tools'],
596
597
  reconcileTerminal,
598
+ replayEnvelopes: (from) => (0, api_fetch_1.apiFetch)(`/api/agentic/sessions/${encodeURIComponent(started.sessionId)}/envelopes?from=${from}&limit=500`, { credential: this.resolvedCredential, signal: AbortSignal.timeout(5000) }),
597
599
  });
598
600
  // OSK-2938 — Ctrl-C must be bounded, visible, and honest about what it
599
601
  // did NOT stop.
@@ -3,6 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
4
  const data_provider_1 = require("@skrr-ai/data-provider");
5
5
  const base_command_1 = require("../../../base-command");
6
+ function betaWriteMayHaveCommitted(error) {
7
+ const failure = error;
8
+ const status = failure?.status ?? failure?.response?.status;
9
+ return status === 408 || (typeof status === 'number' && status >= 500) ||
10
+ ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND'].includes(failure?.code ?? '') ||
11
+ /fetch failed|socket hang up|timed? ?out/i.test(failure?.message ?? '');
12
+ }
6
13
  class AgentsReleasesCreate extends base_command_1.BaseCommand {
7
14
  static description = 'Create a private agent release';
8
15
  static args = { agentId: core_1.Args.string({ required: true, ignoreStdin: true }) };
@@ -27,24 +34,63 @@ class AgentsReleasesCreate extends base_command_1.BaseCommand {
27
34
  exit: 2,
28
35
  });
29
36
  }
30
- const result = flags.beta
31
- ? await data_provider_1.dataService.createBetaAgentRelease({
32
- agentId: args.agentId,
33
- changelog: flags.changelog,
34
- versionMajor: flags['version-major'],
35
- versionMinor: flags['version-minor'],
36
- releaseId: flags['release-id'],
37
- offerVersionId: flags['offer-version-id'],
38
- })
39
- : await data_provider_1.dataService.createPrivateAgentRelease({
37
+ let result;
38
+ let recoveredAfterTransportFailure = false;
39
+ if (flags.beta) {
40
+ try {
41
+ result = await data_provider_1.dataService.createBetaAgentRelease({
42
+ agentId: args.agentId,
43
+ changelog: flags.changelog,
44
+ versionMajor: flags['version-major'],
45
+ versionMinor: flags['version-minor'],
46
+ releaseId: flags['release-id'],
47
+ offerVersionId: flags['offer-version-id'],
48
+ });
49
+ }
50
+ catch (error) {
51
+ if (!betaWriteMayHaveCommitted(error))
52
+ throw error;
53
+ const releaseId = flags['release-id'];
54
+ let verification = 'unavailable';
55
+ try {
56
+ const releases = await data_provider_1.dataService.getAgentReleases(args.agentId);
57
+ const found = releases.data?.find((release) => release.id === releaseId);
58
+ if (found) {
59
+ result = found;
60
+ recoveredAfterTransportFailure = true;
61
+ }
62
+ else {
63
+ verification = 'not_observed';
64
+ }
65
+ }
66
+ catch {
67
+ // A failed read cannot settle whether the write reached the server.
68
+ }
69
+ if (!result) {
70
+ const suggestion = `Run skrr agents releases list ${args.agentId} --json and check for ${releaseId} before retrying.`;
71
+ this.failWithCliError({
72
+ message: `The beta publish request ended before the server confirmed its outcome. ${suggestion}`,
73
+ code: 'BETA_RELEASE_OUTCOME_UNCONFIRMED',
74
+ exit: 1,
75
+ retryable: false,
76
+ details: { agentId: args.agentId, releaseId, verification, suggestion },
77
+ });
78
+ }
79
+ }
80
+ }
81
+ else {
82
+ result = await data_provider_1.dataService.createPrivateAgentRelease({
40
83
  agentId: args.agentId,
41
84
  changelog: flags.changelog,
42
85
  versionMajor: flags['version-major'],
43
86
  versionMinor: flags['version-minor'],
44
87
  });
88
+ }
45
89
  this.log(flags.json
46
90
  ? this.agentJson(args.agentId, result)
47
- : `Created release for agent ${args.agentId}`);
91
+ : recoveredAfterTransportFailure
92
+ ? `Beta release ${flags['release-id']} exists for agent ${args.agentId}; the publish response was interrupted.`
93
+ : `Created release for agent ${args.agentId}`);
48
94
  if (!flags.json)
49
95
  this.printAgentUrl(args.agentId);
50
96
  }
@@ -105,6 +105,9 @@ class DedicatedMachinesCp extends base_command_1.BaseCommand {
105
105
  if (err instanceof dedicated_copy_1.DedicatedCopyError) {
106
106
  this.error(err.message, { exit: 1, code: err.code });
107
107
  }
108
+ // Keep the API status, request id and explicit retry decision together.
109
+ if ((0, base_command_1.apiRefusal)(err).body?.retryable === true)
110
+ this.handleApiError(err);
108
111
  const message = (0, dedicated_machines_1.formatDedicatedRuntimeApiError)(err);
109
112
  if (message)
110
113
  this.error(message, { exit: 1, code: (0, dedicated_machines_1.dedicatedRuntimeApiErrorCode)(err) });
@@ -154,6 +154,9 @@ class DedicatedMachinesExec extends base_command_1.BaseCommand {
154
154
  err instanceof dedicated_exec_1.DedicatedExecIdempotencyUnsupportedError) {
155
155
  this.error(err.message, { exit: 1, code: err.code });
156
156
  }
157
+ // Keep the API status, request id and explicit retry decision together.
158
+ if ((0, base_command_1.apiRefusal)(err).body?.retryable === true)
159
+ this.handleApiError(err);
157
160
  const message = (0, dedicated_machines_1.formatDedicatedRuntimeApiError)(err);
158
161
  if (message)
159
162
  this.error(message, { exit: 1, code: (0, dedicated_machines_1.dedicatedRuntimeApiErrorCode)(err) });
@@ -8,6 +8,7 @@ export default class SpacesSnapshot extends BaseCommand {
8
8
  static flags: {
9
9
  json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
10
  'from-json': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
11
+ 'update-template': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
11
12
  title: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
12
13
  description: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
13
14
  'short-description': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
@@ -21,12 +21,13 @@ const CATEGORY_OPTIONS = [
21
21
  'other',
22
22
  ];
23
23
  class SpacesSnapshot extends base_command_1.BaseCommand {
24
- static description = 'Create a snapshot (template) from a space';
24
+ static description = 'Create or recapture a template from a space';
25
25
  static examples = [
26
26
  '<%= config.bin %> spaces snapshot <space-id> --title "Q2 Launch Template"',
27
27
  '<%= config.bin %> spaces snapshot <space-id> --title "Support" --category engineering --public',
28
28
  '<%= config.bin %> spaces snapshot <space-id> --from-json snapshot.json',
29
29
  '<%= config.bin %> spaces snapshot <space-id> --from-json snapshot.json --title "Override"',
30
+ '<%= config.bin %> spaces snapshot <source-space-id> --update-template <template-id> --title "Q2 Launch v2"',
30
31
  ];
31
32
  static args = {
32
33
  id: core_1.Args.string({ description: 'Space ID', required: true, ignoreStdin: true }),
@@ -36,6 +37,9 @@ class SpacesSnapshot extends base_command_1.BaseCommand {
36
37
  'from-json': core_1.Flags.string({
37
38
  description: 'Path to a JSON file (or "-" for stdin) to use as the request body',
38
39
  }),
40
+ 'update-template': core_1.Flags.string({
41
+ description: 'Recapture this existing template from its original Space',
42
+ }),
39
43
  title: core_1.Flags.string({ description: 'Snapshot title (required unless set via --from-json)' }),
40
44
  description: core_1.Flags.string({ description: 'Snapshot description' }),
41
45
  'short-description': core_1.Flags.string({ description: 'Short description (one liner)' }),
@@ -58,6 +62,7 @@ class SpacesSnapshot extends base_command_1.BaseCommand {
58
62
  this.error(err.message, { exit: 1 });
59
63
  }
60
64
  const payload = (0, prompt_1.mergeDefined)(body, {
65
+ templateId: flags['update-template'],
61
66
  title: flags.title,
62
67
  description: flags.description,
63
68
  shortDescription: flags['short-description'],
@@ -83,7 +88,7 @@ class SpacesSnapshot extends base_command_1.BaseCommand {
83
88
  return;
84
89
  }
85
90
  const s = result;
86
- this.log(`Snapshot created: ${s.id ?? s._id ?? '?'} — ${String(payload.title)}`);
91
+ this.log(`Snapshot ${payload.templateId ? 'updated' : 'created'}: ${s.id ?? s._id ?? '?'} — ${String(payload.title)}`);
87
92
  }
88
93
  }
89
94
  exports.default = SpacesSnapshot;
@@ -19,10 +19,9 @@ class StoreInstalls extends base_command_1.BaseCommand {
19
19
  this.requireAuth();
20
20
  const { flags } = await this.parse(StoreInstalls);
21
21
  const workspaceId = await (0, workspaces_1.ambientWorkspaceId)();
22
- (0, data_provider_1.setWorkspaceIdHeader)(workspaceId ?? null);
23
22
  let response;
24
23
  try {
25
- response = await data_provider_1.dataService.getStoreInstalls();
24
+ response = await data_provider_1.dataService.getStoreInstalls(workspaceId);
26
25
  }
27
26
  catch (error) {
28
27
  this.handleApiError(error);
@@ -34,6 +34,9 @@ class StoreSignals extends base_command_1.BaseCommand {
34
34
  const fmt = (v) => `$${((v ?? 0) / 1_000_000).toFixed(4)}`;
35
35
  this.log(`Run cost ${fmt(s.measuredRunCost.minCostMicros)}–${fmt(s.measuredRunCost.maxCostMicros)} per run (measured, ${s.measuredRunCost.cohorts} buyers, ${s.measuredRunCost.windowDays}d)`);
36
36
  }
37
+ else {
38
+ this.log('Run cost Insufficient data (fewer than 5 independent buyers)');
39
+ }
37
40
  }
38
41
  catch (err) {
39
42
  this.handleApiError(err);
@@ -42,7 +42,7 @@ function parseReceipt(body) {
42
42
  : null;
43
43
  }
44
44
  class StoreUninstall extends base_command_1.BaseCommand {
45
- static description = 'Uninstall an agent you installed from the store. Reinstalling restores it — unless you pass --erase, which also deletes its memory and ends its commitments (irreversible). The agent, your purchase and its delivered tasks are always kept.';
45
+ static description = 'Uninstall is reversible: reinstalling restores the Agent copy. --erase permanently removes its Memory and ends commitments; the Agent, purchase, and delivered tasks remain.';
46
46
  static examples = [
47
47
  '<%= config.bin %> store uninstall agent_abc123',
48
48
  '<%= config.bin %> store uninstall agent_abc123 --erase',
@@ -5,7 +5,7 @@ const data_provider_1 = require("@skrr-ai/data-provider");
5
5
  const base_command_1 = require("../../base-command");
6
6
  const prompt_1 = require("../../lib/prompt");
7
7
  class SubscriptionsSubscribe extends base_command_1.BaseCommand {
8
- static description = 'Subscribe to a published Service — pays its first period from your balance, installs your own managed instance, and opens the obligation it is delivered through. Shows the price and asks you to confirm first.';
8
+ static description = 'Subscribe to a published Service. By default, this shows the first-period price before buying from your balance, installing a managed instance, and opening its delivery obligation. Use --trial for the Service\'s card-free trial instead.';
9
9
  static examples = [
10
10
  '<%= config.bin %> subscriptions subscribe agent_abc123',
11
11
  '<%= config.bin %> subscriptions subscribe agent_abc123 --space space_xyz --yes --json',
@@ -29,7 +29,7 @@ class SubscriptionsSubscribe extends base_command_1.BaseCommand {
29
29
  description: 'Activate a period that was already paid (its grant id) instead of buying one. Nothing is charged.',
30
30
  }),
31
31
  trial: core_1.Flags.boolean({
32
- description: 'Start the card-free trial instead of buying: 7 days, nothing charged, does not renew (OSK-12281).',
32
+ description: 'Start this Service\'s card-free trial instead of buying. Its duration and compute terms are shown before confirmation; nothing is charged and it does not renew.',
33
33
  }),
34
34
  yes: core_1.Flags.boolean({
35
35
  char: 'y',
@@ -60,6 +60,12 @@ export type ReconciledTerminal = {
60
60
  * drift (OSK-7629).
61
61
  */
62
62
  export declare function writeJsonLine(output: NodeJS.WritableStream, value: unknown): void;
63
+ export type EnvelopeReplayPage = {
64
+ envelopes: unknown[];
65
+ hasMore?: boolean;
66
+ nextSeq?: number;
67
+ gap?: boolean;
68
+ };
63
69
  export type AgenticStreamOptions = {
64
70
  baseURL: string;
65
71
  token: string;
@@ -89,6 +95,10 @@ export type AgenticStreamOptions = {
89
95
  * the stream alone.
90
96
  */
91
97
  reconcileTerminal?: () => Promise<ReconciledTerminal | null>;
98
+ /** Owner-authorized canonical envelopes, including events emitted before room admission. */
99
+ replayEnvelopes?: (from: number) => Promise<EnvelopeReplayPage>;
100
+ /** Bounded live catch-up cadence while a run is active. Default 2s. */
101
+ envelopeReplayIntervalMs?: number;
92
102
  /** Silence before the first durable-record probe. Default 20s. */
93
103
  idleTerminalProbeMs?: number;
94
104
  /** How long to keep trying to attach before reporting a detach. Default 30s. */
@@ -376,6 +386,12 @@ export declare class AgenticStreamClient {
376
386
  private accumulated;
377
387
  /** Highest envelope `__seq` folded into `accumulated`, for gap detection. */
378
388
  private highestSeq;
389
+ private readonly pendingEnvelopes;
390
+ private replayTimer;
391
+ private replayFlight;
392
+ private replayGeneration;
393
+ private replayFailures;
394
+ private finishingReplay;
379
395
  private reportedGap;
380
396
  private readonly seenEnvelopes;
381
397
  /**
@@ -487,6 +503,12 @@ export declare class AgenticStreamClient {
487
503
  * what the run reports — only what the reader is told while it is happening.
488
504
  */
489
505
  private reportSeqGap;
506
+ /** Keep later live frames behind missing earlier frames while canonical replay catches up. */
507
+ private receiveSessionOutput;
508
+ private flushPendingEnvelopes;
509
+ /** Poll even while a tool is quiet: a pre-join start has no later live event to reveal its gap. */
510
+ private catchUpEnvelopes;
511
+ private applySessionOutput;
490
512
  private installHandlers;
491
513
  private handlePermission;
492
514
  private handleQuestion;
@@ -534,6 +556,7 @@ export declare class AgenticStreamClient {
534
556
  private emitToolEvent;
535
557
  private emit;
536
558
  private finish;
559
+ private finishAfterReplay;
537
560
  /**
538
561
  * The answer as the durable record holds it, for a run whose stream had a
539
562
  * hole. Bounded: the record is written before the terminal event is sent, so
@@ -628,6 +628,12 @@ class AgenticStreamClient {
628
628
  accumulated = '';
629
629
  /** Highest envelope `__seq` folded into `accumulated`, for gap detection. */
630
630
  highestSeq = 0;
631
+ pendingEnvelopes = new Map();
632
+ replayTimer = null;
633
+ replayFlight = null;
634
+ replayGeneration = 0;
635
+ replayFailures = 0;
636
+ finishingReplay = false;
631
637
  reportedGap = false;
632
638
  // Envelope keys already applied, so a re-delivered `session:output` frame is
633
639
  // not appended twice (OSK-4834). Cleared on `execution:stream-reset`.
@@ -726,6 +732,7 @@ class AgenticStreamClient {
726
732
  this.socket.emit('session:join', this.sessionJoinPayload());
727
733
  this.emit({ type: 'session.joined', sessionId: this.options.sessionId });
728
734
  this.armIdleProbe();
735
+ void this.catchUpEnvelopes();
729
736
  return result;
730
737
  }
731
738
  /**
@@ -835,6 +842,10 @@ class AgenticStreamClient {
835
842
  clearTimeout(this.idleTimer);
836
843
  this.idleTimer = null;
837
844
  }
845
+ if (this.replayTimer)
846
+ clearTimeout(this.replayTimer);
847
+ this.replayTimer = null;
848
+ this.pendingEnvelopes.clear();
838
849
  this.abandonConnectWait?.();
839
850
  const socket = this.socket;
840
851
  this.socket = null;
@@ -883,12 +894,145 @@ class AgenticStreamClient {
883
894
  this.highestSeq = seq;
884
895
  }
885
896
  }
897
+ /** Keep later live frames behind missing earlier frames while canonical replay catches up. */
898
+ receiveSessionOutput(event) {
899
+ if (this.closed || this.settled)
900
+ return;
901
+ const envelopes = event.envelopes;
902
+ if (!this.options.replayEnvelopes || !Array.isArray(envelopes)) {
903
+ this.applySessionOutput(event);
904
+ return;
905
+ }
906
+ for (const envelope of envelopes) {
907
+ const seq = Number(envelope?.__seq);
908
+ if (!Number.isSafeInteger(seq) || seq <= 0) {
909
+ this.applySessionOutput({ ...event, envelopes: [envelope] });
910
+ }
911
+ else if (seq > this.highestSeq && !this.pendingEnvelopes.has(seq)) {
912
+ this.pendingEnvelopes.set(seq, { envelope, event });
913
+ }
914
+ }
915
+ this.flushPendingEnvelopes(false);
916
+ if (this.pendingEnvelopes.size > 2000)
917
+ this.flushPendingEnvelopes(true);
918
+ if (this.pendingEnvelopes.size)
919
+ void this.catchUpEnvelopes();
920
+ }
921
+ flushPendingEnvelopes(allowGap, through = Infinity) {
922
+ const seqs = [...this.pendingEnvelopes.keys()].sort((a, b) => a - b);
923
+ for (const seq of seqs) {
924
+ if (seq > through)
925
+ break;
926
+ if (!allowGap && seq !== this.highestSeq + 1)
927
+ break;
928
+ const item = this.pendingEnvelopes.get(seq);
929
+ this.pendingEnvelopes.delete(seq);
930
+ this.applySessionOutput({ ...item.event, envelopes: [item.envelope] });
931
+ }
932
+ }
933
+ /** Poll even while a tool is quiet: a pre-join start has no later live event to reveal its gap. */
934
+ catchUpEnvelopes() {
935
+ if (!this.options.replayEnvelopes || this.closed || this.settled)
936
+ return Promise.resolve();
937
+ if (this.replayFlight)
938
+ return this.replayFlight;
939
+ if (this.replayTimer)
940
+ clearTimeout(this.replayTimer);
941
+ this.replayTimer = null;
942
+ const generation = this.replayGeneration;
943
+ this.replayFlight = (async () => {
944
+ try {
945
+ for (let page = 0; page < 4; page += 1) {
946
+ const from = this.highestSeq;
947
+ const result = await this.options.replayEnvelopes(from);
948
+ if (this.closed || this.settled || generation !== this.replayGeneration)
949
+ return;
950
+ this.replayFailures = 0;
951
+ this.receiveSessionOutput({ type: 'session.output', sessionId: this.options.sessionId, source: 'replay', envelopes: result.envelopes });
952
+ // The canonical endpoint explicitly identifies retention gaps. Only
953
+ // then may later frames pass with the existing honest gap notice.
954
+ if (result.gap) {
955
+ // A retention gap proves only this page, never a later live frame.
956
+ const pageSeqs = result.envelopes.map((e) => Number(e?.__seq)).filter(Number.isSafeInteger);
957
+ if (pageSeqs.length)
958
+ this.flushPendingEnvelopes(true, Math.max(...pageSeqs));
959
+ this.flushPendingEnvelopes(false);
960
+ }
961
+ if (!result.hasMore || this.highestSeq <= from)
962
+ break;
963
+ }
964
+ }
965
+ catch {
966
+ // Recovery failure says nothing about the producer. After bounded
967
+ // retries retain the existing live/gap/final-record fallback.
968
+ this.replayFailures += 1;
969
+ if (this.replayFailures >= 3)
970
+ this.flushPendingEnvelopes(true);
971
+ }
972
+ })().finally(() => {
973
+ this.replayFlight = null;
974
+ if (!this.closed && !this.settled && !this.finishingReplay) {
975
+ this.replayTimer = setTimeout(() => void this.catchUpEnvelopes(), this.options.envelopeReplayIntervalMs ?? 2000);
976
+ }
977
+ });
978
+ return this.replayFlight;
979
+ }
980
+ applySessionOutput(event) {
981
+ // The agent's words arrive HERE for agentic runs, inside `envelopes`, and
982
+ // `event.text` is null. Normalising the frame without reading them meant
983
+ // `skrr agents chat` printed two header lines and exited having shown
984
+ // nothing, while the agent had answered perfectly — and `--no-stream
985
+ // --json` returned `status: completed` with `response: ""`.
986
+ //
987
+ // Only `ev.t === 'text'` counts. The same envelope stream carries tool
988
+ // and status frames, and folding those into the answer would put machine
989
+ // chatter in `response`.
990
+ // Dedupe re-delivered envelopes (session room + user room + join replay):
991
+ // append and reprint only the ones we have not applied yet (OSK-4834).
992
+ const rawEnvelopes = event.envelopes;
993
+ if (Array.isArray(rawEnvelopes) && rawEnvelopes.length > 0) {
994
+ const fresh = unseenEnvelopes(event, this.seenEnvelopes);
995
+ if (fresh.length === 0)
996
+ return; // a pure re-delivery — drop it whole
997
+ const scoped = { ...event, envelopes: fresh };
998
+ // Say what never arrived, once, before folding this batch in
999
+ // (OSK-7631). Checked against the FRESH set: a re-delivery carries old
1000
+ // seqs and would read as a gap that never happened.
1001
+ this.reportSeqGap(fresh);
1002
+ const envelopeText = envelopesText(scoped);
1003
+ if (envelopeText) {
1004
+ this.accumulated += envelopeText;
1005
+ this.emit({ ...scoped, type: 'text.delta', text: this.accumulated });
1006
+ }
1007
+ // A daemon-routed turn carries its tool calls HERE and nowhere else.
1008
+ // Projecting them gives both execution paths one vocabulary (OSK-7730).
1009
+ for (const projected of projectDaemonToolEvents(fresh, this.toolNamesByCall)) {
1010
+ this.emitToolEvent({ ...projected, sessionId: this.options.sessionId });
1011
+ }
1012
+ // Notices meant for the person — a local harness that could not resume
1013
+ // its previous session and started a fresh one (OSK-12131). A plain
1014
+ // `service` line is diagnostic chatter and stays in the envelope; one
1015
+ // marked `notice` is said out loud.
1016
+ for (const notice of projectSessionNotices(fresh)) {
1017
+ this.emit({ ...notice, sessionId: this.options.sessionId });
1018
+ }
1019
+ // Who decided each permission request — a person (and where), the
1020
+ // mode, a policy — said beside the decision (OSK-12113).
1021
+ for (const resolved of projectPermissionResolutions(fresh, this.toolNamesByPermission)) {
1022
+ this.emit({ ...resolved, sessionId: this.options.sessionId });
1023
+ }
1024
+ this.emit({ ...scoped, type: 'session.output' });
1025
+ return;
1026
+ }
1027
+ this.emit({ ...event, type: 'session.output' });
1028
+ }
886
1029
  installHandlers() {
887
1030
  const socket = this.socket;
888
1031
  const match = (event) => !event.sessionId || event.sessionId === this.options.sessionId;
889
1032
  socket.on('connect', () => {
890
1033
  socket.emit('session:join', this.sessionJoinPayload());
891
1034
  this.emit({ type: 'transport.connected', sessionId: this.options.sessionId });
1035
+ void this.catchUpEnvelopes();
892
1036
  });
893
1037
  socket.on('disconnect', (reason) => {
894
1038
  this.transportDropped = true;
@@ -942,55 +1086,8 @@ class AgenticStreamClient {
942
1086
  this.emit({ ...event, type: 'text.delta' });
943
1087
  });
944
1088
  socket.on('session:output', (event) => {
945
- if (!match(event))
946
- return;
947
- // The agent's words arrive HERE for agentic runs, inside `envelopes`, and
948
- // `event.text` is null. Normalising the frame without reading them meant
949
- // `skrr agents chat` printed two header lines and exited having shown
950
- // nothing, while the agent had answered perfectly — and `--no-stream
951
- // --json` returned `status: completed` with `response: ""`.
952
- //
953
- // Only `ev.t === 'text'` counts. The same envelope stream carries tool
954
- // and status frames, and folding those into the answer would put machine
955
- // chatter in `response`.
956
- // Dedupe re-delivered envelopes (session room + user room + join replay):
957
- // append and reprint only the ones we have not applied yet (OSK-4834).
958
- const rawEnvelopes = event.envelopes;
959
- if (Array.isArray(rawEnvelopes) && rawEnvelopes.length > 0) {
960
- const fresh = unseenEnvelopes(event, this.seenEnvelopes);
961
- if (fresh.length === 0)
962
- return; // a pure re-delivery — drop it whole
963
- const scoped = { ...event, envelopes: fresh };
964
- // Say what never arrived, once, before folding this batch in
965
- // (OSK-7631). Checked against the FRESH set: a re-delivery carries old
966
- // seqs and would read as a gap that never happened.
967
- this.reportSeqGap(fresh);
968
- const envelopeText = envelopesText(scoped);
969
- if (envelopeText) {
970
- this.accumulated += envelopeText;
971
- this.emit({ ...scoped, type: 'text.delta', text: this.accumulated });
972
- }
973
- // A daemon-routed turn carries its tool calls HERE and nowhere else.
974
- // Projecting them gives both execution paths one vocabulary (OSK-7730).
975
- for (const projected of projectDaemonToolEvents(fresh, this.toolNamesByCall)) {
976
- this.emitToolEvent({ ...projected, sessionId: this.options.sessionId });
977
- }
978
- // Notices meant for the person — a local harness that could not resume
979
- // its previous session and started a fresh one (OSK-12131). A plain
980
- // `service` line is diagnostic chatter and stays in the envelope; one
981
- // marked `notice` is said out loud.
982
- for (const notice of projectSessionNotices(fresh)) {
983
- this.emit({ ...notice, sessionId: this.options.sessionId });
984
- }
985
- // Who decided each permission request — a person (and where), the
986
- // mode, a policy — said beside the decision (OSK-12113).
987
- for (const resolved of projectPermissionResolutions(fresh, this.toolNamesByPermission)) {
988
- this.emit({ ...resolved, sessionId: this.options.sessionId });
989
- }
990
- this.emit({ ...scoped, type: 'session.output' });
991
- return;
992
- }
993
- this.emit({ ...event, type: 'session.output' });
1089
+ if (match(event))
1090
+ this.receiveSessionOutput(event);
994
1091
  });
995
1092
  socket.on('tool:start', (event) => {
996
1093
  if (match(event))
@@ -1011,6 +1108,8 @@ class AgenticStreamClient {
1011
1108
  this.renderedText = '';
1012
1109
  // Seqs restart with the stream, so a fresh low seq is not a gap.
1013
1110
  this.highestSeq = 0;
1111
+ this.replayGeneration += 1;
1112
+ this.pendingEnvelopes.clear();
1014
1113
  this.reportedGap = false;
1015
1114
  // A reset restarts the stream, so envelope seqs may repeat; forget the
1016
1115
  // seen keys or the fresh post-reset text would be deduped away (OSK-4834).
@@ -1429,6 +1528,27 @@ class AgenticStreamClient {
1429
1528
  }
1430
1529
  }
1431
1530
  finish(status, event) {
1531
+ if (this.settled || this.finishingReplay)
1532
+ return;
1533
+ if (this.options.replayEnvelopes) {
1534
+ this.finishingReplay = true;
1535
+ const activeReplay = this.replayFlight;
1536
+ void (async () => {
1537
+ if (activeReplay)
1538
+ await activeReplay;
1539
+ // A request already in flight may have read before the terminal event.
1540
+ await this.catchUpEnvelopes();
1541
+ })().finally(() => {
1542
+ if (this.closed || this.settled)
1543
+ return;
1544
+ this.flushPendingEnvelopes(true);
1545
+ this.finishAfterReplay(status, event);
1546
+ });
1547
+ return;
1548
+ }
1549
+ this.finishAfterReplay(status, event);
1550
+ }
1551
+ finishAfterReplay(status, event) {
1432
1552
  if (this.settled)
1433
1553
  return;
1434
1554
  this.settled = true;