clauderipple 0.9.4 → 0.10.0

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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -5,6 +5,14 @@ import crypto from "node:crypto";
5
5
  import { clampEffort } from "../../compat.js";
6
6
  import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemWithDroppedToolNote, toolNameForResponses } from "../chatgpt/translate.js";
7
7
  import { identityPrefix, instructionsSuffix } from "../../identity.js";
8
+ import { encodeEnvelope, envelopeFor, replayableReasoning } from "../../reasoning.js";
9
+ /** Counts a replay outcome, creating the tally the adapter reports from when it is the first. */
10
+ function bump(opts, kind, by = 1) {
11
+ if (!opts)
12
+ return;
13
+ const carried = opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 });
14
+ carried[kind] += by;
15
+ }
8
16
  function textOf(content) {
9
17
  if (typeof content === "string")
10
18
  return content;
@@ -104,6 +112,14 @@ export function toChatMessages(req, opts) {
104
112
  if (role === "assistant") {
105
113
  const text = [];
106
114
  const calls = [];
115
+ // The reasoning this turn came back with, rejoined into one string and sent as
116
+ // `reasoning_content` on the same assistant message — the shape DeepSeek's own example builds
117
+ // by appending the whole response message, and the only shape its API documents. The field
118
+ // name is the one the vendor sent it under, carried in the envelope: a model heard on
119
+ // `reasoning` is answered on `reasoning` (reasoning.ts).
120
+ const reasoning = [];
121
+ let field = "reasoning_content";
122
+ let carriedHere = 0;
107
123
  for (const block of message.content) {
108
124
  if (block.type === "text")
109
125
  text.push(String(block.text ?? ""));
@@ -112,9 +128,31 @@ export function toChatMessages(req, opts) {
112
128
  knownCalls.add(call.id);
113
129
  calls.push({ id: call.id, type: "function", function: { name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) } });
114
130
  }
131
+ const envelope = envelopeFor(block, "chat", opts?.env, (reason) => {
132
+ bump(opts, "undecodable");
133
+ if (opts?.replay !== false)
134
+ opts?.log?.(`dropped a reasoning carrier: ${reason}`);
135
+ });
136
+ if (!envelope || opts?.replay === false)
137
+ continue;
138
+ const held = block.thinking;
139
+ if (typeof held === "string" && held.length > 0)
140
+ reasoning.push(held);
141
+ field = envelope.f === "reasoning" ? "reasoning" : "reasoning_content";
142
+ carriedHere++;
143
+ }
144
+ if (carriedHere > 0)
145
+ bump(opts, "replayed", carriedHere);
146
+ // A turn whose only content was reasoning is still a turn the model had: it goes out with the
147
+ // reasoning alone rather than vanishing, which is what dropping thinking did before.
148
+ if (text.some(Boolean) || calls.length || reasoning.length) {
149
+ messages.push({
150
+ role: "assistant",
151
+ content: text.join("\n") || null,
152
+ ...(calls.length ? { tool_calls: calls } : {}),
153
+ ...(reasoning.length ? (field === "reasoning" ? { reasoning: reasoning.join("\n\n") } : { reasoning_content: reasoning.join("\n\n") }) : {}),
154
+ });
115
155
  }
116
- if (text.some(Boolean) || calls.length)
117
- messages.push({ role: "assistant", content: text.join("\n") || null, ...(calls.length ? { tool_calls: calls } : {}) });
118
156
  continue;
119
157
  }
120
158
  let parts = [];
@@ -125,13 +163,23 @@ export function toChatMessages(req, opts) {
125
163
  messages.push({ role: "user", content: onlyText ? parts.map((part) => part.text).join("\n") : parts });
126
164
  parts = [];
127
165
  };
166
+ // A tool result's images (and the fallback text of an unknown call) wait until the run of tool
167
+ // results ends: every `tool` message must follow the assistant's tool_calls directly, and a user
168
+ // message between parallel results is refused (DeepSeek: "insufficient tool messages", #64).
169
+ let deferred = [];
170
+ const release = () => {
171
+ parts.push(...deferred);
172
+ deferred = [];
173
+ };
128
174
  for (const block of message.content) {
129
175
  if (block.type === "text") {
176
+ release();
130
177
  const text = String(block.text ?? "");
131
178
  if (text)
132
179
  parts.push({ type: "text", text });
133
180
  }
134
181
  else if (block.type === "image") {
182
+ release();
135
183
  const url = imageUrl(block);
136
184
  if (url)
137
185
  parts.push({ type: "image_url", image_url: { url } });
@@ -148,19 +196,27 @@ export function toChatMessages(req, opts) {
148
196
  if (knownCalls.has(result.tool_use_id))
149
197
  messages.push({ role: "tool", tool_call_id: result.tool_use_id, content: output });
150
198
  else
151
- parts.push({ type: "text", text: `[Tool result]\n${output}` });
199
+ deferred.push({ type: "text", text: `[Tool result]\n${output}` });
152
200
  for (const url of images)
153
- parts.push({ type: "image_url", image_url: { url } });
201
+ deferred.push({ type: "image_url", image_url: { url } });
154
202
  }
155
203
  }
204
+ release();
156
205
  flush();
157
206
  }
158
207
  return messages;
159
208
  }
