@skrr-ai/cli 0.1.85 → 0.1.87

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.
@@ -20,6 +20,12 @@ export type AgenticStreamResult = {
20
20
  responseMessageId?: string;
21
21
  text: string;
22
22
  status: 'completed' | 'aborted' | 'failed';
23
+ /**
24
+ * The run first failed as handed over (a deploy replaced the relaying server)
25
+ * and the machine's completed reply was then repaired onto the record
26
+ * (OSK-13543). Absent on every ordinary run.
27
+ */
28
+ repaired?: true;
23
29
  lastEvent?: AgenticStreamEvent;
24
30
  };
25
31
  /**
@@ -45,7 +51,82 @@ export type ReconciledTerminal = {
45
51
  text?: string;
46
52
  /** The recorded failure reason — `metadata.agentic.error` on the message. */
47
53
  error?: string;
54
+ /**
55
+ * The failure is a hand-over, not an ending (OSK-13543): the row is failed
56
+ * with {@link DAEMON_HANDOVER_REASON}, which the server itself treats as
57
+ * "outcome pending the machine's own turn-end".
58
+ */
59
+ handedOver?: boolean;
60
+ };
61
+ /**
62
+ * OSK-13517 / OSK-13543 — the one failure that is not an ending.
63
+ *
64
+ * A deploy replaced the server RELAYING a local-daemon turn while the daemon
65
+ * kept running it. The server saves the turn `error: true` with this reason
66
+ * (`metadata.agentic.errorSubtype`), keeps the conversation quarantined, and
67
+ * converges the row later from the machine's own turn-end: completed →
68
+ * repaired (`error: false`, `metadata.agentic.deliveryRepair`), anything else →
69
+ * settled failed (`errorSubtype: worker_replaced_daemon_failed`).
70
+ *
71
+ * Mirrors `WORKER_REPLACED_DAEMON_RUNNING_REASON` in
72
+ * `api/server/services/AgentWorker/workerReplacedNotice.js` and the code
73
+ * beside it in `daemonHandlers.js` / `conversationAdmissionClaim.js`; a parity
74
+ * spec reads those files so the two cannot drift.
75
+ */
76
+ export declare const WORKER_REPLACED_CODE = "AGENT_WORKER_REPLACED";
77
+ export declare const DAEMON_HANDOVER_REASON = "worker_replaced_daemon_running";
78
+ /**
79
+ * Is this terminal event the hand-over failure, and nothing else?
80
+ *
81
+ * The server sends it on two paths with different shapes, so the reason is
82
+ * read wherever either puts it:
83
+ * - the QueueEvents backstop (`queue.js`): `{code: 'AGENT_WORKER_REPLACED',
84
+ * errorType: 'worker_replaced_daemon_running', errors}`;
85
+ * - the worker's own result (`buildExecutionErrorEvent`): `errorType` and
86
+ * `reason` both carry the daemon reason, `code` only when the result had one.
87
+ * The plain deploy failure shares the CODE (`errorType: 'worker_replaced'`, the
88
+ * turn really ended), so the code alone must never qualify, and a code that
89
+ * is present but different disqualifies.
90
+ */
91
+ export declare function isDaemonHandoverFailure(event: Record<string, unknown> | null | undefined): boolean;
92
+ /** What the durable record says about a handed-over turn, read while waiting. */
93
+ export type HandoverReading =
94
+ /** Still failed with the hand-over reason, or not yet written: keep waiting. */
95
+ {
96
+ state: 'pending';
97
+ }
98
+ /** The machine finished and the server repaired the row. */
99
+ | {
100
+ state: 'repaired';
101
+ text: string;
102
+ repairedAt?: string;
103
+ }
104
+ /** The machine's turn ended without finishing, and the server settled it so. */
105
+ | {
106
+ state: 'failed';
107
+ error: string;
108
+ text?: string;
109
+ };
110
+ export type HandoverWaitOptions = {
111
+ /** Read the persisted response message. A throw or null is "no evidence". */
112
+ read: () => Promise<HandoverReading | null>;
113
+ /** The response message the repair notification names. */
114
+ responseMessageId?: string;
115
+ conversationId?: string;
116
+ /** How long to wait for the machine's reply. 0 disables the wait. */
117
+ maxWaitMs: number;
118
+ /** Fallback re-read cadence when no notification arrives. Default 15s. */
119
+ pollIntervalMs?: number;
120
+ /**
121
+ * Where the human status line goes when stdout is reserved for the final
122
+ * result (`--no-stream`). Default stderr.
123
+ */
124
+ statusOutput?: NodeJS.WritableStream;
48
125
  };
