@skrr-ai/cli 0.1.86 → 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.
- package/bin/run.js +11 -0
- package/dist/base-command.js +1 -0
- package/dist/commands/agents/chat.d.ts +58 -1
- package/dist/commands/agents/chat.js +180 -4
- package/dist/lib/agent-home-workspace.d.ts +19 -0
- package/dist/lib/agent-home-workspace.js +27 -0
- package/dist/lib/agentic-stream.d.ts +135 -0
- package/dist/lib/agentic-stream.js +341 -6
- package/dist/lib/auth-storage.d.ts +4 -0
- package/dist/lib/auth-storage.js +33 -0
- package/dist/lib/keychain.d.ts +1 -0
- package/dist/lib/keychain.js +67 -14
- package/dist/lib/refresh.d.ts +7 -0
- package/dist/lib/refresh.js +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/oclif.manifest.json +28966 -28958
- package/package.json +1 -1
|
@@ -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
|
package/dist/lib/auth-storage.js
CHANGED
|
@@ -41,6 +41,7 @@ exports.readFromBackend = readFromBackend;
|
|
|
41
41
|
exports.describeForeignCredential = describeForeignCredential;
|
|
42
42
|
exports.writeToBackend = writeToBackend;
|
|
43
43
|
exports.clearAllBackends = clearAllBackends;
|
|
44
|
+
exports.takeResolvedKeychainRead = takeResolvedKeychainRead;
|
|
44
45
|
exports.normalizeServerOrigin = normalizeServerOrigin;
|
|
45
46
|
exports.__authFilePathForTest = __authFilePathForTest;
|
|
46
47
|
/**
|
|
@@ -504,6 +505,11 @@ function unlinkQuietly(p) {
|
|
|
504
505
|
function readFromKeychain() {
|
|
505
506
|
if (!keychain.isAvailable())
|
|
506
507
|
return null;
|
|
508
|
+
// One pass: the owner record is read once for the whole bundle, not once per
|
|
509
|
+
// secret plus once for itself (see `withKeychainReadPass`).
|
|
510
|
+
return keychain.withKeychainReadPass(readFromKeychainInPass);
|
|
511
|
+
}
|
|
512
|
+
function readFromKeychainInPass() {
|
|
507
513
|
const tokenEntry = keychain.readToken();
|
|
508
514
|
if (!tokenEntry) {
|
|
509
515
|
// Read came back empty despite "available" — don't cache a false
|
|
@@ -769,6 +775,7 @@ function describeForeignCredential(result) {
|
|
|
769
775
|
function writeToBackend(bundle, options = {}) {
|
|
770
776
|
if ((0, harness_context_1.isHarnessCliContext)())
|
|
771
777
|
throw new harness_context_1.HarnessCredentialPersistRefused();
|
|
778
|
+
lastKeychainTokenRead = null;
|
|
772
779
|
const requestedOrigin = options.serverOrigin ?? bundle.serverOrigin;
|
|
773
780
|
let persistedBundle = bundle;
|
|
774
781
|
if (requestedOrigin !== undefined) {
|
|
@@ -821,9 +828,34 @@ function writeToBackend(bundle, options = {}) {
|
|
|
821
828
|
* auth-failure cleanup). Idempotent.
|
|
822
829
|
*/
|
|
823
830
|
function clearAllBackends() {
|
|
831
|
+
lastKeychainTokenRead = null;
|
|
824
832
|
clearKeychainBackend();
|
|
825
833
|
deleteFileBackend();
|
|
826
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* The Keychain read the credential resolver just made, for the ONE caller that
|
|
837
|
+
* reads the same bundle again a moment later: `BaseCommand.init()` resolves the
|
|
838
|
+
* credential and then asks `ensureFreshCliCredential` whether it needs a
|
|
839
|
+
* refresh, which re-read every slot (another six `security` spawns, ~100ms) to
|
|
840
|
+
* learn the expiry the first read already had.
|
|
841
|
+
*
|
|
842
|
+
* Handed out at most once, only for the credential that read produced, only
|
|
843
|
+
* for the same server origin, and dropped by any write or clear in this
|
|
844
|
+
* process. It never stands in for the re-read under the auth lock: a refresh
|
|
845
|
+
* still reads the store fresh before it spends a refresh token.
|
|
846
|
+
*/
|
|
847
|
+
let lastKeychainTokenRead = null;
|
|
848
|
+
function takeResolvedKeychainRead(credential, expectedServerOrigin) {
|
|
849
|
+
const read = lastKeychainTokenRead;
|
|
850
|
+
lastKeychainTokenRead = null;
|
|
851
|
+
if (!read?.bundle || credential.source !== 'keychain')
|
|
852
|
+
return null;
|
|
853
|
+
if (!credential.token || read.bundle.token !== credential.token)
|
|
854
|
+
return null;
|
|
855
|
+
if (read.expectedServerOrigin !== normalizeServerOrigin(expectedServerOrigin))
|
|
856
|
+
return null;
|
|
857
|
+
return read;
|
|
858
|
+
}
|
|
827
859
|
/**
|
|
828
860
|
* The low-level backends, exposed for the credential resolver's
|
|
829
861
|
* `readKeychainToken` / `readFileToken` slots. Internal callers should
|
|
@@ -847,6 +879,7 @@ exports.backends = {
|
|
|
847
879
|
readKeychainToken(expectedServerOrigin) {
|
|
848
880
|
const read = readKeychainAssessed();
|
|
849
881
|
const result = assessBundleOrigin(read.bundle, 'keychain', expectedServerOrigin);
|
|
882
|
+
lastKeychainTokenRead = result.bundle && result.originStatus === 'matched' ? result : null;
|
|
850
883
|
return result.bundle?.token ?? null;
|
|
851
884
|
},
|
|
852
885
|
readFileToken(expectedServerOrigin) {
|
package/dist/lib/keychain.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export declare function isAvailable(): boolean;
|
|
|
38
38
|
* masquerade as "keychain always available" across the process lifetime.
|
|
39
39
|
*/
|
|
40
40
|
export declare function resetAvailability(): void;
|
|
41
|
+
export declare function withKeychainReadPass<T>(fn: () => T): T;
|
|
41
42
|
export interface KeychainEntry {
|
|
42
43
|
token: string;
|
|
43
44
|
expiresAt?: number;
|
package/dist/lib/keychain.js
CHANGED
|
@@ -43,6 +43,7 @@ exports.isLegacySlotCurrent = isLegacySlotCurrent;
|
|
|
43
43
|
exports.__currentServiceForTest = __currentServiceForTest;
|
|
44
44
|
exports.isAvailable = isAvailable;
|
|
45
45
|
exports.resetAvailability = resetAvailability;
|
|
46
|
+
exports.withKeychainReadPass = withKeychainReadPass;
|
|
46
47
|
exports.readToken = readToken;
|
|
47
48
|
exports.writeToken = writeToken;
|
|
48
49
|
exports.readRefreshToken = readRefreshToken;
|
|
@@ -256,17 +257,56 @@ function isSecretAccount(account) {
|
|
|
256
257
|
return account === ACCOUNT_TOKEN || account === ACCOUNT_REFRESH;
|
|
257
258
|
}
|
|
258
259
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
260
|
+
* One synchronous read of a bundle, memoised per (service, account).
|
|
261
|
+
*
|
|
262
|
+
* Every `security` call is a process spawn costing ~15-20ms, and a bundle read
|
|
263
|
+
* used to make eight of them for six items: each secret's envelope decrypt
|
|
264
|
+
* re-read the owner record (`foreignOwnerRootAt`), and the bundle read it a
|
|
265
|
+
* third time for itself. Measured on `skrr agents show`, that was 17 serial
|
|
266
|
+
* spawns and ~280ms before the first request could leave.
|
|
267
|
+
*
|
|
268
|
+
* A pass is SYNCHRONOUS by construction (`execFileSync` throughout, and the
|
|
269
|
+
* callback is not awaited), so nothing else in this process can interleave a
|
|
270
|
+
* write; a write made inside the pass drops the memo for that item. Another
|
|
271
|
+
* process's write is no more or less visible than before — the eight separate
|
|
272
|
+
* reads were never one atomic snapshot either. Outside a pass nothing is
|
|
273
|
+
* memoised: a later read always reaches the Keychain, which is what the
|
|
274
|
+
* refresh path's re-read under the auth lock depends on.
|
|
261
275
|
*/
|
|
262
|
-
|
|
263
|
-
|
|
276
|
+
let readPassMemo = null;
|
|
277
|
+
function withKeychainReadPass(fn) {
|
|
278
|
+
if (readPassMemo)
|
|
279
|
+
return fn();
|
|
280
|
+
readPassMemo = new Map();
|
|
281
|
+
try {
|
|
282
|
+
return fn();
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
readPassMemo = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function memoKey(service, account) {
|
|
289
|
+
return `${service}\u0000${account}`;
|
|
290
|
+
}
|
|
291
|
+
function forgetSlotInPass(account, service) {
|
|
292
|
+
readPassMemo?.delete(memoKey(service, account));
|
|
293
|
+
}
|
|
294
|
+
/** The stored string exactly as `security` returns it, or null. */
|
|
295
|
+
function readRawSlot(account, service) {
|
|
296
|
+
const key = memoKey(service, account);
|
|
297
|
+
if (readPassMemo?.has(key))
|
|
298
|
+
return readPassMemo.get(key) ?? null;
|
|
299
|
+
const value = execReadSlot(account, service);
|
|
300
|
+
readPassMemo?.set(key, value);
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
function execReadSlot(account, service) {
|
|
264
304
|
try {
|
|
265
305
|
const out = (0, node_child_process_1.execFileSync)('security', ['find-generic-password', '-s', service, '-a', account, '-w'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
266
306
|
const trimmed = out.replace(/\n$/, '');
|
|
267
307
|
if (trimmed.length === 0)
|
|
268
308
|
return null;
|
|
269
|
-
|
|
309
|
+
return trimmed;
|
|
270
310
|
}
|
|
271
311
|
catch (err) {
|
|
272
312
|
const status = err.status;
|
|
@@ -280,6 +320,15 @@ function readSlot(account, service = currentService(), raw_ = false) {
|
|
|
280
320
|
}
|
|
281
321
|
return null;
|
|
282
322
|
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* `raw` returns the stored string exactly, with no envelope transform — for
|
|
326
|
+
* copying another config's item, which may be sealed under a key we lack.
|
|
327
|
+
*/
|
|
328
|
+
function readSlot(account, service = currentService(), raw_ = false) {
|
|
329
|
+
const raw = readRawSlot(account, service);
|
|
330
|
+
if (raw === null)
|
|
331
|
+
return null;
|
|
283
332
|
if (raw_ || !isSecretAccount(account))
|
|
284
333
|
return raw;
|
|
285
334
|
// L12 — route secret slots through the envelope.
|
|
@@ -339,6 +388,7 @@ function quoteForSecurityInteractive(v) {
|
|
|
339
388
|
return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
340
389
|
}
|
|
341
390
|
function writeSlot(account, value, service = currentService(), raw = false) {
|
|
391
|
+
forgetSlotInPass(account, service);
|
|
342
392
|
try {
|
|
343
393
|
// L12 — wrap secret-bearing slots through the envelope when active.
|
|
344
394
|
// Empty / non-secret slots are passthrough, and so is a raw copy.
|
|
@@ -364,6 +414,7 @@ function writeSlot(account, value, service = currentService(), raw = false) {
|
|
|
364
414
|
}
|
|
365
415
|
}
|
|
366
416
|
function deleteSlot(account, service = currentService()) {
|
|
417
|
+
forgetSlotInPass(account, service);
|
|
367
418
|
try {
|
|
368
419
|
(0, node_child_process_1.execFileSync)('security', ['delete-generic-password', '-s', service, '-a', account], {
|
|
369
420
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
@@ -484,15 +535,17 @@ function readBundleAt(slot) {
|
|
|
484
535
|
if (!isAvailable())
|
|
485
536
|
return null;
|
|
486
537
|
const service = serviceForSlot(slot);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
538
|
+
return withKeychainReadPass(() => {
|
|
539
|
+
const token = readEntryAt(service, ACCOUNT_TOKEN, ACCOUNT_EXPIRES);
|
|
540
|
+
if (!token)
|
|
541
|
+
return null;
|
|
542
|
+
return {
|
|
543
|
+
token,
|
|
544
|
+
refresh: readEntryAt(service, ACCOUNT_REFRESH, ACCOUNT_REFRESH_EXPIRES),
|
|
545
|
+
serverOrigin: readSlot(ACCOUNT_SERVER_ORIGIN, service) || null,
|
|
546
|
+
owner: readSlot(ACCOUNT_OWNER, service) || null,
|
|
547
|
+
};
|
|
548
|
+
});
|
|
496
549
|
}
|
|
497
550
|
const BUNDLE_ACCOUNTS = [
|
|
498
551
|
ACCOUNT_TOKEN,
|