@skrr-ai/cli 0.1.86 → 0.1.88

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.
Files changed (103) hide show
  1. package/bin/run.js +11 -0
  2. package/dist/base-command.js +1 -0
  3. package/dist/commands/agents/chat.d.ts +58 -1
  4. package/dist/commands/agents/chat.js +206 -4
  5. package/dist/commands/computer/index.d.ts +19 -0
  6. package/dist/commands/computer/index.js +37 -0
  7. package/dist/commands/labels/create.d.ts +1 -0
  8. package/dist/commands/labels/create.js +2 -0
  9. package/dist/commands/labels/restore.d.ts +1 -0
  10. package/dist/commands/labels/restore.js +6 -3
  11. package/dist/commands/labels/update.d.ts +1 -0
  12. package/dist/commands/labels/update.js +2 -0
  13. package/dist/commands/machines/computer-adoption.d.ts +15 -0
  14. package/dist/commands/machines/computer-adoption.js +33 -0
  15. package/dist/commands/machines/dedicated/archive.d.ts +26 -0
  16. package/dist/commands/machines/dedicated/archive.js +113 -0
  17. package/dist/commands/machines/dedicated/audit.d.ts +25 -0
  18. package/dist/commands/machines/dedicated/audit.js +113 -0
  19. package/dist/commands/machines/dedicated/computer.d.ts +23 -0
  20. package/dist/commands/machines/dedicated/computer.js +64 -0
  21. package/dist/commands/machines/dedicated/cp.d.ts +2 -0
  22. package/dist/commands/machines/dedicated/cp.js +74 -2
  23. package/dist/commands/machines/dedicated/git-status.d.ts +15 -0
  24. package/dist/commands/machines/dedicated/git-status.js +56 -0
  25. package/dist/commands/machines/dedicated/index.js +15 -1
  26. package/dist/commands/machines/dedicated/ls.d.ts +27 -0
  27. package/dist/commands/machines/dedicated/ls.js +100 -0
  28. package/dist/commands/machines/dedicated/mkdir.d.ts +18 -0
  29. package/dist/commands/machines/dedicated/mkdir.js +57 -0
  30. package/dist/commands/machines/dedicated/mv.d.ts +24 -0
  31. package/dist/commands/machines/dedicated/mv.js +83 -0
  32. package/dist/commands/machines/dedicated/rm.d.ts +26 -0
  33. package/dist/commands/machines/dedicated/rm.js +100 -0
  34. package/dist/commands/machines/dedicated/search.d.ts +24 -0
  35. package/dist/commands/machines/dedicated/search.js +78 -0
  36. package/dist/commands/machines/dedicated/stat.d.ts +22 -0
  37. package/dist/commands/machines/dedicated/stat.js +92 -0
  38. package/dist/commands/machines/hosted/index.js +1 -1
  39. package/dist/commands/machines/hosted/list.js +1 -1
  40. package/dist/commands/machines/hosted/start.js +1 -1
  41. package/dist/commands/machines/services/declare.d.ts +24 -0
  42. package/dist/commands/machines/services/declare.js +73 -0
  43. package/dist/commands/machines/services/index.d.ts +15 -0
  44. package/dist/commands/machines/services/index.js +31 -0
  45. package/dist/commands/machines/services/ls.d.ts +17 -0
  46. package/dist/commands/machines/services/ls.js +60 -0
  47. package/dist/commands/machines/services/withdraw.d.ts +19 -0
  48. package/dist/commands/machines/services/withdraw.js +47 -0
  49. package/dist/commands/machines/share.d.ts +29 -0
  50. package/dist/commands/machines/share.js +100 -0
  51. package/dist/commands/machines/shared.d.ts +19 -0
  52. package/dist/commands/machines/shared.js +62 -0
  53. package/dist/commands/machines/shares.d.ts +16 -0
  54. package/dist/commands/machines/shares.js +69 -0
  55. package/dist/commands/machines/unshare.d.ts +19 -0
  56. package/dist/commands/machines/unshare.js +61 -0
  57. package/dist/commands/tasks/labels/create.d.ts +1 -0
  58. package/dist/commands/tasks/labels/create.js +3 -0
  59. package/dist/commands/views/create.d.ts +1 -0
  60. package/dist/commands/views/create.js +5 -0
  61. package/dist/lib/agent-home-workspace.d.ts +26 -0
  62. package/dist/lib/agent-home-workspace.js +174 -0
  63. package/dist/lib/agentic-stream.d.ts +135 -0
  64. package/dist/lib/agentic-stream.js +341 -6
  65. package/dist/lib/auth-storage.d.ts +4 -0
  66. package/dist/lib/auth-storage.js +33 -0
  67. package/dist/lib/computer-adoption.d.ts +55 -0
  68. package/dist/lib/computer-adoption.js +67 -0
  69. package/dist/lib/computer-consent.d.ts +13 -0
  70. package/dist/lib/computer-consent.js +33 -0
  71. package/dist/lib/computer-files.d.ts +239 -0
  72. package/dist/lib/computer-files.js +707 -0
  73. package/dist/lib/dedicated-copy.d.ts +45 -9
  74. package/dist/lib/dedicated-copy.js +141 -44
  75. package/dist/lib/dedicated-machines.d.ts +49 -0
  76. package/dist/lib/dedicated-machines.js +97 -6
  77. package/dist/lib/keychain.d.ts +1 -0
  78. package/dist/lib/keychain.js +67 -14
  79. package/dist/lib/label-scope.d.ts +12 -0
  80. package/dist/lib/label-scope.js +15 -1
  81. package/dist/lib/machine-audit.d.ts +34 -0
  82. package/dist/lib/machine-audit.js +50 -0
  83. package/dist/lib/machine-grants.d.ts +70 -0
  84. package/dist/lib/machine-grants.js +47 -0
  85. package/dist/lib/machine-services.d.ts +64 -0
  86. package/dist/lib/machine-services.js +60 -0
  87. package/dist/lib/refresh.d.ts +7 -0
  88. package/dist/lib/refresh.js +2 -1
  89. package/dist/lib/task-view-render.d.ts +2 -0
  90. package/dist/lib/task-view-render.js +1 -1
  91. package/dist/lib/views/vocabulary.d.ts +1 -1
  92. package/dist/lib/views/vocabulary.js +2 -1
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.d.ts +955 -0
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.js +1070 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +7 -0
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.d.ts +955 -0
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.js +1051 -0
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +7 -0
  99. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  100. package/dist/node_modules/@skrr-ai/data-provider/index.js +22143 -20728
  101. package/dist/node_modules/@skrr-ai/data-provider/package.json +1 -1
  102. package/oclif.manifest.json +18855 -17260
  103. package/package.json +2 -2