126
+ /** Default fallback re-read cadence while waiting on a handed-over turn. */
127
+ export declare const HANDOVER_POLL_INTERVAL_MS = 15000;
128
+ /** `600000` → `10m`, `90000` → `1m30s`, `45000` → `45s`. */
129
+ export declare function formatWaitDuration(ms: number): string;
49
130
  /**
50
131
  * Write exactly one complete JSON value on one line.
51
132
  *
@@ -106,6 +187,12 @@ export type AgenticStreamOptions = {
106
187
  * owns that stream and its contract does not include diagnostics.
107
188
  */
108
189
  debug?: (line: string) => void;
190
+ /**
191
+ * OSK-13543 — wait for a handed-over local-daemon turn instead of ending on
192
+ * its failure. See {@link isDaemonHandoverFailure}. Omitted: today's
193
+ * behaviour, every `execution:error` settles the run at once.
194
+ */
195
+ awaitHandover?: HandoverWaitOptions;
109
196
  /** Silence before the first durable-record probe. Default 20s. */
110
197
  idleTerminalProbeMs?: number;
111
198
  /** How long to keep trying to attach before reporting a detach. Default 30s. */
@@ -493,6 +580,11 @@ export declare class AgenticStreamClient {
493
580
  * a reconciled detach report the real outcome instead of the detach.
494
581
  */
495
582
  private terminalResult;
583
+ /**
584
+ * A handed-over turn being waited on (OSK-13543): the failure event that
585
+ * started the wait, and the timers bounding it. Null otherwise.
586
+ */
587
+ private handover;
496
588
  private resolve;
497
589
  private reject;
498
590
  /** The terminal result, or null while the run is still in flight. */
@@ -542,6 +634,44 @@ export declare class AgenticStreamClient {
542
634
  probeTerminal(reason: string): Promise<boolean>;
543
635
  cancel(reason?: string): void;
544
636
  close(): void;
637
+ /**
638
+ * True while a handed-over turn is being waited on (OSK-13543). A Ctrl-C in
639
+ * this state stops the wait; it cannot cancel the machine's turn.
640
+ */
641
+ awaitingHandover(): boolean;
642
+ /**
643
+ * OSK-13543 — the run failed because a deploy replaced the server relaying a
644
+ * turn the machine is still running. That failure is the server's PROVISIONAL
645
+ * verdict: it keeps the conversation held and rewrites the row from the
646
+ * machine's own turn-end. Ending here told a CLI user "failed" about a turn
647
+ * whose answer the web showed ninety seconds later.
648
+ *
649
+ * So keep the stream, the user-room subscription and the envelope replay
650
+ * open, and settle on what the RECORD converges to: repaired → completed,
651
+ * settled failed → failed with the machine's reason, neither within the
652
+ * bound → the original failure. The record, not the notification, decides:
653
+ * a notification only says "read again", so a lost or duplicated one costs
654
+ * at most a poll interval.
655
+ *
656
+ * Returns false (and changes nothing) when the run cannot wait: no hand-over
657
+ * reader configured, a zero bound, or a run already settling.
658
+ */
659
+ private beginHandoverWait;
660
+ /** Ask the record again; coalesces with a read already in flight. */
661
+ private requestHandoverRead;
662
+ /** One read of the record. True when it settled the run. */
663
+ private readHandover;
664
+ /** The bound ran out: one last look, then the original failure. */
665
+ private expireHandover;
666
+ private stopHandoverTimers;
667
+ private endHandover;
668
+ /**
669
+ * The server's "this row was rewritten" notice (`lateDeliveryRepair`
670
+ * `notifyViewersMessageRewritten`), delivered to every socket of the user
671
+ * through the `user:<id>` room this socket joins on connect. A hint to read,
672
+ * never a verdict.
673
+ */
674
+ private onConversationUpdate;
545
675
  private debug;
546
676
  /**
547
677
  * Record a hole being passed over — the moment replay has given up on it.
@@ -622,6 +752,11 @@ export declare class AgenticStreamClient {
622
752
  */
623
753
  private emitToolEvent;
624
754
  private emit;
755
+ /**
756
+ * Human-mode lines for the hand-over wait. Returns true when the event is
757
+ * fully rendered here; false lets the ordinary rendering continue.
758
+ */
759
+ private renderHandoverStatus;
625
760
  private finish;
626
761
  private finishAfterReplay;
627
762
  /**
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.CODEX_DECLINED_TOOL_OUTPUTS = exports.TYPE_AHEAD_DISCARD_MS = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
3
+ exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.CODEX_DECLINED_TOOL_OUTPUTS = exports.TYPE_AHEAD_DISCARD_MS = exports.HANDOVER_POLL_INTERVAL_MS = exports.DAEMON_HANDOVER_REASON = exports.WORKER_REPLACED_CODE = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
4
+ exports.isDaemonHandoverFailure = isDaemonHandoverFailure;
5
+ exports.formatWaitDuration = formatWaitDuration;
4
6
  exports.writeJsonLine = writeJsonLine;
5
7
  exports.discardTypeAhead = discardTypeAhead;
6
8
  exports.describePermissionTarget = describePermissionTarget;
@@ -41,6 +43,54 @@ const dpop_auth_1 = require("./dpop-auth");
41
43
  */
42
44
  exports.NON_INTERACTIVE_QUESTION_DISMISSAL = 'No answer: this turn was started non-interactively (no terminal), so nobody can reply to ' +
43
45
  'questions. Proceed with your best judgement, or finish by stating exactly what you need.';
46
+ /**
47
+ * OSK-13517 / OSK-13543 — the one failure that is not an ending.
48
+ *
49
+ * A deploy replaced the server RELAYING a local-daemon turn while the daemon
50
+ * kept running it. The server saves the turn `error: true` with this reason
51
+ * (`metadata.agentic.errorSubtype`), keeps the conversation quarantined, and
52
+ * converges the row later from the machine's own turn-end: completed →
53
+ * repaired (`error: false`, `metadata.agentic.deliveryRepair`), anything else →
54
+ * settled failed (`errorSubtype: worker_replaced_daemon_failed`).
55
+ *
56
+ * Mirrors `WORKER_REPLACED_DAEMON_RUNNING_REASON` in
57
+ * `api/server/services/AgentWorker/workerReplacedNotice.js` and the code
58
+ * beside it in `daemonHandlers.js` / `conversationAdmissionClaim.js`; a parity
59
+ * spec reads those files so the two cannot drift.
60
+ */
61
+ exports.WORKER_REPLACED_CODE = 'AGENT_WORKER_REPLACED';
62
+ exports.DAEMON_HANDOVER_REASON = 'worker_replaced_daemon_running';
63
+ /**
64
+ * Is this terminal event the hand-over failure, and nothing else?
65
+ *
66
+ * The server sends it on two paths with different shapes, so the reason is
67
+ * read wherever either puts it:
68
+ * - the QueueEvents backstop (`queue.js`): `{code: 'AGENT_WORKER_REPLACED',
69
+ * errorType: 'worker_replaced_daemon_running', errors}`;
70
+ * - the worker's own result (`buildExecutionErrorEvent`): `errorType` and
71
+ * `reason` both carry the daemon reason, `code` only when the result had one.
72
+ * The plain deploy failure shares the CODE (`errorType: 'worker_replaced'`, the
73
+ * turn really ended), so the code alone must never qualify, and a code that
74
+ * is present but different disqualifies.
75
+ */
76
+ function isDaemonHandoverFailure(event) {
77
+ if (!event)
78
+ return false;
79
+ if (event.code !== undefined && event.code !== exports.WORKER_REPLACED_CODE)
80
+ return false;
81
+ return [event.errorType, event.reason, event.daemonReason].includes(exports.DAEMON_HANDOVER_REASON);
82
+ }
83
+ /** Default fallback re-read cadence while waiting on a handed-over turn. */
84
+ exports.HANDOVER_POLL_INTERVAL_MS = 15_000;
85
+ /** `600000` → `10m`, `90000` → `1m30s`, `45000` → `45s`. */
86
+ function formatWaitDuration(ms) {
87
+ const total = Math.max(0, Math.round(ms / 1000));
88
+ const minutes = Math.floor(total / 60);
89
+ const seconds = total % 60;
90
+ if (!minutes)
91
+ return `${seconds}s`;
92
+ return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
93
+ }
44
94
  /**
45
95
  * Write exactly one complete JSON value on one line.
46
96
  *
@@ -745,6 +795,11 @@ class AgenticStreamClient {
745
795
  * a reconciled detach report the real outcome instead of the detach.
746
796
  */
747
797
  terminalResult = null;
798
+ /**
799
+ * A handed-over turn being waited on (OSK-13543): the failure event that
800
+ * started the wait, and the timers bounding it. Null otherwise.
801
+ */
802
+ handover = null;
748
803
  resolve;
749
804
  reject;
750
805
  /** The terminal result, or null while the run is still in flight. */
@@ -833,6 +888,11 @@ class AgenticStreamClient {
833
888
  return;
834
889
  if (this.settled || this.closed)
835
890
  return;
891
+ // While a handed-over turn is waited on, the record reads `error: true` BY
892
+ // DESIGN — it is the failure being waited out. The generic probe would
893
+ // settle on exactly that; the hand-over reader owns the record instead.
894
+ if (this.handover)
895
+ return;
836
896
  if (this.idleTimer)
837
897
  clearTimeout(this.idleTimer);
838
898
  this.idleTimer = setTimeout(() => void this.probeTerminal('idle'), this.options.idleTerminalProbeMs ?? 20_000);
@@ -846,7 +906,7 @@ class AgenticStreamClient {
846
906
  */
847
907
  async probeTerminal(reason) {
848
908
  const reconcile = this.options.reconcileTerminal;
849
- if (!reconcile || this.settled || this.reconciling)
909
+ if (!reconcile || this.settled || this.reconciling || this.handover)
850
910
  return false;
851
911
  this.reconciling = true;
852
912
  let terminal = null;
@@ -866,6 +926,23 @@ class AgenticStreamClient {
866
926
  this.armIdleProbe();
867
927
  return false;
868
928
  }
929
+ // The stream never delivered the hand-over failure (joined late, or the
930
+ // frame was lost), but the record carries it: wait the same way. Not on a
931
+ // detach — the caller has already stopped watching and asks only what the
932
+ // record says now.
933
+ if (terminal.handedOver && reason !== 'detach') {
934
+ const begun = this.beginHandoverWait({
935
+ type: 'execution:error',
936
+ sessionId: this.options.sessionId,
937
+ code: exports.WORKER_REPLACED_CODE,
938
+ errorType: exports.DAEMON_HANDOVER_REASON,
939
+ reason: exports.DAEMON_HANDOVER_REASON,
940
+ reconciledFrom: 'durable_record',
941
+ ...(terminal.error ? { message: terminal.error, errors: [terminal.error] } : {}),
942
+ });
943
+ if (begun)
944
+ return false;
945
+ }
869
946
  // The record is the witness the stream refused to be, so say so: the
870
947
  // recorded reason is exactly what "the surface shows nothing at all" cost
871
948
  // the reader (OSK-7595 — the CLI reported a transport error while the run's
@@ -883,6 +960,11 @@ class AgenticStreamClient {
883
960
  cancel(reason = 'Interrupted by user') {
884
961
  if (this.settled || !this.socket)
885
962
  return;
963
+ // A handed-over turn has no relaying server left to carry an interrupt to
964
+ // the machine, so sending one would claim a cancel nothing performs. The
965
+ // caller stops WAITING instead ({@link awaitingHandover}).
966
+ if (this.handover)
967
+ return;
886
968
  this.cancelled = true;
887
969
  this.socket.emit('execution:interrupt', {
888
970
  sessionId: this.options.sessionId,
@@ -902,6 +984,7 @@ class AgenticStreamClient {
902
984
  if (this.replayTimer)
903
985
  clearTimeout(this.replayTimer);
904
986
  this.replayTimer = null;
987
+ this.stopHandoverTimers();
905
988
  this.pendingEnvelopes.clear();
906
989
  this.abandonConnectWait?.();
907
990
  const socket = this.socket;
@@ -923,6 +1006,189 @@ class AgenticStreamClient {
923
1006
  this.readline?.close();
924
1007
  this.readline = null;
925
1008
  }
1009
+ /**
1010
+ * True while a handed-over turn is being waited on (OSK-13543). A Ctrl-C in
1011
+ * this state stops the wait; it cannot cancel the machine's turn.
1012
+ */
1013
+ awaitingHandover() {
1014
+ return this.handover !== null && !this.settled && !this.closed;
1015
+ }
1016
+ /**
1017
+ * OSK-13543 — the run failed because a deploy replaced the server relaying a
1018
+ * turn the machine is still running. That failure is the server's PROVISIONAL
1019
+ * verdict: it keeps the conversation held and rewrites the row from the
1020
+ * machine's own turn-end. Ending here told a CLI user "failed" about a turn
1021
+ * whose answer the web showed ninety seconds later.
1022
+ *
1023
+ * So keep the stream, the user-room subscription and the envelope replay
1024
+ * open, and settle on what the RECORD converges to: repaired → completed,
1025
+ * settled failed → failed with the machine's reason, neither within the
1026
+ * bound → the original failure. The record, not the notification, decides:
1027
+ * a notification only says "read again", so a lost or duplicated one costs
1028
+ * at most a poll interval.
1029
+ *
1030
+ * Returns false (and changes nothing) when the run cannot wait: no hand-over
1031
+ * reader configured, a zero bound, or a run already settling.
1032
+ */
1033
+ beginHandoverWait(event) {
1034
+ const opts = this.options.awaitHandover;
1035
+ if (!opts || !(opts.maxWaitMs > 0))
1036
+ return false;
1037
+ if (this.settled || this.closed || this.finishingReplay || this.cancelled)
1038
+ return false;
1039
+ if (this.handover)
1040
+ return true;
1041
+ if (this.idleTimer) {
1042
+ clearTimeout(this.idleTimer);
1043
+ this.idleTimer = null;
1044
+ }
1045
+ const startedAt = Date.now();
1046
+ // Deliberately NOT unref'd, for the reason `armIdleProbe` gives: an
1047
+ // unsettled run is a reason to stay alive (OSK-10061).
1048
+ const deadlineTimer = setTimeout(() => void this.expireHandover(), opts.maxWaitMs);
1049
+ const pollTimer = setInterval(() => this.requestHandoverRead(), opts.pollIntervalMs ?? exports.HANDOVER_POLL_INTERVAL_MS);
1050
+ this.handover = { event, startedAt, deadlineTimer, pollTimer, reading: null, readAgain: false };
1051
+ const firstError = Array.isArray(event.errors) && typeof event.errors[0] === 'string'
1052
+ ? event.errors[0]
1053
+ : undefined;
1054
+ const message = typeof event.message === 'string' && event.message ? event.message : firstError;
1055
+ this.emit({
1056
+ type: 'run.handed_over',
1057
+ sessionId: this.options.sessionId,
1058
+ code: exports.WORKER_REPLACED_CODE,
1059
+ reason: exports.DAEMON_HANDOVER_REASON,
1060
+ ...(message ? { message } : {}),
1061
+ ...(opts.responseMessageId ? { responseMessageId: opts.responseMessageId } : {}),
1062
+ waitMs: opts.maxWaitMs,
1063
+ deadlineAt: new Date(startedAt + opts.maxWaitMs).toISOString(),
1064
+ });
1065
+ // The repair can land before the failure notice reaches us (the queue's
1066
+ // backstop records and settles in one pass), so look once right away.
1067
+ this.requestHandoverRead();
1068
+ return true;
1069
+ }
1070
+ /** Ask the record again; coalesces with a read already in flight. */
1071
+ requestHandoverRead() {
1072
+ const handover = this.handover;
1073
+ if (!handover || this.settled || this.closed)
1074
+ return;
1075
+ if (handover.reading) {
1076
+ handover.readAgain = true;
1077
+ return;
1078
+ }
1079
+ handover.reading = this.readHandover().finally(() => {
1080
+ if (this.handover !== handover)
1081
+ return;
1082
+ handover.reading = null;
1083
+ if (handover.readAgain) {
1084
+ handover.readAgain = false;
1085
+ this.requestHandoverRead();
1086
+ }
1087
+ });
1088
+ }
1089
+ /** One read of the record. True when it settled the run. */
1090
+ async readHandover() {
1091
+ const handover = this.handover;
1092
+ const read = this.options.awaitHandover?.read;
1093
+ if (!handover || !read)
1094
+ return false;
1095
+ let reading = null;
1096
+ try {
1097
+ reading = await read();
1098
+ }
1099
+ catch {
1100
+ // A failed read is no evidence about the turn; the next trigger retries.
1101
+ reading = null;
1102
+ }
1103
+ if (this.handover !== handover || this.settled || this.closed)
1104
+ return false;
1105
+ if (!reading || reading.state === 'pending')
1106
+ return false;
1107
+ const waitedMs = Date.now() - handover.startedAt;
1108
+ this.endHandover();
1109
+ if (reading.state === 'repaired') {
1110
+ this.finish('completed', {
1111
+ type: 'execution:complete',
1112
+ sessionId: this.options.sessionId,
1113
+ result: reading.text,
1114
+ repaired: true,
1115
+ repairedFrom: 'durable_record',
1116
+ overturned: { code: exports.WORKER_REPLACED_CODE, reason: exports.DAEMON_HANDOVER_REASON },
1117
+ waitedMs,
1118
+ ...(reading.repairedAt ? { repairedAt: reading.repairedAt } : {}),
1119
+ });
1120
+ return true;
1121
+ }
1122
+ // The machine reported its own ending and the server settled the row on
1123
+ // it. That sentence replaces the provisional "still working" one, which is
1124
+ // now false; the original fields stay so a consumer keyed on them still
1125
+ // matches.
1126
+ this.finish('failed', {
1127
+ ...handover.event,
1128
+ message: reading.error,
1129
+ errors: [reading.error],
1130
+ ...(typeof reading.text === 'string' && reading.text ? { result: reading.text } : {}),
1131
+ handover: { outcome: 'machine_failed', waitedMs },
1132
+ });
1133
+ return true;
1134
+ }
1135
+ /** The bound ran out: one last look, then the original failure. */
1136
+ async expireHandover() {
1137
+ const handover = this.handover;
1138
+ if (!handover || this.settled || this.closed)
1139
+ return;
1140
+ if (handover.pollTimer)
1141
+ clearInterval(handover.pollTimer);
1142
+ handover.pollTimer = null;
1143
+ handover.deadlineTimer = null;
1144
+ if (handover.reading)
1145
+ await handover.reading;
1146
+ if (this.handover !== handover || this.settled || this.closed)
1147
+ return;
1148
+ if (await this.readHandover())
1149
+ return;
1150
+ if (this.handover !== handover || this.settled || this.closed)
1151
+ return;
1152
+ const waitedMs = Date.now() - handover.startedAt;
1153
+ this.endHandover();
1154
+ this.finish('failed', { ...handover.event, handover: { outcome: 'expired', waitedMs } });
1155
+ }
1156
+ stopHandoverTimers() {
1157
+ if (!this.handover)
1158
+ return;
1159
+ if (this.handover.deadlineTimer)
1160
+ clearTimeout(this.handover.deadlineTimer);
1161
+ if (this.handover.pollTimer)
1162
+ clearInterval(this.handover.pollTimer);
1163
+ this.handover.deadlineTimer = null;
1164
+ this.handover.pollTimer = null;
1165
+ }
1166
+ endHandover() {
1167
+ this.stopHandoverTimers();
1168
+ this.handover = null;
1169
+ }
1170
+ /**
1171
+ * The server's "this row was rewritten" notice (`lateDeliveryRepair`
1172
+ * `notifyViewersMessageRewritten`), delivered to every socket of the user
1173
+ * through the `user:<id>` room this socket joins on connect. A hint to read,
1174
+ * never a verdict.
1175
+ */
1176
+ onConversationUpdate(data) {
1177
+ if (!this.handover || !data || data.messagesRepaired !== true)
1178
+ return;
1179
+ const opts = this.options.awaitHandover;
1180
+ const messageId = typeof data.messageId === 'string' ? data.messageId : undefined;
1181
+ const conversationId = typeof data.conversationId === 'string' ? data.conversationId : undefined;
1182
+ // Narrowest identity both sides know. When neither is known, read anyway:
1183
+ // a spare read is cheap and the record decides.
1184
+ let ours = true;
1185
+ if (messageId && opts?.responseMessageId)
1186
+ ours = messageId === opts.responseMessageId;
1187
+ else if (conversationId && opts?.conversationId)
1188
+ ours = conversationId === opts.conversationId;
1189
+ if (ours)
1190
+ this.requestHandoverRead();
1191
+ }
926
1192
  debug(line) {
927
1193
  try {
928
1194
  (this.options.debug ?? defaultStreamDebug)(`session ${this.options.sessionId}: ${line}`);
@@ -1164,6 +1430,12 @@ class AgenticStreamClient {
1164
1430
  // seqs and would read as a gap that never happened.
1165
1431
  this.noteSeqGap(fresh);
1166
1432
  this.noteDaemonToolEnvelopes(fresh);
1433
+ // The machine's own turn-end is what the server repairs or settles the
1434
+ // row on; seeing it relayed is the moment the record is about to move.
1435
+ if (this.handover &&
1436
+ fresh.some((env) => env?.ev?.t === 'turn-end')) {
1437
+ this.requestHandoverRead();
1438
+ }
1167
1439
  const envelopeText = envelopesText(scoped);
1168
1440
  if (envelopeText) {
1169
1441
  this.accumulated += envelopeText;
@@ -1198,6 +1470,9 @@ class AgenticStreamClient {
1198
1470
  socket.emit('session:join', this.sessionJoinPayload());
1199
1471
  this.emit({ type: 'transport.connected', sessionId: this.options.sessionId });
1200
1472
  void this.catchUpEnvelopes();
1473
+ // A repair notice sent while the transport was down is gone for good.
1474
+ if (this.handover)
1475
+ this.requestHandoverRead();
1201
1476
  });
1202
1477
  socket.on('disconnect', (reason) => {
1203
1478
  this.transportDropped = true;
@@ -1294,22 +1569,42 @@ class AgenticStreamClient {
1294
1569
  if (this.firstTimeSeen('text.completed', key))
1295
1570
  this.emit({ ...event, type: 'text.completed' });
1296
1571
  });
1572
+ // While a handed-over turn is waited on, a terminal frame — a repeat of the
1573
+ // hand-over failure through another room, or anything a later writer sends
1574
+ // — is a reason to read the record, not a verdict of its own (OSK-13543).
1297
1575
  socket.on('execution:complete', (event) => {
1298
- if (match(event))
1576
+ if (!match(event))
1577
+ return;
1578
+ if (this.handover)
1579
+ this.requestHandoverRead();
1580
+ else
1299
1581
  this.finish('completed', event);
1300
1582
  });
1301
1583
  socket.on('execution:aborted', (event) => {
1302
- if (match(event))
1584
+ if (!match(event))
1585
+ return;
1586
+ if (this.handover)
1587
+ this.requestHandoverRead();
1588
+ else
1303
1589
  this.finish('aborted', event);
1304
1590
  });
1305
1591
  socket.on('execution:cancelled', (event) => {
1306
- if (match(event))
1592
+ if (!match(event))
1593
+ return;
1594
+ if (this.handover)
1595
+ this.requestHandoverRead();
1596
+ else
1307
1597
  this.finish('aborted', event);
1308
1598
  });
1309
1599
  socket.on('execution:error', (event) => {
1310
- if (match(event))
1600
+ if (!match(event))
1601
+ return;
1602
+ if (this.handover)
1603
+ this.requestHandoverRead();
1604
+ else if (!(isDaemonHandoverFailure(event) && this.beginHandoverWait(event)))
1311
1605
  this.finish('failed', event);
1312
1606
  });
1607
+ socket.on('conversation:update', (data) => this.onConversationUpdate(data));
1313
1608
  socket.on('permission:request', (event) => {
1314
1609
  if (!match(event))
1315
1610
  return;
@@ -1598,6 +1893,11 @@ class AgenticStreamClient {
1598
1893
  // Any event is proof the stream is alive; the watchdog only fires on real
1599
1894
  // silence.
1600
1895
  this.armIdleProbe();
1896
+ // A handed-over turn can hold the terminal for minutes, so a person is told
1897
+ // why — in every human mode. `--no-stream` reserves stdout for the final
1898
+ // result, so there the line goes to stderr (OSK-13543).
1899
+ if (!this.options.jsonl && this.renderHandoverStatus(event))
1900
+ return;
1601
1901
  // Accumulation and `onEvent` still run — only the incremental rendering is
1602
1902
  // suppressed, so a quiet run is the same turn with a different display.
1603
1903
  if (this.options.quiet)
@@ -1695,6 +1995,40 @@ class AgenticStreamClient {
1695
1995
  this.renderedText = this.accumulated;
1696
1996
  }
1697
1997
  }
1998
+ /**
1999
+ * Human-mode lines for the hand-over wait. Returns true when the event is
2000
+ * fully rendered here; false lets the ordinary rendering continue.
2001
+ */
2002
+ renderHandoverStatus(event) {
2003
+ const handover = event.handover;
2004
+ const isStatus = event.type === 'run.handed_over' ||
2005
+ (event.type === 'run.completed' && event.repaired === true) ||
2006
+ (event.type === 'run.failed' && !!handover);
2007
+ if (!isStatus)
2008
+ return false;
2009
+ const output = this.options.quiet
2010
+ ? (this.options.awaitHandover?.statusOutput ?? process.stderr)
2011
+ : (this.options.output ?? process.stdout);
2012
+ if (event.type === 'run.handed_over') {
2013
+ output.write('\n[machine] A platform update restarted the server relaying this turn, but your ' +
2014
+ 'machine is still working on it. Waiting up to ' +
2015
+ `${formatWaitDuration(Number(event.waitMs) || 0)} for its reply ` +
2016
+ '(Ctrl-C stops waiting; it does not stop your machine).\n');
2017
+ this.renderedText = this.accumulated;
2018
+ return true;
2019
+ }
2020
+ if (event.type === 'run.completed') {
2021
+ output.write('[machine] Your machine finished the turn, and its full reply was recovered.\n');
2022
+ return true;
2023
+ }
2024
+ if (handover?.outcome === 'expired') {
2025
+ output.write(`\n[machine] No reply from your machine within ${formatWaitDuration(Number(this.options.awaitHandover?.maxWaitMs) || 0)}. If it finishes later, the reply is saved to the conversation.\n`);
2026
+ }
2027
+ else {
2028
+ output.write('\n[machine] Your machine ended the turn without finishing it.\n');
2029
+ }
2030
+ return this.options.quiet === true;
2031
+ }
1698
2032
  finish(status, event) {
1699
2033
  if (this.settled || this.finishingReplay)
1700
2034
  return;
@@ -1823,6 +2157,7 @@ class AgenticStreamClient {
1823
2157
  responseMessageId: this.responseMessageId,
1824
2158
  text: this.accumulated,
1825
2159
  status,
2160
+ ...(status === 'completed' && event.repaired === true ? { repaired: true } : {}),
1826
2161
  lastEvent: event,
1827
2162
  };
1828
2163
  this.resolve(this.terminalResult);
@@ -136,6 +136,10 @@ export declare function writeToBackend(bundle: AuthBundle, options?: WriteOption
136
136
  * auth-failure cleanup). Idempotent.
137
137
  */
138
138
  export declare function clearAllBackends(): void;
139
+ export declare function takeResolvedKeychainRead(credential: {
140
+ token: string | null;
141
+ source: string;
142
+ }, expectedServerOrigin: string): ReadResult | null;
139
143
  /**
140
144
  * The low-level backends, exposed for the credential resolver's
141
145
  * `readKeychainToken` / `readFileToken` slots. Internal callers should