160
209
  /** Translate to the stateless OpenAI Responses input grammar. */
161
- export function toResponsesInput(req) {
210
+ export function toResponsesInput(req, opts) {
162
211
  const input = [];
163
212
  const knownCalls = new Set();
213
+ let lastCallId;
214
+ // A translation with no options is a translation with nothing to say about reasoning: there is no
215
+ // provider to name in an envelope and no model to check it against, so nothing is carried either
216
+ // way. `opts` is only consulted when it is there.
217
+ const carried = (count) => {
218
+ bump(opts, count);
219
+ };
164
220
  for (const message of req.messages) {
165
221
  const role = message.role === "assistant" ? "assistant" : "user";
166
222
  if (typeof message.content === "string") {
@@ -177,6 +233,27 @@ export function toResponsesInput(req) {
177
233
  parts = [];
178
234
  };
179
235
  for (const block of message.content) {
236
+ // A reasoning item this conversation came back with, in the position it was replayed from —
237
+ // beside the function calls of that turn, and never for the other wire's carrier (reasoning.ts).
238
+ const envelope = envelopeFor(block, "responses", opts?.env, (reason) => {
239
+ carried("undecodable");
240
+ if (opts?.replay !== false)
241
+ opts?.log?.(`dropped a reasoning carrier: ${reason}`);
242
+ });
243
+ if (envelope) {
244
+ if (opts?.replay !== false) {
245
+ const item = replayableReasoning(envelope.r);
246
+ if (item) {
247
+ flush();
248
+ input.push(item);
249
+ carried("replayed");
250
+ opts?.onArrival?.(item, lastCallId);
251
+ }
252
+ else
253
+ carried("undecodable");
254
+ }
255
+ continue;
256
+ }
180
257
  if (block.type === "text") {
181
258
  const text = String(block.text ?? "");
182
259
  if (text)
@@ -191,6 +268,7 @@ export function toResponsesInput(req) {
191
268
  flush();
192
269
  const call = block;
193
270
  knownCalls.add(call.id);
271
+ lastCallId = call.id;
194
272
  input.push({ type: "function_call", call_id: call.id, name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) });
195
273
  }
196
274
  else if (block.type === "tool_result") {
@@ -243,11 +321,18 @@ export function toOpenAiRequest(req, opts) {
243
321
  return out;
244
322
  }
245
323
  const responseTools = tools.map(({ function: fn }) => ({ type: "function", ...fn }));
324
+ const input = toResponsesInput(req, opts);
246
325
  const out = {
247
326
  model: opts.model,
248
- input: toResponsesInput(req),
327
+ input,
249
328
  stream: true,
250
329
  };
330
+ // A vendor marked `sealedReasoning` is asked for its reasoning on every turn, the way OpenCode's own
331
+ // client asks: asking only once a conversation already carried some would never start one on a
332
+ // vendor that seals nothing unasked. Anyone else is asked only when the conversation is already
333
+ // carrying its reasoning, so a request for a vendor never measured stays as it was.
334
+ if (opts.replay !== false && (opts.sealedReasoning || input.some((item) => item.type === "reasoning")))
335
+ out.include = ["reasoning.encrypted_content"];
251
336
  const instructions = systemWithIdentity(systemWithDroppedToolNote(req), opts);
252
337
  if (instructions)
253
338
  out.instructions = instructions;
@@ -268,7 +353,7 @@ export function toOpenAiRequest(req, opts) {
268
353
  if (typeof req.temperature === "number")
269
354
  out.temperature = req.temperature;
270
355
  if (effort)
271
- out.reasoning = { effort };
356
+ out.reasoning = opts.sealedReasoning && opts.replay !== false ? { effort, summary: "auto" } : { effort };
272
357
  return out;
273
358
  }
274
359
  export { conversationKey, estimateTokens };
@@ -277,26 +362,43 @@ export class OpenAiStreamMapper {
277
362
  started = false;
278
363
  finished = false;
279
364
  blockIndex = -1;
365
+ /** `block` is the content entry this block writes into; `signature` seals a Responses item. */
280
366
  open = null;
281
367
  sawTool = false;
282
368
  finishReason;
283
369
  responseItems = [];
284
370
  responseItemsById = new Map();
371
+ /** Reasoning items of a Responses stream, by item id, until the item that carries one is done. */
372
+ reasoningItems = new Map();
373
+ /** The item id of the most recent function_call, which is what a following reasoning describes. */
374
+ lastCallId;
375
+ /** The field a Chat vendor's reasoning arrived under; the same one it is replayed under. */
376
+ chatReasoningField = "reasoning_content";
285
377
  messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
286
378
  servedTier;
287
379
  content = [];
288
380
  usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
289
381
  stopReason = "end_turn";
382
+ /** How many reasoning items this response handed over whole. The `reasoning=` figure in the log. */
383
+ reasoningCount = 0;
290
384
  model;
291
385
  startInput;
292
386
  /** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
293
387
  toolNames;
388
+ /** What goes in each envelope: the provider and model this stream is going to (reasoning.ts). */
389
+ env;
390
+ /**
391
+ * The wire this stream is on. It is fixed for the mapper's life — the adapter knows it before the
392
+ * first event. Decides what a closing block does: on Chat the block itself is the carrier.
393
+ */
394
+ wire = "chat";
294
395
  /** Applied to every error message before it is stored or sent. */
295
396
  redactError = (message) => message;
296
- constructor(model, startInput = 0, toolNames = new Map()) {
397
+ constructor(model, startInput = 0, toolNames = new Map(), env = { p: "", m: "" }) {
297
398
  this.model = model;
298
399
  this.startInput = startInput;
299
400
  this.toolNames = toolNames;
401
+ this.env = { p: env.p, m: env.m || model };
300
402
  }
301
403
  get isFinished() { return this.finished; }
302
404
  /**
@@ -315,17 +417,40 @@ export class OpenAiStreamMapper {
315
417
  this.started = true;
316
418
  return [{ event: "message_start", data: { type: "message_start", message: { id: this.messageId, type: "message", role: "assistant", model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: this.startInput, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } } }];
317
419
  }
420
+ /**
421
+ * Close the block that is open, writing any envelope it carries as a `signature_delta` first — last
422
+ * in the block, after all of its text, the order Anthropic's own thinking blocks use.
423
+ *
424
+ * A Chat reasoning block is sealed here, because only now is its text whole; a Responses one was
425
+ * sealed by its item (see `sealReasoningItem`) before this is reached, so it never gets here
426
+ * unsigned.
427
+ */
318
428
  closeBlock() {
319
429
  if (!this.open)
320
430
  return [];
321
- const index = this.open.index;
431
+ const { index, kind, block } = this.open;
432
+ let signature = this.open.signature;
433
+ if (signature === undefined && kind === "thinking" && this.wire === "chat") {
434
+ signature = this.signatureForChat(block);
435
+ }
322
436
  this.open = null;
323
- return [{ event: "content_block_stop", data: { type: "content_block_stop", index } }];
437
+ const out = [];
438
+ if (signature !== undefined)
439
+ out.push({ event: "content_block_delta", data: { type: "content_block_delta", index, delta: { type: "signature_delta", signature } } });
440
+ out.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
441
+ return out;
324
442
  }
325
- openBlock(kind, contentBlock, toolIndex) {
443
+ /**
444
+ * `block` is the caller's entry in `this.content` for this block, when it has one. It is held here
445
+ * rather than found as the last entry, because a Chat caller pushes the entry it is about to open
446
+ * *before* calling this — which would close the block against the wrong entry.
447
+ */
448
+ openBlock(kind, contentBlock, toolIndex, block) {
326
449
  const out = this.closeBlock();
327
450
  const index = ++this.blockIndex;
328
- this.open = { kind, index, ...(toolIndex === undefined ? {} : { toolIndex }) };
451
+ if (block !== undefined)
452
+ this.content.push(block);
453
+ this.open = { kind, index, ...(toolIndex === undefined ? {} : { toolIndex }), ...(block === undefined ? {} : { block }) };
329
454
  out.push({ event: "content_block_start", data: { type: "content_block_start", index, content_block: contentBlock } });
330
455
  return out;
331
456
  }
@@ -371,27 +496,34 @@ export class OpenAiStreamMapper {
371
496
  for (const choice of choices) {
372
497
  const delta = choice.delta ?? {};
373
498
  // Thinking arrives before the answer, so it opens the first block of the turn. `reasoning` is
374
- // accepted alongside `reasoning_content` because vendors on this wire disagree on the name.
375
- const reasoning = typeof delta.reasoning_content === "string" && delta.reasoning_content ? delta.reasoning_content
376
- : typeof delta.reasoning === "string" && delta.reasoning ? delta.reasoning : "";
499
+ // accepted alongside `reasoning_content` because vendors on this wire disagree on the name;
500
+ // the name it arrived under is remembered, because the same name is what the reasoning has to
501
+ // be replayed under on the next turn (reasoning.ts).
502
+ const field = typeof delta.reasoning_content === "string" && delta.reasoning_content ? "reasoning_content"
503
+ : typeof delta.reasoning === "string" && delta.reasoning ? "reasoning" : null;
504
+ const reasoning = field === "reasoning_content" ? delta.reasoning_content : field === "reasoning" ? delta.reasoning : "";
505
+ if (field)
506
+ this.chatReasoningField = field;
377
507
  if (reasoning) {
378
508
  if (!this.open || this.open.kind !== "thinking") {
379
- this.content.push({ type: "thinking", thinking: "" });
380
- out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
509
+ // A Chat model's reasoning has no sealed blob to resume from, so the carrier is the block
510
+ // itself: the block's text is the reasoning to replay, and its signature is the envelope
511
+ // naming the field to replay it under (reasoning.ts). It is sealed when the block closes,
512
+ // so the envelope is built once the whole string has arrived (see `signatureForChat`).
513
+ out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }, undefined, { type: "thinking", thinking: "" }));
381
514
  }
382
- const last = this.content[this.content.length - 1];
383
- if (last?.type === "thinking")
384
- last.thinking += reasoning;
515
+ const block = this.open?.block;
516
+ if (block)
517
+ block.thinking += reasoning;
385
518
  out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning } } });
386
519
  }
387
520
  if (typeof delta.content === "string" && delta.content) {
388
521
  if (!this.open || this.open.kind !== "text") {
389
- this.content.push({ type: "text", text: "" });
390
- out.push(...this.openBlock("text", { type: "text", text: "" }));
522
+ out.push(...this.openBlock("text", { type: "text", text: "" }, undefined, { type: "text", text: "" }));
391
523
  }
392
- const last = this.content[this.content.length - 1];
393
- if (last?.type === "text")
394
- last.text += delta.content;
524
+ const block = this.open?.block;
525
+ if (block)
526
+ block.text += delta.content;
395
527
  out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "text_delta", text: delta.content } } });
396
528
  }
