@skrr-ai/cli 0.1.22 → 0.1.23

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 (243) hide show
  1. package/README.md +83 -73
  2. package/bin/dev-fallback.js +221 -0
  3. package/bin/run.js +43 -0
  4. package/dist/base-command.d.ts +15 -0
  5. package/dist/base-command.js +71 -10
  6. package/dist/commands/agents/attach-harness.d.ts +37 -0
  7. package/dist/commands/agents/attach-harness.js +102 -3
  8. package/dist/commands/agents/chat.d.ts +69 -1
  9. package/dist/commands/agents/chat.js +202 -22
  10. package/dist/commands/agents/create.d.ts +38 -0
  11. package/dist/commands/agents/create.js +95 -2
  12. package/dist/commands/agents/schedule-trace.js +11 -0
  13. package/dist/commands/agents/show.js +29 -1
  14. package/dist/commands/api-keys/create.d.ts +24 -0
  15. package/dist/commands/api-keys/create.js +80 -0
  16. package/dist/commands/api-keys/list.d.ts +11 -0
  17. package/dist/commands/api-keys/list.js +42 -0
  18. package/dist/commands/api-keys/revoke.d.ts +14 -0
  19. package/dist/commands/api-keys/revoke.js +33 -0
  20. package/dist/commands/api-keys/rotate.d.ts +19 -0
  21. package/dist/commands/api-keys/rotate.js +42 -0
  22. package/dist/commands/balance/index.d.ts +18 -0
  23. package/dist/commands/balance/index.js +29 -0
  24. package/dist/commands/balance/show.d.ts +25 -0
  25. package/dist/commands/balance/show.js +60 -0
  26. package/dist/commands/balance/statement.d.ts +19 -0
  27. package/dist/commands/balance/statement.js +52 -0
  28. package/dist/commands/browser/doctor.d.ts +11 -0
  29. package/dist/commands/browser/doctor.js +11 -0
  30. package/dist/commands/code/jobs/approve.d.ts +25 -0
  31. package/dist/commands/code/jobs/approve.js +52 -0
  32. package/dist/commands/code/jobs/cancel.d.ts +12 -0
  33. package/dist/commands/code/jobs/cancel.js +39 -0
  34. package/dist/commands/code/jobs/index.d.ts +19 -0
  35. package/dist/commands/code/jobs/index.js +38 -0
  36. package/dist/commands/code/jobs/list.d.ts +12 -0
  37. package/dist/commands/code/jobs/list.js +87 -0
  38. package/dist/commands/code/jobs/run.d.ts +32 -0
  39. package/dist/commands/code/jobs/run.js +151 -0
  40. package/dist/commands/code/jobs/show.d.ts +12 -0
  41. package/dist/commands/code/jobs/show.js +86 -0
  42. package/dist/commands/commitments/preflight.js +18 -4
  43. package/dist/commands/commitments/receipt-metrics.d.ts +39 -0
  44. package/dist/commands/commitments/receipt-metrics.js +78 -0
  45. package/dist/commands/convos/search.js +5 -5
  46. package/dist/commands/harnesses/leases/list.d.ts +3 -0
  47. package/dist/commands/harnesses/leases/list.js +59 -12
  48. package/dist/commands/harnesses/leases/show.js +36 -0
  49. package/dist/commands/harnesses/list.d.ts +18 -0
  50. package/dist/commands/harnesses/list.js +30 -0
  51. package/dist/commands/harnesses/products.js +48 -3
  52. package/dist/commands/harnesses/quota.d.ts +16 -0
  53. package/dist/commands/harnesses/quota.js +26 -1
  54. package/dist/commands/harnesses/usage.js +17 -0
  55. package/dist/commands/inbox/index.d.ts +2 -1
  56. package/dist/commands/inbox/index.js +27 -4
  57. package/dist/commands/login.js +4 -1
  58. package/dist/commands/machines/list.d.ts +1 -0
  59. package/dist/commands/machines/list.js +33 -5
  60. package/dist/commands/memory/approvals/issue.d.ts +18 -0
  61. package/dist/commands/memory/approvals/issue.js +63 -0
  62. package/dist/commands/memory/commit.js +5 -2
  63. package/dist/commands/memory/context.d.ts +3 -2
  64. package/dist/commands/memory/context.js +9 -11
  65. package/dist/commands/memory/delete.d.ts +1 -1
  66. package/dist/commands/memory/list.d.ts +1 -1
  67. package/dist/commands/memory/list.js +1 -1
  68. package/dist/commands/memory/mounts/resolve.d.ts +10 -0
  69. package/dist/commands/memory/mounts/resolve.js +27 -0
  70. package/dist/commands/memory/proposals/commit.d.ts +18 -0
  71. package/dist/commands/memory/proposals/commit.js +32 -0
  72. package/dist/commands/memory/proposals/create.d.ts +18 -0
  73. package/dist/commands/memory/proposals/create.js +56 -0
  74. package/dist/commands/memory/proposals/list.d.ts +13 -0
  75. package/dist/commands/memory/proposals/list.js +31 -0
  76. package/dist/commands/memory/proposals/promote.d.ts +22 -0
  77. package/dist/commands/memory/proposals/promote.js +33 -0
  78. package/dist/commands/memory/proposals/reject.d.ts +16 -0
  79. package/dist/commands/memory/proposals/reject.js +28 -0
  80. package/dist/commands/memory/rebuild-index.d.ts +20 -0
  81. package/dist/commands/memory/rebuild-index.js +25 -0
  82. package/dist/commands/memory/save.d.ts +1 -1
  83. package/dist/commands/memory/save.js +1 -1
  84. package/dist/commands/memory/search.d.ts +6 -3
  85. package/dist/commands/memory/search.js +27 -3
  86. package/dist/commands/memory/show.d.ts +1 -1
  87. package/dist/commands/memory/stores/archive.d.ts +14 -0
  88. package/dist/commands/memory/stores/archive.js +31 -0
  89. package/dist/commands/memory/stores/create.d.ts +12 -0
  90. package/dist/commands/memory/stores/create.js +30 -0
  91. package/dist/commands/memory/stores/delete.d.ts +15 -0
  92. package/dist/commands/memory/stores/delete.js +38 -0
  93. package/dist/commands/memory/stores/list.d.ts +10 -0
  94. package/dist/commands/memory/stores/list.js +29 -0
  95. package/dist/commands/memory/stores/show.d.ts +12 -0
  96. package/dist/commands/memory/stores/show.js +14 -0
  97. package/dist/commands/memory/stores/update.d.ts +15 -0
  98. package/dist/commands/memory/stores/update.js +39 -0
  99. package/dist/commands/memory/sync/delete.d.ts +13 -0
  100. package/dist/commands/memory/sync/delete.js +35 -0
  101. package/dist/commands/memory/sync/pull.d.ts +19 -0
  102. package/dist/commands/memory/sync/pull.js +52 -0
  103. package/dist/commands/memory/sync/push.d.ts +18 -0
  104. package/dist/commands/memory/sync/push.js +115 -0
  105. package/dist/commands/memory/sync/resolve.d.ts +23 -0
  106. package/dist/commands/memory/sync/resolve.js +27 -0
  107. package/dist/commands/memory/sync/status.d.ts +9 -0
  108. package/dist/commands/memory/sync/status.js +22 -0
  109. package/dist/commands/memory/topics/archive.d.ts +20 -0
  110. package/dist/commands/memory/topics/archive.js +29 -0
  111. package/dist/commands/memory/topics/create.d.ts +23 -0
  112. package/dist/commands/memory/topics/create.js +46 -0
  113. package/dist/commands/memory/topics/delete.d.ts +22 -0
  114. package/dist/commands/memory/topics/delete.js +30 -0
  115. package/dist/commands/memory/topics/forget.d.ts +22 -0
  116. package/dist/commands/memory/topics/forget.js +29 -0
  117. package/dist/commands/memory/topics/list.d.ts +13 -0
  118. package/dist/commands/memory/topics/list.js +22 -0
  119. package/dist/commands/memory/topics/move.d.ts +22 -0
  120. package/dist/commands/memory/topics/move.js +26 -0
  121. package/dist/commands/memory/topics/show.d.ts +17 -0
  122. package/dist/commands/memory/topics/show.js +21 -0
  123. package/dist/commands/memory/topics/update.d.ts +23 -0
  124. package/dist/commands/memory/topics/update.js +27 -0
  125. package/dist/commands/memory/tree.d.ts +5 -2
  126. package/dist/commands/memory/tree.js +20 -2
  127. package/dist/commands/memory/update.d.ts +1 -1
  128. package/dist/commands/memory/versions/list.d.ts +16 -0
  129. package/dist/commands/memory/versions/list.js +22 -0
  130. package/dist/commands/memory/versions/redact.d.ts +21 -0
  131. package/dist/commands/memory/versions/redact.js +22 -0
  132. package/dist/commands/memory/versions/restore.d.ts +20 -0
  133. package/dist/commands/memory/versions/restore.js +16 -0
  134. package/dist/commands/memory/versions/show.d.ts +15 -0
  135. package/dist/commands/memory/versions/show.js +17 -0
  136. package/dist/commands/messages/list.js +25 -8
  137. package/dist/commands/messages/show.js +10 -0
  138. package/dist/commands/store/install.js +5 -1
  139. package/dist/commands/store/releases.js +1 -1
  140. package/dist/commands/store/update.js +5 -1
  141. package/dist/commands/subscriptions/cancel.js +5 -1
  142. package/dist/commands/subscriptions/health.js +5 -1
  143. package/dist/commands/subscriptions/status.js +5 -1
  144. package/dist/commands/subscriptions/subscribe.js +5 -1
  145. package/dist/commands/tasks/activity.d.ts +2 -3
  146. package/dist/commands/tasks/activity.js +15 -5
  147. package/dist/commands/tasks/complete.d.ts +18 -150
  148. package/dist/commands/tasks/complete.js +161 -600
  149. package/dist/commands/tasks/create.d.ts +5 -0
  150. package/dist/commands/tasks/create.js +107 -4
  151. package/dist/commands/tasks/deliverable/add.d.ts +8 -0
  152. package/dist/commands/tasks/deliverable/add.js +41 -8
  153. package/dist/commands/tasks/deliverable/confirm.d.ts +5 -5
  154. package/dist/commands/tasks/deliverable/confirm.js +9 -18
  155. package/dist/commands/tasks/deliverable/list.d.ts +12 -0
  156. package/dist/commands/tasks/deliverable/list.js +37 -0
  157. package/dist/commands/tasks/events/append.d.ts +2 -0
  158. package/dist/commands/tasks/events/append.js +11 -4
  159. package/dist/commands/tasks/events/list.d.ts +1 -0
  160. package/dist/commands/tasks/events/list.js +1 -0
  161. package/dist/commands/tasks/events/tail.d.ts +1 -0
  162. package/dist/commands/tasks/events/tail.js +1 -0
  163. package/dist/commands/tasks/expectations/assess.d.ts +21 -0
  164. package/dist/commands/tasks/expectations/assess.js +77 -0
  165. package/dist/commands/tasks/expectations.js +14 -18
  166. package/dist/commands/tasks/judge.d.ts +1 -0
  167. package/dist/commands/tasks/judge.js +2 -1
  168. package/dist/commands/tasks/output.d.ts +2 -11
  169. package/dist/commands/tasks/output.js +64 -145
  170. package/dist/commands/tasks/report.d.ts +3 -3
  171. package/dist/commands/tasks/report.js +64 -41
  172. package/dist/commands/tasks/resource/add.d.ts +23 -0
  173. package/dist/commands/tasks/resource/add.js +76 -0
  174. package/dist/commands/tasks/resource/list.d.ts +13 -0
  175. package/dist/commands/tasks/resource/list.js +41 -0
  176. package/dist/commands/tasks/result/show.d.ts +13 -0
  177. package/dist/commands/tasks/result/show.js +46 -0
  178. package/dist/commands/tasks/result/submit.d.ts +26 -0
  179. package/dist/commands/tasks/result/submit.js +134 -0
  180. package/dist/commands/tasks/resume/save.d.ts +16 -0
  181. package/dist/commands/tasks/resume/save.js +59 -0
  182. package/dist/commands/tasks/resume/show.d.ts +13 -0
  183. package/dist/commands/tasks/resume/show.js +35 -0
  184. package/dist/commands/tasks/review.d.ts +17 -0
  185. package/dist/commands/tasks/review.js +54 -0
  186. package/dist/commands/tasks/runs.js +5 -0
  187. package/dist/commands/tasks/timeline.d.ts +2 -2
  188. package/dist/commands/tasks/timeline.js +10 -23
  189. package/dist/commands/tasks/updates/add.d.ts +22 -0
  190. package/dist/commands/tasks/updates/add.js +101 -0
  191. package/dist/commands/tasks/updates/list.d.ts +15 -0
  192. package/dist/commands/tasks/updates/list.js +57 -0
  193. package/dist/commands/tasks/waive.d.ts +1 -0
  194. package/dist/commands/tasks/waive.js +2 -1
  195. package/dist/lib/agentic-stream.d.ts +257 -0
  196. package/dist/lib/agentic-stream.js +490 -33
  197. package/dist/lib/balance.d.ts +8 -0
  198. package/dist/lib/commitment-product.d.ts +10 -1
  199. package/dist/lib/commitment-product.js +66 -2
  200. package/dist/lib/commitments.d.ts +2 -1
  201. package/dist/lib/commitments.js +1 -0
  202. package/dist/lib/conversation-search.d.ts +3 -0
  203. package/dist/lib/conversation-search.js +75 -0
  204. package/dist/lib/execution-target.d.ts +21 -0
  205. package/dist/lib/execution-target.js +22 -6
  206. package/dist/lib/harnesses.d.ts +82 -6
  207. package/dist/lib/harnesses.js +30 -2
  208. package/dist/lib/hosted-machines.js +9 -0
  209. package/dist/lib/inbox.d.ts +2 -1
  210. package/dist/lib/machines.d.ts +17 -0
  211. package/dist/lib/machines.js +29 -0
  212. package/dist/lib/managed-agent-keys.d.ts +104 -0
  213. package/dist/lib/managed-agent-keys.js +50 -0
  214. package/dist/lib/memory-scope.d.ts +4 -3
  215. package/dist/lib/memory-scope.js +9 -9
  216. package/dist/lib/memory-sync.d.ts +114 -0
  217. package/dist/lib/memory-sync.js +730 -0
  218. package/dist/lib/message-provenance.d.ts +42 -0
  219. package/dist/lib/message-provenance.js +89 -0
  220. package/dist/lib/scoped-memory-command.d.ts +68 -0
  221. package/dist/lib/scoped-memory-command.js +183 -0
  222. package/dist/lib/session-task-endpoints.d.ts +1 -1
  223. package/dist/lib/session-task-endpoints.js +7 -0
  224. package/dist/lib/sky-code-broker.d.ts +47 -0
  225. package/dist/lib/sky-code-broker.js +50 -0
  226. package/dist/lib/sky-code-doctor.js +10 -1
  227. package/dist/lib/task-artifact-flags.d.ts +2 -0
  228. package/dist/lib/task-artifact-flags.js +7 -0
  229. package/dist/lib/task-execution.d.ts +4 -5
  230. package/dist/lib/task-execution.js +4 -5
  231. package/dist/lib/task-extras.d.ts +1 -5
  232. package/dist/lib/task-extras.js +1 -5
  233. package/dist/lib/task-transcript.d.ts +5 -7
  234. package/dist/lib/task-transcript.js +8 -16
  235. package/dist/lib/tasks.d.ts +7 -21
  236. package/dist/lib/tasks.js +17 -20
  237. package/dist/lib/triggers.d.ts +39 -1
  238. package/dist/lib/triggers.js +70 -2
  239. package/dist/lib/usage-discovery.d.ts +17 -1
  240. package/dist/lib/usage-discovery.js +17 -1
  241. package/dist/node_modules/@skrr-ai/data-provider/index.js +3733 -3618
  242. package/oclif.manifest.json +16201 -10377
  243. package/package.json +26 -5