@@ -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
@@ -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) {
@@ -0,0 +1,55 @@
1
+ /** One window's counts, for all daemons and for field daemons (not Dedicated Runtime guests). */
2
+ export interface ComputerAdoptionCounts {
3
+ active: number;
4
+ computerOnly: number;
5
+ both: number;
6
+ legacyOnly: number;
7
+ neither: number;
8
+ legacyOnlyShare: number | null;
9
+ announcingOldShare: number | null;
10
+ }
11
+ /** One client build that opened a `browser:liveview:*` view inside the window (OSK-13616). */
12
+ export interface ComputerLiveViewClient {
13
+ platform: string;
14
+ os: string;
15
+ version: string;
16
+ lastSeenAt: string;
17
+ }
18
+ /** The client half of the retirement criterion, from socket client identity. */
19
+ export interface ComputerLiveViewClients {
20
+ readable: boolean;
21
+ windowDays: number;
22
+ /** When the record began; the half is only measurable once it covers a whole window. */
23
+ recordingSince?: string;
24
+ coversWindow?: boolean;
25
+ clients: ComputerLiveViewClient[];
26
+ mobileVersions?: string[];
27
+ /** Clients that sent no identity: possibly an old mobile build. */
28
+ unattributed?: number;
29
+ mobileHalfMet: boolean | null;
30
+ }
31
+ /** `GET /api/machines/computer-adoption` — admin only (OSK-13578, OSK-13616). */
32
+ export interface ComputerAdoption {
33
+ generatedAt: string;
34
+ windows: Record<string, {
35
+ all: ComputerAdoptionCounts;
36
+ field: ComputerAdoptionCounts;
37
+ }>;
38
+ announced: Record<string, number>;
39
+ /** Absent from a server older than OSK-13616. */
40
+ liveViewClients?: ComputerLiveViewClients;
41
+ criterion: {
42
+ maxLegacyOnlyShare: number;
43
+ windowDays: number;
44
+ mobileMoved: string;
45
+ oldFamilies: string[];
46
+ daemonHalfMet: boolean | null;
47
+ mobileHalfMet?: boolean | null;
48
+ };
49
+ }
50
+ /**
51
+ * How many daemons seen recently are still on the old event families rather
52
+ * than the computer protocol — the number the families' retirement waits on.
53
+ */
54
+ export declare function getComputerAdoption(): Promise<ComputerAdoption>;
55
+ export declare function computerAdoptionLines(read: ComputerAdoption): string[];
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getComputerAdoption = getComputerAdoption;
4
+ exports.computerAdoptionLines = computerAdoptionLines;
5
+ const api_fetch_1 = require("./api-fetch");
6
+ /**
7
+ * How many daemons seen recently are still on the old event families rather
8
+ * than the computer protocol — the number the families' retirement waits on.
9
+ */
10
+ async function getComputerAdoption() {
11
+ return (0, api_fetch_1.apiFetch)('/api/machines/computer-adoption');
12
+ }
13
+ function percent(share) {
14
+ return share === null ? 'n/a (no active daemons)' : `${(share * 100).toFixed(1)}%`;
15
+ }
16
+ function countsLines(label, counts) {
17
+ return [
18
+ ` ${label}: ${counts.active} active`,
19
+ ` computer only ${counts.computerOnly} · both ${counts.both} · old families only ${counts.legacyOnly} · neither ${counts.neither}`,
20
+ ` still on the old families only: ${percent(counts.legacyOnlyShare)} announcing an old family: ${percent(counts.announcingOldShare)}`,
21
+ ];
22
+ }
23
+ function mobileHalfLine(read) {
24
+ const clients = read.liveViewClients;
25
+ if (!clients)
26
+ return ` mobile half: ${read.criterion.mobileMoved}`;
27
+ if (!clients.readable)
28
+ return ' mobile half: not measurable: the client record could not be read';
29
+ const versions = clients.mobileVersions ?? [];
30
+ if (clients.mobileHalfMet === false) {
31
+ return ` mobile half (no mobile build opened a live view in ${clients.windowDays} days): NOT met — still on it: ${versions.join(', ')}`;
32
+ }
33
+ if (clients.mobileHalfMet === null && clients.coversWindow === false) {
34
+ return ` mobile half: not measurable yet: the record began ${clients.recordingSince ?? 'recently'} and needs ${clients.windowDays} days`;
35
+ }
36
+ if (clients.mobileHalfMet === null) {
37
+ return ` mobile half: not measurable: ${clients.unattributed ?? 0} client build(s) opened a live view without saying what they are`;
38
+ }
39
+ return ` mobile half (no mobile build opened a live view in ${clients.windowDays} days): MET`;
40
+ }
41
+ function clientLines(read) {
42
+ const clients = read.liveViewClients;
43
+ if (!clients?.readable || clients.clients.length === 0)
44
+ return [];
45
+ return [
46
+ '',
47
+ `Client builds that opened a browser:liveview:* view in the last ${clients.windowDays}d`,
48
+ ...clients.clients.map((client) => ` ${client.platform} ${client.os} ${client.version} last ${client.lastSeenAt}`),
49
+ ];
50
+ }
51
+ function computerAdoptionLines(read) {
52
+ const lines = [`Computer protocol adoption (${read.generatedAt})`];
53
+ for (const [window, scopes] of Object.entries(read.windows)) {
54
+ lines.push('', `Seen in the last ${window}`);
55
+ lines.push(...countsLines('all daemons', scopes.all));
56
+ lines.push(...countsLines('field daemons (no guests)', scopes.field));
57
+ }
58
+ const { criterion } = read;
59
+ let verdict = 'NOT met';
60
+ if (criterion.daemonHalfMet === null)
61
+ verdict = 'not measurable: no active daemons';
62
+ else if (criterion.daemonHalfMet)
63
+ verdict = 'MET';
64
+ lines.push('', `Retirement of ${criterion.oldFamilies.join(', ')}`, ` daemon half (< ${(criterion.maxLegacyOnlyShare * 100).toFixed(0)}% of daemons active in ${criterion.windowDays} days on the old families only): ${verdict}`, mobileHalfLine(read));
65
+ lines.push(...clientLines(read));
66
+ return lines;
67
+ }
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ /**
3
+ * The bridge for `skrr computer` — the machine-local consent for the
4
+ * computer's new surfaces on a personal machine (design §18, D11;
5
+ * OSK-13532): the file surface and a person's own browser windows.
6
+ *
7
+ * The consent is a decision made ON the machine, so it is implemented by the
8
+ * local daemon (`skrrd computer allow|deny|status`) writing daemon.json —
9
+ * never by a REST route, because nothing sent from the cloud may grant it.
10
+ * Keeping the CLI a thin forwarder preserves that boundary: `skrr` carries
11
+ * the verb to the same binary `skrrd` runs it on.
12
+ */
13
+ export declare function forwardComputerConsent(command: Command, passthrough: readonly string[]): Promise<void>;