397
529
  if (Array.isArray(delta.tool_calls)) {
@@ -417,6 +549,42 @@ export class OpenAiStreamMapper {
417
549
  }
418
550
  return out;
419
551
  }
552
+ /**
553
+ * Seal a closing thinking block of our own: a Chat block into the envelope that names the field to
554
+ * replay its text under (see `feedChat`).
555
+ */
556
+ signatureForChat(block) {
557
+ if (this.wire !== "chat" || block?.type !== "thinking" || block.thinking.length === 0)
558
+ return undefined;
559
+ const signature = encodeEnvelope({ w: "chat", p: this.env.p, m: this.env.m, f: this.chatReasoningField });
560
+ block.signature = signature;
561
+ this.reasoningCount++;
562
+ return signature;
563
+ }
564
+ /**
565
+ * Put the finished Responses reasoning item into the envelope its block carries: the wire, this
566
+ * provider and model, and the item itself — type, id, summary and the backend's sealed blob,
567
+ * untouched (reasoning.ts). An item the backend did not seal has nothing to resume, so its block
568
+ * stays unsigned and only its summary survives, as display text.
569
+ */
570
+ sealReasoningItem(item) {
571
+ const held = this.reasoningItems.get(item.id);
572
+ const block = item.block;
573
+ block.thinking = held?.text.trim() || block.thinking.trim() || (typeof held?.summary === "string" ? held.summary.trim() : "");
574
+ if (!held?.encrypted) {
575
+ // An item the backend did not seal has nothing to resume. The block stays unsigned, so the
576
+ // client treats it as display text and never replays it (reasoning.ts).
577
+ block.signature = "";
578
+ return;
579
+ }
580
+ const reasoning = { type: "reasoning", encrypted_content: held.encrypted };
581
+ if (item.id)
582
+ reasoning.id = item.id;
583
+ if (Array.isArray(held.summary))
584
+ reasoning.summary = held.summary;
585
+ block.signature = encodeEnvelope({ w: "responses", p: this.env.p, m: this.env.m, r: reasoning });
586
+ this.reasoningCount++;
587
+ }
420
588
  responseItem(id, kind) {
421
589
  if (typeof id === "string")
422
590
  return this.responseItemsById.get(id);
@@ -452,6 +620,24 @@ export class OpenAiStreamMapper {
452
620
  item.closed = true;
453
621
  continue;
454
622
  }
623
+ if (item.kind === "reasoning") {
624
+ // Emitted only once the item is done, because the block is handed over whole: the sealed
625
+ // reasoning is what goes in the envelope, and it may only arrive with the completed item.
626
+ // `sealReasoningItem` has already put the envelope on the block by then.
627
+ if (!item.done)
628
+ break;
629
+ const reasoning = item.block;
630
+ out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
631
+ if (reasoning.signature !== undefined)
632
+ this.open = { ...this.open, signature: reasoning.signature };
633
+ if (reasoning.thinking)
634
+ out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning.thinking } } });
635
+ item.emitted = true;
636
+ item.emittedText = reasoning.thinking.length;
637
+ out.push(...this.closeBlock());
638
+ item.closed = true;
639
+ continue;
640
+ }
455
641
  const tool = item.block;