@@ -1,15 +1,40 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AgenticStreamClient = void 0;
3
+ exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = void 0;
4
+ exports.writeJsonLine = writeJsonLine;
4
5
  exports.envelopesText = envelopesText;
5
6
  exports.envelopeKey = envelopeKey;
6
7
  exports.unseenEnvelopes = unseenEnvelopes;
8
+ exports.projectDaemonToolEvents = projectDaemonToolEvents;
9
+ exports.renderableDelta = renderableDelta;
10
+ exports.transportRefusal = transportRefusal;
11
+ exports.transportRefusalMessage = transportRefusalMessage;
12
+ exports.attachDeadlineFor = attachDeadlineFor;
13
+ exports.jitteredRetryDelay = jitteredRetryDelay;
14
+ exports.detectSeqGap = detectSeqGap;
15
+ exports.seqGapNotice = seqGapNotice;
7
16
  exports.toWebSocketOrigin = toWebSocketOrigin;
8
17
  const node_readline_1 = require("node:readline");
9
18
  const socket_io_client_1 = require("socket.io-client");
10
19
  const data_provider_1 = require("@skrr-ai/data-provider");
11
20
  const config_1 = require("./config");
12
21
  const publicEndpoints_generated_1 = require("./publicEndpoints.generated");
22
+ /**
23
+ * Write exactly one complete JSON value on one line.
24
+ *
25
+ * `--jsonl` is the mode automation parses, and its whole contract is one JSON
26
+ * value per physical line. Two things break that if the line is composed by
27
+ * hand: a raw newline inside a string (a multi-line agent answer or tool
28
+ * output) splits one record across many unparseable lines, and routing the line
29
+ * through oclif's `this.log` puts it through `util.format`, which rewrites `%s`
30
+ * / `%d` / `%j` sequences that appear in ordinary shell output. `JSON.stringify`
31
+ * escapes the first and this helper avoids the second by writing to the stream
32
+ * directly, so every JSONL line in the CLI is produced in one place and cannot
33
+ * drift (OSK-7629).
34
+ */
35
+ function writeJsonLine(output, value) {
36
+ output.write(`${JSON.stringify(value) ?? 'null'}\n`);
37
+ }
13
38
  /**
14
39
  * Small Node-side adapter for the existing Agentic Socket.IO contract.
15
40
  * It deliberately normalizes only lifecycle/text/tool events; the server's
@@ -86,6 +111,162 @@ function unseenEnvelopes(event, seen) {
86
111
  }
87
112
  return fresh;
88
113
  }
114
+ /**
115
+ * Project daemon `tool-call-start` / `tool-call-end` envelopes into the same
116
+ * `tool.started` / `tool.completed` events a cloud run emits.
117
+ *
118
+ * The CLI used to emit tool events only for cloud-executed turns: a run on a
119
+ * local harness produced three shell commands and ZERO tool events, in both
120
+ * `--jsonl` and the human rendering, while the envelopes carrying them arrived
121
+ * intact inside `session:output` (OSK-7730). Automation counting tool calls
122
+ * concluded a local run used no tools; a human watching one saw nothing at all
123
+ * while commands ran on their own machine (OSK-7773 is the same absence on the
124
+ * web). The harness a turn lands on is a routing decision and must not change
125
+ * the event vocabulary.
126
+ *
127
+ * `tool-call-end` carries no tool name, so the name is correlated on `call`
128
+ * (the tool-use id) through `namesByCall`, which the caller keeps for the life
129
+ * of the run. The pairing is unambiguous within a turn.
130
+ */
131
+ function projectDaemonToolEvents(envelopes, namesByCall) {
132
+ const out = [];
133
+ for (const envelope of envelopes) {
134
+ const ev = envelope?.ev;
135
+ if (!ev || typeof ev.t !== 'string')
136
+ continue;
137
+ const call = typeof ev.call === 'string' ? ev.call : undefined;
138
+ if (ev.t === 'tool-call-start') {
139
+ const name = typeof ev.name === 'string' ? ev.name : undefined;
140
+ if (call && name)
141
+ namesByCall.set(call, name);
142
+ out.push({
143
+ type: 'tool.started',
144
+ ...(call ? { toolUseId: call } : {}),
145
+ ...(name ? { toolName: name } : {}),
146
+ ...(ev.args !== undefined ? { toolInput: ev.args } : {}),
147
+ });
148
+ }
149
+ else if (ev.t === 'tool-call-end') {
150
+ const name = call ? namesByCall.get(call) : undefined;
151
+ out.push({
152
+ type: 'tool.completed',
153
+ ...(call ? { toolUseId: call } : {}),
154
+ ...(name ? { toolName: name } : {}),
155
+ ...(ev.output !== undefined ? { toolResponse: ev.output } : {}),
156
+ ...(typeof ev.status === 'string' ? { status: ev.status } : {}),
157
+ });
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * The part of a cumulative snapshot that has not been shown yet.
164
+ *
165
+ * The stream carries CUMULATIVE text, and the plain renderer used to redraw
166
+ * each snapshot after a bare carriage return. `\r` returns the cursor to column
167
+ * 0 of the CURRENT line only — it cannot rewind past a newline — so the repaint
168
+ * was correct exactly while the answer was one line, and every snapshot after
169
+ * the model's first `\n` stranded the previous snapshot's earlier lines on
170
+ * screen. One two-line answer printed its first paragraph sixteen times
171
+ * (OSK-7731); another printed the answer twice and fused it into tokens the
172
+ * agent never wrote, `HOSTHOST=e2b.local` (OSK-7701).
173
+ *
174
+ * Printing only the new suffix needs no cursor control at all, which also makes
175
+ * it correct when stdout is a file or a pipe — where nothing consumes `\r` and
176
+ * the redirected capture looked worse than the screen.
177
+ *
178
+ * Returns the text to append, and the new "already shown" watermark. A snapshot
179
+ * that is not an extension of what was shown (a stream reset, or a server that
180
+ * rewrote the answer) is emitted whole on a fresh line rather than diffed.
181
+ */
182
+ function renderableDelta(shown, snapshot) {
183
+ if (snapshot === shown)
184
+ return { append: '', shown };
185
+ if (snapshot.startsWith(shown))
186
+ return { append: snapshot.slice(shown.length), shown: snapshot };
187
+ return { append: `\n${snapshot}`, shown: snapshot };
188
+ }
189
+ /** The user-connection cap, as `userConnectionTracking.js` names it. */
190
+ exports.USER_CONNECTION_CAP_CODE = 'user_connection_cap';
191
+ function positiveInt(value) {
192
+ const n = Number(value);
193
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
194
+ }
195
+ function transportRefusal(error) {
196
+ const data = error?.data;
197
+ if (!data || typeof data !== 'object')
198
+ return { retryable: false };
199
+ const code = typeof data.code === 'string' && data.code ? data.code : undefined;
200
+ const connectionClass = typeof data.connectionClass === 'string' && data.connectionClass
201
+ ? data.connectionClass
202
+ : undefined;
203
+ return {
204
+ ...(code ? { code } : {}),
205
+ ...(connectionClass ? { connectionClass } : {}),
206
+ ...(positiveInt(data.limit) !== undefined ? { limit: positiveInt(data.limit) } : {}),
207
+ ...(positiveInt(data.retryAfterMs) !== undefined
208
+ ? { retryAfterMs: positiveInt(data.retryAfterMs) }
209
+ : {}),
210
+ // Only an explicit `true`. An unknown refusal must not be retried forever.
211
+ retryable: data.retryable === true,
212
+ };
213
+ }
214
+ /**
215
+ * The sentence a person can act on, built from whatever the refusal carried.
216
+ *
217
+ * Degrades one clause at a time: an older server that sends no `data` still
218
+ * gets its own message back unchanged, which is exactly today's behaviour and
219
+ * no worse.
220
+ */
221
+ function transportRefusalMessage(message, refusal) {
222
+ const parts = [String(message || 'The stream was refused').trim()];
223
+ if (refusal.limit !== undefined) {
224
+ parts.push(`The limit is ${refusal.limit} concurrent ${refusal.connectionClass ?? 'client'} connection${refusal.limit === 1 ? '' : 's'} per account, shared by every open \`skrr\` session and browser tab you have.`);
225
+ }
226
+ if (refusal.retryAfterMs !== undefined) {
227
+ parts.push(`Retrying in ${Math.round(refusal.retryAfterMs / 1000)}s.`);
228
+ }
229
+ else if (refusal.retryable) {
230
+ parts.push('Retrying.');
231
+ }
232
+ // The fact that matters most and was never said: the run is already accepted.
233
+ parts.push('The run was already accepted and keeps executing either way.');
234
+ return parts.join(' ');
235
+ }
236
+ /**
237
+ * How long a refusal is worth waiting out.
238
+ *
239
+ * The default 30s attach deadline is exactly the server's own
240
+ * `WS_USER_CAP_RETRY_AFTER_MS`, so a client honouring the retry contract on the
241
+ * default budget gives up in the same instant it is allowed to try again. The
242
+ * window is widened to cover the retry plus a connect, and only when the server
243
+ * named a delay.
244
+ */
245
+ function attachDeadlineFor(timeoutMs, refusal) {
246
+ if (!refusal.retryable || refusal.retryAfterMs === undefined)
247
+ return timeoutMs;
248
+ return Math.max(timeoutMs, refusal.retryAfterMs + 15_000);
249
+ }
250
+ /** Jitter, so a fleet refused together does not return together. */
251
+ function jitteredRetryDelay(retryAfterMs, random = Math.random) {
252
+ return Math.round(retryAfterMs * (1 + random() * 0.25));
253
+ }
254
+ function detectSeqGap(highestApplied, incoming) {
255
+ if (!Number.isFinite(incoming) || incoming <= 0)
256
+ return null;
257
+ // The FIRST envelope is a gap when it is not seq 1 — that is the reported
258
+ // case, and a `> highestApplied + 1` test alone would miss it, because
259
+ // nothing has been applied yet.
260
+ if (incoming <= highestApplied + 1)
261
+ return null;
262
+ return { from: highestApplied + 1, to: incoming - 1 };
263
+ }
264
+ function seqGapNotice(gap) {
265
+ const count = gap.to - gap.from + 1;
266
+ return (`[${count} stream chunk${count === 1 ? '' : 's'} (seq ${gap.from}` +
267
+ `${count === 1 ? '' : `-${gap.to}`}) never arrived — the text above is incomplete. ` +
268
+ 'The full answer is in the persisted message and in run.result.]');
269
+ }
89
270
  class AgenticStreamClient {
90
271
  options;
91
272
  socket = null;
@@ -94,13 +275,48 @@ class AgenticStreamClient {
94
275
  settled = false;
95
276
  closed = false;
96
277
  accumulated = '';
278
+ /** Highest envelope `__seq` folded into `accumulated`, for gap detection. */
279
+ highestSeq = 0;
280
+ reportedGap = false;
97
281
  // Envelope keys already applied, so a re-delivered `session:output` frame is
98
282
  // not appended twice (OSK-4834). Cleared on `execution:stream-reset`.
99
283
  seenEnvelopes = new Set();
284
+ /**
285
+ * Lifecycle events already emitted, by their own identity (OSK-7632/7689/7709).
286
+ *
287
+ * `session:output` envelopes are deduped by `envelopeKey`, but the lifecycle
288
+ * events do not ride an envelope and were exempt: one turn delivered
289
+ * `run.started` four times byte-identically (76% of a 70KB stream), and one
290
+ * Bash execution arrived as four `tool.started` events sharing one
291
+ * `toolUseId`. Anything counting tool calls from the stream — a timeline, a
292
+ * usage display, an audit log — reported four sandbox commands where one ran.
293
+ * The server fans one broadcast to several rooms the client is in, so the
294
+ * repeat count varies run to run and cannot be inferred by a consumer.
295
+ */
296
+ seenLifecycle = new Set();
297
+ /** `call` → tool name, so a daemon `tool-call-end` can be named (OSK-7730). */
298
+ toolNamesByCall = new Map();
299
+ /** Text already written to the terminal, for suffix-only rendering. */
300
+ renderedText = '';
301
+ idleTimer = null;
302
+ reconciling = false;
100
303
  responseMessageId;
101
304
  lastEvent;
305
+ /**
306
+ * The terminal result, once there is one.
307
+ *
308
+ * `run()`'s promise is the normal way to read it, but a caller that raced
309
+ * `run()` against its own detach has already discarded that promise by the
310
+ * time {@link probeTerminal} settles it. Keeping the value here is what lets
311
+ * a reconciled detach report the real outcome instead of the detach.
312
+ */
313
+ terminalResult = null;
102
314
  resolve;
103
315
  reject;
316
+ /** The terminal result, or null while the run is still in flight. */
317
+ settledResult() {
318
+ return this.terminalResult;
319
+ }
104
320
  constructor(options) {
105
321
  this.options = options;
106
322
  }
@@ -131,8 +347,71 @@ class AgenticStreamClient {
131
347
  await this.waitForConnect();
132
348
  this.socket.emit('session:join', { sessionId: this.options.sessionId });
133
349
  this.emit({ type: 'session.joined', sessionId: this.options.sessionId });
350
+ this.armIdleProbe();
134
351
  return result;
135
352
  }
353
+ /**
354
+ * (Re)start the silence watchdog that asks the durable record what happened.
355
+ *
356
+ * Armed on join and reset by every event, so it only fires when the stream
357
+ * has actually gone quiet. A probe that finds the turn still running re-arms
358
+ * rather than giving up — a 200-second tool call is silence, not a failure —
359
+ * so the cost of being wrong is one cheap read, while the cost of not asking
360
+ * was an interactive session that never returned.
361
+ */
362
+ armIdleProbe() {
363
+ if (!this.options.reconcileTerminal)
364
+ return;
365
+ if (this.settled || this.closed)
366
+ return;
367
+ if (this.idleTimer)
368
+ clearTimeout(this.idleTimer);
369
+ this.idleTimer = setTimeout(() => void this.probeTerminal('idle'), this.options.idleTerminalProbeMs ?? 20_000);
370
+ this.idleTimer.unref?.();
371
+ }
372
+ /**
373
+ * Settle from the durable record, if it has reached a terminal state.
374
+ *
375
+ * Public so the caller can force one before declaring a detach: a run that
376
+ * finished 13 seconds ago must not be reported as "may still be executing"
377
+ * (OSK-7660).
378
+ */
379
+ async probeTerminal(reason) {
380
+ const reconcile = this.options.reconcileTerminal;
381
+ if (!reconcile || this.settled || this.reconciling)
382
+ return false;
383
+ this.reconciling = true;
384
+ let terminal = null;
385
+ try {
386
+ terminal = await reconcile();
387
+ }
388
+ catch {
389
+ // A failed probe is not evidence about the run. Keep waiting.
390
+ terminal = null;
391
+ }
392
+ finally {
393
+ this.reconciling = false;
394
+ }
395
+ if (this.settled)
396
+ return true;
397
+ if (!terminal) {
398
+ this.armIdleProbe();
399
+ return false;
400
+ }
401
+ // The record is the witness the stream refused to be, so say so: the
402
+ // recorded reason is exactly what "the surface shows nothing at all" cost
403
+ // the reader (OSK-7595 — the CLI reported a transport error while the run's
404
+ // own recorded failure said something else entirely).
405
+ this.finish(terminal.status === 'failed' ? 'failed' : 'completed', {
406
+ type: 'run.reconciled',
407
+ sessionId: this.options.sessionId,
408
+ reconciledFrom: 'durable_record',
409
+ reason,
410
+ ...(terminal.error ? { message: terminal.error, error: terminal.error } : {}),
411
+ ...(typeof terminal.text === 'string' ? { result: terminal.text } : {}),
412
+ });
413
+ return true;
414
+ }
136
415
  cancel(reason = 'Interrupted by user') {
137
416
  if (this.settled || !this.socket)
138
417
  return;
@@ -148,6 +427,10 @@ class AgenticStreamClient {
148
427
  if (this.closed)
149
428
  return;
150
429
  this.closed = true;
430
+ if (this.idleTimer) {
431
+ clearTimeout(this.idleTimer);
432
+ this.idleTimer = null;
433
+ }
151
434
  const socket = this.socket;
152
435
  this.socket = null;
153
436
  // Stop dispatch BEFORE tearing anything down. socket.io can deliver a
@@ -167,6 +450,34 @@ class AgenticStreamClient {
167
450
  this.readline?.close();
168
451
  this.readline = null;
169
452
  }
453
+ /**
454
+ * Report the first hole in the live envelope sequence, once.
455
+ *
456
+ * Once, not per gap: a stream that has already lost its beginning tends to
457
+ * lose more, and one honest sentence is the useful form of that. The terminal
458
+ * result is authoritative and reconciled separately, so this never changes
459
+ * what the run reports — only what the reader is told while it is happening.
460
+ */
461
+ reportSeqGap(envelopes) {
462
+ for (const envelope of envelopes) {
463
+ const seq = Number(envelope?.__seq);
464
+ if (!Number.isFinite(seq) || seq <= 0)
465
+ continue;
466
+ const gap = detectSeqGap(this.highestSeq, seq);
467
+ if (gap && !this.reportedGap) {
468
+ this.reportedGap = true;
469
+ this.emit({
470
+ type: 'stream.gap',
471
+ sessionId: this.options.sessionId,
472
+ fromSeq: gap.from,
473
+ toSeq: gap.to,
474
+ message: seqGapNotice(gap),
475
+ });
476
+ }
477
+ if (seq > this.highestSeq)
478
+ this.highestSeq = seq;
479
+ }
480
+ }
170
481
  installHandlers() {
171
482
  const socket = this.socket;
172
483
  const match = (event) => !event.sessionId || event.sessionId === this.options.sessionId;
@@ -175,19 +486,35 @@ class AgenticStreamClient {
175
486
  this.emit({ type: 'transport.connected', sessionId: this.options.sessionId });
176
487
  });
177
488
  socket.on('disconnect', (reason) => {
178
- if (!this.settled)
179
- this.emit({ type: 'transport.disconnected', sessionId: this.options.sessionId, reason });
489
+ if (this.settled)
490
+ return;
491
+ this.emit({ type: 'transport.disconnected', sessionId: this.options.sessionId, reason });
492
+ // Losing the view is not evidence the run stopped — but it IS the moment
493
+ // to ask the record, because a turn refused at admission publishes its
494
+ // reason there and nowhere else.
495
+ void this.probeTerminal('transport_disconnected');
180
496
  });
181
497
  socket.on('connect_error', (error) => {
182
- if (!this.settled)
183
- this.emit({
184
- type: 'transport.error',
185
- sessionId: this.options.sessionId,
186
- message: error.message,
187
- });
498
+ if (this.settled)
499
+ return;
500
+ // Everything the server said, not just the sentence (OSK-7663).
501
+ const refusal = transportRefusal(error);
502
+ this.emit({
503
+ type: 'transport.error',
504
+ sessionId: this.options.sessionId,
505
+ message: transportRefusalMessage(error.message, refusal),
506
+ ...(refusal.code ? { code: refusal.code } : {}),
507
+ ...(refusal.limit !== undefined ? { limit: refusal.limit } : {}),
508
+ ...(refusal.retryAfterMs !== undefined ? { retryAfterMs: refusal.retryAfterMs } : {}),
509
+ ...(refusal.connectionClass ? { connectionClass: refusal.connectionClass } : {}),
510
+ retryable: refusal.retryable,
511
+ });
512
+ void this.probeTerminal('transport_error');
188
513
  });
189
514
  socket.on('session:init', (event) => {
190
- if (match(event))
515
+ // One run starts once. The server fans this to several rooms the client
516
+ // has joined, and the repeats are byte-identical (OSK-7689/7709).
517
+ if (match(event) && this.firstTimeSeen('run.started', this.options.sessionId))
191
518
  this.emit({ ...event, type: 'run.started' });
192
519
  });
193
520
  socket.on('message:stream', (event) => {
@@ -228,11 +555,20 @@ class AgenticStreamClient {
228
555
  if (fresh.length === 0)
229
556
  return; // a pure re-delivery — drop it whole
230
557
  const scoped = { ...event, envelopes: fresh };
558
+ // Say what never arrived, once, before folding this batch in
559
+ // (OSK-7631). Checked against the FRESH set: a re-delivery carries old
560
+ // seqs and would read as a gap that never happened.
561
+ this.reportSeqGap(fresh);
231
562
  const envelopeText = envelopesText(scoped);
232
563
  if (envelopeText) {
233
564
  this.accumulated += envelopeText;
234
565
  this.emit({ ...scoped, type: 'text.delta', text: this.accumulated });
235
566
  }
567
+ // A daemon-routed turn carries its tool calls HERE and nowhere else.
568
+ // Projecting them gives both execution paths one vocabulary (OSK-7730).
569
+ for (const projected of projectDaemonToolEvents(fresh, this.toolNamesByCall)) {
570
+ this.emitToolEvent({ ...projected, sessionId: this.options.sessionId });
571
+ }
236
572
  this.emit({ ...scoped, type: 'session.output' });
237
573
  return;
238
574
  }
@@ -240,11 +576,11 @@ class AgenticStreamClient {
240
576
  });
241
577
  socket.on('tool:start', (event) => {
242
578
  if (match(event))
243
- this.emit({ ...event, type: 'tool.started' });
579
+ this.emitToolEvent({ ...event, type: 'tool.started' });
244
580
  });
245
581
  socket.on('tool:end', (event) => {
246
582
  if (match(event))
247
- this.emit({ ...event, type: 'tool.completed' });
583
+ this.emitToolEvent({ ...event, type: 'tool.completed' });
248
584
  });
249
585
  socket.on('execution:enqueue', (event) => {
250
586
  if (match(event))
@@ -254,13 +590,23 @@ class AgenticStreamClient {
254
590
  if (!match(event))
255
591
  return;
256
592
  this.accumulated = '';
593
+ this.renderedText = '';
594
+ // Seqs restart with the stream, so a fresh low seq is not a gap.
595
+ this.highestSeq = 0;
596
+ this.reportedGap = false;
257
597
  // A reset restarts the stream, so envelope seqs may repeat; forget the
258
598
  // seen keys or the fresh post-reset text would be deduped away (OSK-4834).
259
599
  this.seenEnvelopes.clear();
600
+ this.seenLifecycle.clear();
260
601
  this.emit({ ...event, type: 'text.reset' });
261
602
  });
262
603
  socket.on('message:stream-end', (event) => {
263
- if (match(event))
604
+ if (!match(event))
605
+ return;
606
+ // Byte-identical repeats, including `endedAt` (OSK-7689). Key on the
607
+ // message the stream ended for, falling back to the session.
608
+ const key = String(event.messageId ?? event.endedAt ?? this.options.sessionId);
609
+ if (this.firstTimeSeen('text.completed', key))
264
610
  this.emit({ ...event, type: 'text.completed' });
265
611
  });
266
612
  socket.on('execution:complete', (event) => {
@@ -349,32 +695,83 @@ class AgenticStreamClient {
349
695
  }
350
696
  return new Promise((resolve) => this.readline.question(prompt, resolve));
351
697
  }
698
+ /**
699
+ * True the first time this (kind, identity) pair is seen; false afterwards.
700
+ * The identity is the event's OWN — a tool-use id, a message id — never a
701
+ * position in the stream, because the repeat count varies run to run.
702
+ */
703
+ firstTimeSeen(kind, identity) {
704
+ if (!identity)
705
+ return true; // no identity to dedupe on — treat as new
706
+ const key = `${kind}|${identity}`;
707
+ if (this.seenLifecycle.has(key))
708
+ return false;
709
+ this.seenLifecycle.add(key);
710
+ return true;
711
+ }
712
+ /**
713
+ * Emit a tool event once per tool invocation.
714
+ *
715
+ * Deduped on `toolUseId`, which both paths carry and which is the invocation's
716
+ * real identity: four byte-identical `tool.started` events for one Bash call
717
+ * all shared it (OSK-7632). An event with no id cannot be deduped and is
718
+ * passed through, preserving prior behaviour.
719
+ */
720
+ emitToolEvent(event) {
721
+ const id = typeof event.toolUseId === 'string' ? event.toolUseId : undefined;
722
+ if (!this.firstTimeSeen(String(event.type), id))
723
+ return;
724
+ this.emit(event);
725
+ }
352
726
  emit(event) {
353
727
  this.lastEvent = event;
354
728
  this.options.onEvent?.(event);
729
+ // Any event is proof the stream is alive; the watchdog only fires on real
730
+ // silence.
731
+ this.armIdleProbe();
355
732
  // Accumulation and `onEvent` still run — only the incremental rendering is
356
733
  // suppressed, so a quiet run is the same turn with a different display.
357
734
  if (this.options.quiet)
358
735
  return;
736
+ const output = this.options.output ?? process.stdout;
359
737
  if (this.options.jsonl) {
360
- (this.options.output ?? process.stdout).write(`${JSON.stringify(event)}\n`);
738
+ writeJsonLine(output, event);
361
739
  return;
362
740
  }
363
- const output = this.options.output ?? process.stdout;
364
741
  if (event.type === 'text.delta' && typeof event.text === 'string') {
365
- output.write(`\r${event.text}`);
742
+ // Print what is NEW. See `renderableDelta` — the old `\r` repaint could
743
+ // not rewind past a newline, so a multi-line answer was reprinted once
744
+ // per snapshot and its blocks fused (OSK-7701, OSK-7731).
745
+ const { append, shown } = renderableDelta(this.renderedText, event.text);
746
+ this.renderedText = shown;
747
+ if (append)
748
+ output.write(append);
366
749
  }
367
750
  else if (event.type === 'tool.started') {
368
751
  output.write(`\n[tool] ${String(event.toolName ?? 'unknown')}\n`);
752
+ // The banner moved the cursor to a fresh line, so nothing of the answer
753
+ // is on screen after it: the next snapshot must be printed from where the
754
+ // text left off, not from column 0 of a line that no longer holds it.
755
+ this.renderedText = this.accumulated;
369
756
  }
370
757
  else if (event.type === 'tool.completed') {
371
758
  output.write(`[tool completed] ${String(event.toolName ?? 'unknown')}\n`);
759
+ this.renderedText = this.accumulated;
760
+ }
761
+ else if (event.type === 'stream.gap') {
762
+ // Said WHILE READING, which is the only moment it helps: a truncated
763
+ // live answer looks like a complete short answer, and by the terminal
764
+ // event the reader has already believed it (OSK-7631).
765
+ output.write(`\n${String(event.message ?? '')}\n`);
766
+ this.renderedText = this.accumulated;
372
767
  }
373
768
  else if (event.type === 'transport.disconnected') {
374
769
  output.write(`\n[reconnecting] ${String(event.reason ?? '')}\n`);
770
+ this.renderedText = this.accumulated;
375
771
  }
376
772
  else if (event.type === 'permission.requested' || event.type === 'question.requested') {
377
773
  output.write(`\n[${event.type}]\n`);
774
+ this.renderedText = this.accumulated;
378
775
  }
379
776
  }
380
777
  finish(status, event) {
@@ -393,26 +790,41 @@ class AgenticStreamClient {
393
790
  // agent's actual final message rather than however much of it arrived in
394
791
  // time.
395
792
  const finalText = typeof event.result === 'string' ? event.result : '';
396
- if (finalText && finalText !== this.accumulated) {
397
- const hadPartial = this.accumulated.length > 0;
793
+ // …EXCEPT when it is a strict suffix of what we accumulated.
794
+ //
795
+ // On the cloud path `result` carries only the LAST text block, not the fold
796
+ // of all of them: an answer that spoke, ran three tools, then spoke again
797
+ // arrived complete in the stream and in the database, and `run.result`
798
+ // dropped the opening block the user had explicitly asked for — reporting
799
+ // `status: completed` (OSK-7703). A single-part answer hides it entirely,
800
+ // because with one block the last block IS the answer. Taking the longer of
801
+ // the two when one contains the other is right for both defects: the
802
+ // truncated-stream case (result longer) and the last-block case
803
+ // (accumulation longer).
804
+ const accumulationIsSuperset = finalText.length > 0 &&
805
+ this.accumulated.length > finalText.length &&
806
+ this.accumulated.endsWith(finalText);
807
+ if (finalText && finalText !== this.accumulated && !accumulationIsSuperset) {
398
808
  this.accumulated = finalText;
399
- // Repaint the line so the terminal ends on the whole answer instead of
400
- // the fragment the last delta happened to leave there.
401
- if (!this.options.quiet && !this.options.jsonl && hadPartial) {
402
- (this.options.output ?? process.stdout).write(`\r${finalText}`);
403
- }
404
809
  }
405
- if (!this.options.quiet && !this.options.jsonl && this.accumulated)
406
- (this.options.output ?? process.stdout).write('\n');
810
+ if (!this.options.quiet && !this.options.jsonl && this.accumulated) {
811
+ const output = this.options.output ?? process.stdout;
812
+ const { append, shown } = renderableDelta(this.renderedText, this.accumulated);
813
+ this.renderedText = shown;
814
+ if (append)
815
+ output.write(append);
816
+ output.write('\n');
817
+ }
407
818
  this.emit({ ...event, type: `run.${status}` });
408
819
  this.close();
409
- this.resolve({
820
+ this.terminalResult = {
410
821
  sessionId: this.options.sessionId,
411
822
  responseMessageId: this.responseMessageId,
412
823
  text: this.accumulated,
413
824
  status,
414
825
  lastEvent: event,
415
- });
826
+ };
827
+ this.resolve(this.terminalResult);
416
828
  }
417
829
  /**
418
830
  * OSK-2944 — a transient connect failure must not end an ACCEPTED run.
@@ -436,21 +848,66 @@ class AgenticStreamClient {
436
848
  return new Promise((resolve, reject) => {
437
849
  const socket = this.socket;
438
850
  let lastError = null;
439
- const timer = setTimeout(() => {
440
- cleanup();
441
- reject(Object.assign(new Error(`Could not attach to the run stream within ${Math.round(timeoutMs / 1000)}s` +
442
- `${lastError ? `: ${lastError.message}` : ''}`), { code: 'STREAM_ATTACH_TIMEOUT', lastError }));
443
- }, timeoutMs);
851
+ let deadline = timeoutMs;
852
+ let retryTimer = null;
853
+ let retried = false;
854
+ let timer = armDeadline(deadline);
855
+ function armDeadline(ms) {
856
+ const handle = setTimeout(() => {
857
+ cleanup();
858
+ reject(Object.assign(new Error(`Could not attach to the run stream within ${Math.round(ms / 1000)}s` +
859
+ `${lastError ? `: ${lastError.message}` : ''}`), { code: 'STREAM_ATTACH_TIMEOUT', lastError }));
860
+ }, ms);
861
+ handle.unref?.();
862
+ return handle;
863
+ }
444
864
  const onConnect = () => {
445
865
  cleanup();
446
866
  resolve();
447
867
  };
448
- // Report and keep waiting — socket.io is already retrying underneath.
449
868
  const onError = (error) => {
450
869
  lastError = error;
870
+ // ── HONOUR THE RETRY CONTRACT (OSK-7583/7588/7604/7663) ─────────────
871
+ //
872
+ // "socket.io is already retrying underneath" is true for a TRANSPORT
873
+ // failure and false for a MIDDLEWARE refusal, which is what the
874
+ // connection cap is: `userConnectionTracking.js` says so in its own
875
+ // words — Socket.IO destroys a socket that fails middleware and does
876
+ // not reconnect it, and every first-party client is expected to
877
+ // schedule one retry after the stated delay, jittered. The CLI was not
878
+ // one, so a cap refusal ended an accepted run and the user never saw an
879
+ // answer the server had already produced.
880
+ //
881
+ // ONE retry, on the server's own clock, and only when it said the
882
+ // refusal was retryable.
883
+ const refusal = transportRefusal(error);
884
+ if (!refusal.retryable || refusal.retryAfterMs === undefined || retried)
885
+ return;
886
+ retried = true;
887
+ // The default deadline IS the server's retry delay, so without widening
888
+ // it the client gives up in the same instant it is allowed to try.
889
+ const widened = attachDeadlineFor(deadline, refusal);
890
+ if (widened > deadline) {
891
+ clearTimeout(timer);
892
+ deadline = widened;
893
+ timer = armDeadline(deadline);
894
+ }
895
+ retryTimer = setTimeout(() => {
896
+ retryTimer = null;
897
+ try {
898
+ socket.connect();
899
+ }
900
+ catch {
901
+ // The deadline above is the backstop; a failed reconnect attempt
902
+ // must not reject here and turn a pause into a failure.
903
+ }
904
+ }, jitteredRetryDelay(refusal.retryAfterMs));
905
+ retryTimer.unref?.();
451
906
  };
452
907
  const cleanup = () => {
453
908
  clearTimeout(timer);
909
+ if (retryTimer)
910
+ clearTimeout(retryTimer);
454
911
  socket.off('connect', onConnect);
455
912
  socket.off('connect_error', onError);
456
913
  };