456
642
  out.push(...this.openBlock("tool", { type: "tool_use", id: tool.id, name: tool.name, input: {} }, tool.index));
457
643
  if (tool.args) {
@@ -480,12 +666,28 @@ export class OpenAiStreamMapper {
480
666
  this.responseItemsById.set(id, responseItem);
481
667
  }
482
668
  else if (id && item?.type === "function_call") {
669
+ this.lastCallId = item.call_id === undefined || item.call_id === null ? id : String(item.call_id);
483
670
  const index = this.content.filter((block) => block.type === "tool_use").length;
484
671
  const tool = this.toolFor(index, { id: item.call_id ?? item.id, function: { name: item.name } });
485
672
  const responseItem = { id, kind: "tool", block: tool, done: false, emitted: false, closed: false, emittedText: 0 };
486
673
  this.responseItems.push(responseItem);
487
674
  this.responseItemsById.set(id, responseItem);
488
675
  }
676
+ else if (id && item?.type === "reasoning") {
677
+ // The item's sealed reasoning and summary, held until the item is done: the block handed over
678
+ // carries both, and a half-assembled one must not reach the client (reasoning.ts).
679
+ const reasoning = {
680
+ ...(typeof item.encrypted_content === "string" && item.encrypted_content ? { encrypted: item.encrypted_content } : {}),
681
+ summary: item.summary,
682
+ text: "",
683
+ };
684
+ this.reasoningItems.set(id, reasoning);
685
+ const block = { type: "thinking", thinking: "" };
686
+ this.content.push(block);
687
+ const responseItem = { id, kind: "reasoning", block, done: false, emitted: false, closed: false, emittedText: 0 };
688
+ this.responseItems.push(responseItem);
689
+ this.responseItemsById.set(id, responseItem);
690
+ }
489
691
  }
490
692
  else if (type === "response.output_text.delta") {
491
693
  const text = typeof ev.delta === "string" ? ev.delta : "";
@@ -511,10 +713,40 @@ export class OpenAiStreamMapper {
511
713
  }
512
714
  }
513
715
  }
716
+ else if (type === "response.reasoning_summary_text.delta" || type === "response.reasoning_text.delta") {
717
+ // The summary, shown as the thinking block's text. When there is none — which is the usual
718
+ // shape — nothing arrives and the block stays empty, exactly as the item was measured.
719
+ const text = typeof ev.delta === "string" ? ev.delta : "";
720
+ const item = this.responseItem(ev.item_id, "reasoning");
721
+ if (item?.kind === "reasoning" && text) {
722
+ const block = item.block;
723
+ block.thinking += text;
724
+ if (this.reasoningItems.get(item.id))
725
+ this.reasoningItems.get(item.id).text += text;
726
+ if (item.emitted && this.open?.kind === "thinking") {
727
+ out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: text } } });
728
+ item.emittedText += text.length;
729
+ }
730
+ }
731
+ }
514
732
  else if (type === "response.output_item.done") {
515
- const item = this.responseItem(ev.item_id ?? ev.item?.id);
516
- if (item)
733
+ const done = ev.item;
734
+ const item = this.responseItem(ev.item_id ?? done?.id);
735
+ if (done?.encrypted_content !== undefined || done?.summary !== undefined) {
736
+ // The backend may seal the reasoning only when the item completes; take it from there too.
737
+ const held = this.reasoningItems.get(typeof (ev.item_id ?? done?.id) === "string" ? String(ev.item_id ?? done?.id) : "");
738
+ if (held) {
739
+ if (typeof done.encrypted_content === "string" && done.encrypted_content)
740
+ held.encrypted = done.encrypted_content;
741
+ if (done.summary !== undefined)
742
+ held.summary = done.summary;
743
+ }
744
+ }
745
+ if (item) {
517
746
  item.done = true;
747
+ if (item.kind === "reasoning")
748
+ this.sealReasoningItem(item);
749
+ }
518
750
  }
519
751
  else if (type === "response.completed" || type === "response.incomplete") {
520
752
  const response = ev.response;
@@ -535,6 +767,7 @@ export class OpenAiStreamMapper {
535
767
  feed(ev, wire) {
536
768
  if (this.finished)
537
769
  return [];
770
+ this.wire = wire;
538
771
  // Some OpenAI-compatible servers emit an ordinary `{error:{...}}` JSON SSE payload
539
772
  // instead of the Responses `error` event shape. Do not finish it as a successful answer.
540
773
  if (ev.error && typeof ev.error === "object") {
@@ -0,0 +1,116 @@
1
+ // How much of an OpenCode Go subscription is used, for the status bar's limits row.
2
+ //
3
+ // One request per provider: GET {provider.url}/usage (measured 2026-10-15: https://opencode.ai/zen/go/v1/usage)
4
+ // with the provider's own config headers (the bearer key) and the same default User-Agent Node's
5
+ // fetch sends elsewhere — Cloudflare answers 403 `error 1010` to a request carrying no User-Agent
6
+ // and no browser fingerprint, but accepts Node's fetch as it is. Source of the route (behavioral
7
+ // only, no code copied): anomalyco/opencode packages/console/app/src/routes/zen/go/v1/usage.ts and
8
+ // lib/inference-proxy.ts (the `GET /zen/go/v1/usage` → `/go/v1/usage` mapping).
9
+ //
10
+ // Measured answer:
11
+ // {"usage":{"rolling":{"status":"ok","percent":3,"resetsAt":"2026-10-15T05:39:48Z"},
12
+ // "weekly":{"status":"ok","percent":52,"resetsAt":"2026-10-19T00:00:00Z"},
13
+ // "monthly":{"status":"ok","percent":92,"resetsAt":"2026-10-18T05:43:30Z"}}}
14
+ // `rolling` is the five-hour window, `weekly` the week, `monthly` roughly 30 days.
15
+ /** The five-hour, weekly and ~monthly window lengths in minutes. */
16
+ const FIVE_HOURS = 5 * 60;
17
+ const WEEK = 7 * 24 * 60;
18
+ const MONTH = 30 * 24 * 60;
19
+ /** The window lengths and read order of the endpoint's three keys. */
20
+ const KEYS = [
21
+ { key: "rolling", kind: "five_hour", window_minutes: FIVE_HOURS },
22
+ { key: "weekly", kind: "seven_day", window_minutes: WEEK },
23
+ { key: "monthly", kind: "window", window_minutes: MONTH },
24
+ ];
25
+ /**
26
+ * Parse the `/usage` body into windows. A window missing a finite percent is dropped; null when the
27
+ * body carries no `usage` object at all (so the caller keeps its last good answer instead of
28
+ * blanking).
29
+ */
30
+ export function opencodeWindowsFromBody(body) {
31
+ if (!body || typeof body !== "object" || Array.isArray(body))
32
+ return null;
33
+ const usage = body.usage;
34
+ if (!usage || typeof usage !== "object" || Array.isArray(usage))
35
+ return null;
36
+ const source = usage;
37
+ const out = [];
38
+ for (const { key, kind, window_minutes } of KEYS) {
39
+ const w = source[key];
40
+ if (!w || typeof w !== "object" || Array.isArray(w))
41
+ continue;
42
+ const win = w;
43
+ if (typeof win.percent !== "number" || !Number.isFinite(win.percent))
44
+ continue;
45
+ const at = typeof win.resetsAt === "string" ? Date.parse(win.resetsAt) : NaN;
46
+ out.push({ kind, used_percent: win.percent, window_minutes, ...(Number.isFinite(at) ? { reset_at: Math.round(at / 1000) } : {}) });
47
+ }
48
+ return out.length ? out : null;
49
+ }
50
+ /** Whether a provider is an OpenCode Go subscription: its preset, or an OpenCode Go endpoint. */
51
+ export function isOpenCodeGo(p) {
52
+ if (p.type === "openai-compatible" && p.preset === "opencode-go")
53
+ return true;
54
+ if (typeof p.url !== "string")
55
+ return false;
56
+ try {
57
+ const url = new URL(p.url);
58
+ return url.hostname === "opencode.ai" && /^\/zen\/go\/v1(?:\/|$)/.test(url.pathname);
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * Per-provider usage windows, fetched at most once per `ttlMs` per provider. A failed lookup keeps
66
+ * the last good windows rather than blanking them, and only the status code is logged.
67
+ */
68
+ export class OpenCodeUsage {
69
+ last = new Map();
70
+ at = new Map();
71
+ inFlight = new Map();
72
+ opts;
73
+ constructor(opts) {
74
+ this.opts = opts;
75
+ }
76
+ /** The provider's windows, cached for `ttlMs`; the last good ones survive a failed lookup. */
77
+ windows(name, url, headers) {
78
+ const now = (this.opts.now ?? Date.now)();
79
+ const ttl = this.opts.ttlMs ?? 5 * 60_000;
80
+ if (now - (this.at.get(name) ?? Number.NEGATIVE_INFINITY) < ttl)
81
+ return Promise.resolve(this.last.get(name) ?? []);
82
+ const running = this.inFlight.get(name);
83
+ if (running)
84
+ return running;
85
+ const task = this.fetchOne(name, url, headers).finally(() => {
86
+ this.at.set(name, (this.opts.now ?? Date.now)());
87
+ this.inFlight.delete(name);
88
+ });
89
+ this.inFlight.set(name, task);
90
+ return task;
91
+ }
92
+ async fetchOne(name, url, headers) {
93
+ const keep = () => this.last.get(name) ?? [];
94
+ try {
95
+ const res = await (this.opts.fetch ?? fetch)(`${url.replace(/\/+$/, "")}/usage`, {
96
+ method: "GET",
97
+ headers: { accept: "application/json", ...headers },
98
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
99
+ });
100
+ if (!res.ok) {
101
+ // The status alone: the body of an auth failure is not worth the risk of echoing.
102
+ this.opts.warn?.(`opencode usage ${name}: HTTP ${res.status}`);
103
+ return keep();
104
+ }
105
+ const windows = opencodeWindowsFromBody(await res.json());
106
+ if (!windows)
107
+ return keep();
108
+ this.last.set(name, windows);
109
+ return windows;
110
+ }
111
+ catch (e) {
112
+ this.opts.warn?.(`opencode usage ${name}: ${e.message}`);
113
+ return keep();
114
+ }
115
+ }
116
+ }