atom-agent 1.4.0 → 1.5.1

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 (68) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +221 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +502 -21
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +250 -434
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/providers.js +11 -3
  21. package/dist/scheduler.js +38 -9
  22. package/dist/session-revert.js +125 -0
  23. package/dist/sessions.js +101 -0
  24. package/dist/snapshots.js +69 -0
  25. package/dist/system.js +2 -89
  26. package/dist/telemetry.js +79 -5
  27. package/dist/todos.js +241 -0
  28. package/dist/tools/filesystem.js +102 -22
  29. package/dist/tools/registry.js +184 -45
  30. package/dist/tools/ripgrep.js +7 -6
  31. package/dist/tools/search.js +172 -17
  32. package/dist/tools/shared.js +6 -0
  33. package/dist/tools.js +7 -39
  34. package/dist/ui/diff-panel.js +1 -1
  35. package/dist/ui/diff-view.js +13 -5
  36. package/dist/ui/diff.js +67 -0
  37. package/dist/ui/errors.js +20 -6
  38. package/dist/ui/input.js +24 -20
  39. package/dist/ui/live-tail.js +36 -1
  40. package/dist/ui/markdown.js +9 -4
  41. package/dist/ui/modals.js +7 -5
  42. package/dist/ui/paint-scheduler.js +120 -0
  43. package/dist/ui/palette.js +4 -2
  44. package/dist/ui/pickers.js +4 -1
  45. package/dist/ui/side-by-side.js +81 -22
  46. package/dist/ui/status-bar.js +63 -8
  47. package/dist/ui/stream-store.js +7 -0
  48. package/dist/ui/theme.js +23 -1
  49. package/dist/ui/todo-panel.js +5 -2
  50. package/dist/ui/tool-inspector.js +33 -4
  51. package/dist/ui/transcript.js +8 -5
  52. package/dist/web/events.js +93 -0
  53. package/dist/web/runtime.js +790 -0
  54. package/dist/web/server.js +570 -0
  55. package/dist/web/ui/app.js +1925 -0
  56. package/dist/web/ui/index.html +135 -0
  57. package/dist/web/ui/styles.css +515 -0
  58. package/dist/zen.js +532 -34
  59. package/documentation/cli.md +5 -5
  60. package/documentation/configuration.md +11 -6
  61. package/documentation/development.md +4 -3
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/package.json +3 -2
package/dist/adapters.js CHANGED
@@ -6,10 +6,56 @@
6
6
  // Normalized output matches zen ChatResult:
7
7
  // {content, tool_calls:[{id,function:{name,arguments}}], usage?}
8
8
  // so runAgenticLoop/retry/rollback/status code is untouched.
9
- import { allToolDefinitions } from "./tools.js";
9
+ import { chatToolDefinitions } from "./tools.js";
10
10
  import { getProvider, modelsUrlForProvider, } from "./providers.js";
11
11
  export const ANTHROPIC_VERSION = "2023-06-01";
12
12
  export const ANTHROPIC_MAX_TOKENS = 4096;
13
+ // ---- OpenCode Zen client identity (free `*-free` promo models) ----
14
+ //
15
+ // Zen's free pool is gated on official-client identity, verified live
16
+ // 2026-09-12 against https://opencode.ai/zen/v1/chat/completions:
17
+ // - `User-Agent: opencode/*` alone → 429 FreeUsageLimitError.
18
+ // - UA alone (no session) → 400 MissingSessionID
19
+ // ("OpenCode's free tier can only be used in OpenCode").
20
+ // - UA + `x-opencode-session: ses_…` → 200 (any value passes; the check
21
+ // is presence-only, the id needs no server-side registration).
22
+ // - `x-opencode-client` / `x-opencode-project` are NOT required (probed).
23
+ // Paid models are not gated.
24
+ // Lives here (not zen.ts) so both zen.ts and the key-validation path below
25
+ // share one constant without a runtime import cycle.
26
+ export const ZEN_CLIENT_UA = "opencode/1.18.16";
27
+ const ZEN_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
28
+ function zenRandomSuffix(length = 24) {
29
+ let out = "";
30
+ for (let i = 0; i < length; i++) {
31
+ out += ZEN_ID_ALPHABET[Math.floor(Math.random() * ZEN_ID_ALPHABET.length)];
32
+ }
33
+ return out;
34
+ }
35
+ // One stable session per process (mirrors one client conversation; avoids
36
+ // minting a new server-side session per keystroke). Reset only on restart.
37
+ let cachedZenSessionId = null;
38
+ /** Stable `ses_…` id for this process (generated once, lazily). */
39
+ export function zenSessionId() {
40
+ if (!cachedZenSessionId)
41
+ cachedZenSessionId = `ses_${zenRandomSuffix()}`;
42
+ return cachedZenSessionId;
43
+ }
44
+ /** Fresh `msg_…` id per POST (mirrors one id per client message). */
45
+ export function zenRequestId() {
46
+ return `msg_${zenRandomSuffix()}`;
47
+ }
48
+ // Base headers for any Zen HTTP call (chat POST, models GET, key check).
49
+ // Anonymous-capable: omits Authorization when no key (never `Bearer `).
50
+ export function zenHeaders(apiKey, opts) {
51
+ return {
52
+ "Content-Type": "application/json",
53
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
54
+ "User-Agent": ZEN_CLIENT_UA,
55
+ "x-opencode-session": opts?.sessionId ?? zenSessionId(),
56
+ "x-opencode-request": opts?.requestId ?? zenRequestId(),
57
+ };
58
+ }
13
59
  // ---- Reasoning-effort mappings (one /effort knob, three wire shapes) ----
14
60
  //
15
61
  // OpenAI-chat kind sends `reasoning_effort` verbatim (no mapping needed).
@@ -64,10 +110,13 @@ export function geminiThinkingLevelFor(effort) {
64
110
  export function isEffortRejection(errorText) {
65
111
  return /reasoning_effort|reasoning effort|thinking_level|budget_tokens|\bthinking\b/i.test(errorText);
66
112
  }
67
- function toolDefs() {
113
+ function toolDefs(includeUpdateGoal = true) {
68
114
  // Builtins plus extension-registered custom tools, so non-OpenAI kinds
69
115
  // see the same model-visible surface as the OpenAI-chat path.
70
- return allToolDefinitions();
116
+ // includeUpdateGoal:false hides update_goal when the turn has no live
117
+ // goal (same contract as chatToolDefinitions — default keeps every
118
+ // existing caller byte-identical).
119
+ return chatToolDefinitions(includeUpdateGoal);
71
120
  }
72
121
  function parseArgsObject(raw) {
73
122
  try {
@@ -81,11 +130,16 @@ function parseArgsObject(raw) {
81
130
  }
82
131
  }
83
132
  import { ephemeralBreakpoint, assemblePrefix } from "./prompt-cache.js";
133
+ import { hasMediaRefs, lowerOpenAIContent, resolveMediaRefs, stripMedia, } from "./media.js";
84
134
  export function buildAnthropicBody(history, model, opts) {
85
135
  const systems = [];
86
136
  const messages = [];
87
137
  // Group consecutive tool messages into one user message with
88
138
  // multiple tool_result blocks (Anthropic convention).
139
+ // Media lowering (see src/media.ts): histories without descriptors take
140
+ // the string path byte-identically; descriptor-bearing user/tool content
141
+ // expands to text + base64 image blocks (strip mode: prose markers).
142
+ const strip = opts?.stripMedia === true;
89
143
  let pendingToolResults = [];
90
144
  function flushTools() {
91
145
  if (pendingToolResults.length === 0)
@@ -95,20 +149,64 @@ export function buildAnthropicBody(history, model, opts) {
95
149
  }
96
150
  for (const m of history) {
97
151
  if (m.role === "system") {
98
- systems.push(m.content);
152
+ systems.push(strip ? stripMedia(m.content) : m.content);
99
153
  continue;
100
154
  }
101
155
  if (m.role === "tool") {
156
+ if (!hasMediaRefs(m.content)) {
157
+ pendingToolResults.push({
158
+ type: "tool_result",
159
+ tool_use_id: m.tool_call_id,
160
+ content: strip ? stripMedia(m.content) : m.content,
161
+ });
162
+ continue;
163
+ }
164
+ if (strip) {
165
+ pendingToolResults.push({
166
+ type: "tool_result",
167
+ tool_use_id: m.tool_call_id,
168
+ content: stripMedia(m.content),
169
+ });
170
+ continue;
171
+ }
172
+ const { text, media } = resolveMediaRefs(m.content);
173
+ const blocks = [{ type: "text", text }];
174
+ for (const part of media) {
175
+ if (!part.ok)
176
+ continue; // placeholder already inline in text
177
+ blocks.push({
178
+ type: "image",
179
+ source: { type: "base64", media_type: part.mime, data: part.base64 },
180
+ });
181
+ }
102
182
  pendingToolResults.push({
103
183
  type: "tool_result",
104
184
  tool_use_id: m.tool_call_id,
105
- content: m.content,
185
+ content: blocks,
106
186
  });
107
187
  continue;
108
188
  }
109
189
  flushTools();
110
190
  if (m.role === "user") {
111
- messages.push({ role: "user", content: m.content });
191
+ if (!hasMediaRefs(m.content)) {
192
+ messages.push({ role: "user", content: strip ? stripMedia(m.content) : m.content });
193
+ }
194
+ else if (strip) {
195
+ messages.push({ role: "user", content: stripMedia(m.content) });
196
+ }
197
+ else {
198
+ const { text, media } = resolveMediaRefs(m.content);
199
+ const blocks = [{ type: "text", text }];
200
+ for (const part of media) {
201
+ if (!part.ok)
202
+ continue;
203
+ blocks.push({
204
+ type: "image",
205
+ source: { type: "base64", media_type: part.mime, data: part.base64 },
206
+ });
207
+ }
208
+ messages.push({ role: "user", content: blocks });
209
+ }
112
210
  }
113
211
  else {
114
212
  // assistant: text + tool_use blocks
@@ -140,7 +238,7 @@ export function buildAnthropicBody(history, model, opts) {
140
238
  // Compaction path (includeTools:false) omits `tools` + `tool_choice`
141
239
  // entirely — asserted in tests as "no `tools` key".
142
240
  if (includeTools) {
143
- const defs = toolDefs().map((t) => ({
241
+ const defs = toolDefs(opts?.includeUpdateGoal !== false).map((t) => ({
144
242
  name: t.function.name,
145
243
  description: t.function.description,
146
244
  input_schema: t.function.parameters,
@@ -222,10 +320,15 @@ export function geminiHeaders(apiKey) {
222
320
  };
223
321
  }
224
322
  export function buildGeminiBody(history, _model, opts) {
323
+ // Media lowering (see src/media.ts): histories without descriptors take
324
+ // the legacy path byte-identically. Tool-result images ride as inline_data
325
+ // user parts after their functionResponse (function responses stay text);
326
+ // user-message images ride inline. Strip mode: prose markers only.
327
+ const strip = opts?.stripMedia === true;
225
328
  const systems = [];
226
329
  for (const m of history) {
227
330
  if (m.role === "system")
228
- systems.push(m.content);
331
+ systems.push(strip ? stripMedia(m.content) : m.content);
229
332
  }
230
333
  // tool_call_id -> function name (tool messages carry only the id).
231
334
  const nameById = new Map();
@@ -239,27 +342,83 @@ export function buildGeminiBody(history, _model, opts) {
239
342
  }
240
343
  const contents = [];
241
344
  let pendingResponses = [];
345
+ // Images resolved out of tool results (flushed as user inline_data parts
346
+ // right after their functionResponse group).
347
+ let pendingToolImages = [];
242
348
  function flushResponses() {
243
- if (pendingResponses.length === 0)
349
+ if (pendingResponses.length === 0 && pendingToolImages.length === 0)
244
350
  return;
245
- contents.push({ role: "user", parts: pendingResponses });
246
- pendingResponses = [];
351
+ if (pendingResponses.length > 0) {
352
+ contents.push({ role: "user", parts: pendingResponses });
353
+ pendingResponses = [];
354
+ }
355
+ if (pendingToolImages.length > 0) {
356
+ const parts = [
357
+ {
358
+ text: `Image(s) from tool result: ${pendingToolImages.map((i) => i.name).join(", ")}`,
359
+ },
360
+ ];
361
+ for (const img of pendingToolImages) {
362
+ parts.push({ inline_data: { mime_type: img.mime, data: img.data } });
363
+ }
364
+ pendingToolImages = [];
365
+ contents.push({ role: "user", parts });
366
+ }
247
367
  }
248
368
  for (const m of history) {
249
369
  if (m.role === "system")
250
370
  continue;
251
371
  if (m.role === "tool") {
372
+ if (!hasMediaRefs(m.content)) {
373
+ pendingResponses.push({
374
+ functionResponse: {
375
+ name: nameById.get(m.tool_call_id) ?? "unknown",
376
+ response: { result: strip ? stripMedia(m.content) : m.content },
377
+ },
378
+ });
379
+ continue;
380
+ }
381
+ if (strip) {
382
+ pendingResponses.push({
383
+ functionResponse: {
384
+ name: nameById.get(m.tool_call_id) ?? "unknown",
385
+ response: { result: stripMedia(m.content) },
386
+ },
387
+ });
388
+ continue;
389
+ }
390
+ const { text, media } = resolveMediaRefs(m.content);
252
391
  pendingResponses.push({
253
392
  functionResponse: {
254
393
  name: nameById.get(m.tool_call_id) ?? "unknown",
255
- response: { result: m.content },
394
+ response: { result: text },
256
395
  },
257
396
  });
397
+ for (const part of media) {
398
+ if (!part.ok)
399
+ continue;
400
+ pendingToolImages.push({ mime: part.mime, data: part.base64, name: part.name });
401
+ }
258
402
  continue;
259
403
  }
260
404
  flushResponses();
261
405
  if (m.role === "user") {
262
- contents.push({ role: "user", parts: [{ text: m.content }] });
406
+ if (!hasMediaRefs(m.content)) {
407
+ contents.push({ role: "user", parts: [{ text: strip ? stripMedia(m.content) : m.content }] });
408
+ }
409
+ else if (strip) {
410
+ contents.push({ role: "user", parts: [{ text: stripMedia(m.content) }] });
411
+ }
412
+ else {
413
+ const { text, media } = resolveMediaRefs(m.content);
414
+ const parts = [{ text }];
415
+ for (const part of media) {
416
+ if (!part.ok)
417
+ continue;
418
+ parts.push({ inline_data: { mime_type: part.mime, data: part.base64 } });
419
+ }
420
+ contents.push({ role: "user", parts });
421
+ }
263
422
  }
264
423
  else {
265
424
  const am = m;
@@ -290,7 +449,7 @@ export function buildGeminiBody(history, _model, opts) {
290
449
  if (includeTools) {
291
450
  body.tools = [
292
451
  {
293
- functionDeclarations: toolDefs().map((t) => ({
452
+ functionDeclarations: toolDefs(opts?.includeUpdateGoal !== false).map((t) => ({
294
453
  name: t.function.name,
295
454
  description: t.function.description,
296
455
  parameters: stripGeminiSchemaKeys(t.function.parameters),
@@ -330,10 +489,15 @@ export function buildGeminiBody(history, _model, opts) {
330
489
  //
331
490
  // Budget: env ATOM_STALL_TIMEOUT_MS when a finite value > 0 (max-clamped to
332
491
  // 5min; an explicitly tiny value is the operator's choice, and lets tests
333
- // use millisecond budgets), else the 60s default. The hung read is left to
492
+ // use millisecond budgets), else the 60s default. Header budget (time to
493
+ // first `data:` line) defaults to 300s like opencode's headerTimeout —
494
+ // queued free-tier requests sit headerless for minutes while chunk stalls
495
+ // (mid-generation silence) trip much sooner. The hung read is left to
334
496
  // settle — callers cancel/release the reader on the way out as before.
335
497
  export const DEFAULT_SSE_STALL_TIMEOUT_MS = 60_000;
336
498
  export const MAX_SSE_STALL_TIMEOUT_MS = 300_000;
499
+ export const DEFAULT_SSE_HEADER_TIMEOUT_MS = 300_000;
500
+ export const MAX_SSE_HEADER_TIMEOUT_MS = 300_000;
337
501
  export function sseStallTimeoutMs() {
338
502
  const raw = process.env.ATOM_STALL_TIMEOUT_MS;
339
503
  if (raw !== undefined) {
@@ -343,6 +507,15 @@ export function sseStallTimeoutMs() {
343
507
  }
344
508
  return DEFAULT_SSE_STALL_TIMEOUT_MS;
345
509
  }
510
+ export function sseHeaderTimeoutMs() {
511
+ const raw = process.env.ATOM_HEADER_TIMEOUT_MS;
512
+ if (raw !== undefined) {
513
+ const n = Number(raw.trim());
514
+ if (Number.isFinite(n) && n > 0)
515
+ return Math.min(Math.floor(n), MAX_SSE_HEADER_TIMEOUT_MS);
516
+ }
517
+ return DEFAULT_SSE_HEADER_TIMEOUT_MS;
518
+ }
346
519
  export function isStallError(e) {
347
520
  return e instanceof Error && e.message.startsWith("Truncated stream from model (stall:");
348
521
  }
@@ -387,7 +560,9 @@ async function collectSSEText(res) {
387
560
  tail = joined.slice(-8);
388
561
  };
389
562
  const throwIfDataStalled = () => {
390
- const budget = sseStallTimeoutMs();
563
+ // Header phase (no data yet) gets the generous header budget; once real
564
+ // SSE traffic exists the tighter chunk budget applies.
565
+ const budget = rawText.length === 0 ? sseHeaderTimeoutMs() : sseStallTimeoutMs();
391
566
  if (Date.now() - lastDataAt > budget) {
392
567
  throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
393
568
  }
@@ -406,7 +581,9 @@ async function collectSSEText(res) {
406
581
  catch (e) {
407
582
  if (isStallError(e)) {
408
583
  // Free the dead socket on the way out, then surface the stall
409
- // unchanged (permanent Truncated contract never retried).
584
+ // unchanged. Stalls are retryable upstream (one retry when data
585
+ // was already seen, full backoff for header stalls) — never
586
+ // swallowed here.
410
587
  try {
411
588
  await reader.cancel?.();
412
589
  }
@@ -1032,6 +1209,307 @@ export function parseGeminiJson(data) {
1032
1209
  }
1033
1210
  return result;
1034
1211
  }
1212
+ // Extract plain text from a history content value (string, or an OpenAI
1213
+ // parts array — text parts concatenate, anything else is skipped).
1214
+ function responsesTextOf(content) {
1215
+ if (typeof content === "string")
1216
+ return content;
1217
+ if (!Array.isArray(content))
1218
+ return "";
1219
+ let out = "";
1220
+ for (const p of content) {
1221
+ if (typeof p !== "object" || p === null)
1222
+ continue;
1223
+ if (typeof p["text"] === "string")
1224
+ out += p["text"];
1225
+ }
1226
+ return out;
1227
+ }
1228
+ // Convert one user content value to Responses input parts: plain strings
1229
+ // ride as string content; descriptor-bearing strings lower through
1230
+ // lowerOpenAIContent into input_text/input_image parts (strip mode: prose
1231
+ // markers as a single input_text part).
1232
+ function responsesUserContent(content, strip) {
1233
+ const lowered = lowerOpenAIContent("user", content, strip ? "strip" : "send");
1234
+ if (typeof lowered === "string")
1235
+ return lowered;
1236
+ const parts = [];
1237
+ for (const p of lowered) {
1238
+ if (p["type"] === "text" && typeof p["text"] === "string") {
1239
+ parts.push({ type: "input_text", text: p["text"] });
1240
+ }
1241
+ else if (p["type"] === "image_url" &&
1242
+ typeof p["image_url"] === "object" &&
1243
+ p["image_url"] !== null &&
1244
+ typeof p["image_url"]["url"] === "string") {
1245
+ parts.push({
1246
+ type: "input_image",
1247
+ image_url: p["image_url"]["url"],
1248
+ });
1249
+ }
1250
+ }
1251
+ return parts.length > 0 ? parts : "";
1252
+ }
1253
+ export function buildResponsesBody(history, model, opts) {
1254
+ const strip = opts?.stripMedia === true;
1255
+ const systems = [];
1256
+ const input = [];
1257
+ for (const m of history) {
1258
+ if (m.role === "system") {
1259
+ const lowered = lowerOpenAIContent("system", m.content, strip ? "strip" : "send");
1260
+ const text = responsesTextOf(lowered);
1261
+ if (text)
1262
+ systems.push(text);
1263
+ continue;
1264
+ }
1265
+ if (m.role === "user") {
1266
+ input.push({ role: "user", content: responsesUserContent(m.content, strip) });
1267
+ continue;
1268
+ }
1269
+ if (m.role === "tool") {
1270
+ input.push({
1271
+ type: "function_call_output",
1272
+ call_id: m.tool_call_id,
1273
+ output: strip ? stripMedia(m.content) : m.content,
1274
+ });
1275
+ continue;
1276
+ }
1277
+ // assistant: text rides as an assistant message, tool_calls as
1278
+ // function_call items (arguments stay a JSON string, as the API sends).
1279
+ const am = m;
1280
+ if (typeof am.content === "string" && am.content.length > 0) {
1281
+ input.push({ role: "assistant", content: am.content });
1282
+ }
1283
+ for (const tc of am.tool_calls ?? []) {
1284
+ input.push({
1285
+ type: "function_call",
1286
+ call_id: tc.id,
1287
+ name: tc.function.name,
1288
+ arguments: tc.function.arguments || "{}",
1289
+ });
1290
+ }
1291
+ }
1292
+ const body = { model, input };
1293
+ if (systems.length > 0)
1294
+ body.instructions = systems.join("\n\n");
1295
+ // Compaction path (includeTools:false) omits `tools` entirely — same
1296
+ // contract as every other kind ("no `tools` key", asserted in tests).
1297
+ if (opts?.includeTools !== false) {
1298
+ body.tools = toolDefs(opts?.includeUpdateGoal !== false).map((t) => ({
1299
+ type: "function",
1300
+ name: t.function.name,
1301
+ description: t.function.description,
1302
+ parameters: t.function.parameters,
1303
+ }));
1304
+ }
1305
+ return body;
1306
+ }
1307
+ // Server-authoritative effort rejection for the Responses `reasoning`
1308
+ // knob: a 400 naming it means this model/deployment has no such control.
1309
+ // Narrow (reasoning only) so unrelated 400s keep failing loudly.
1310
+ export function isResponsesEffortRejection(errorText) {
1311
+ return /reasoning/i.test(errorText);
1312
+ }
1313
+ // Build a ChatResult from one Responses `response` object (shared by the
1314
+ // streaming terminal event and the non-streaming JSON body):
1315
+ // output[] message items contribute output_text (refusals surface as text
1316
+ // so the turn never goes empty silently); function_call items become tool
1317
+ // calls (call_id first, id fallback — verified live shape carries both).
1318
+ // status "incomplete" (e.g. max_output_tokens cut the response) sets
1319
+ // `truncated` instead of throwing: the loop fails carried calls inline and
1320
+ // continues, same contract as chat finish_reason "length".
1321
+ export function parseResponsesObject(data) {
1322
+ const o = (typeof data === "object" && data !== null ? data : {});
1323
+ const output = o["output"];
1324
+ let text = "";
1325
+ const calls = [];
1326
+ if (Array.isArray(output)) {
1327
+ for (const item of output) {
1328
+ if (item["type"] === "message") {
1329
+ const content = item["content"];
1330
+ if (Array.isArray(content)) {
1331
+ for (const part of content) {
1332
+ if (part["type"] === "output_text" && typeof part["text"] === "string") {
1333
+ text += part["text"];
1334
+ }
1335
+ else if (part["type"] === "refusal" && typeof part["refusal"] === "string") {
1336
+ text += part["refusal"];
1337
+ }
1338
+ }
1339
+ }
1340
+ }
1341
+ else if (item["type"] === "function_call") {
1342
+ const name = typeof item["name"] === "string" ? item["name"] : "";
1343
+ if (!name)
1344
+ continue; // nameless-drop parity with every other kind
1345
+ const callId = typeof item["call_id"] === "string" && item["call_id"]
1346
+ ? item["call_id"]
1347
+ : typeof item["id"] === "string" && item["id"]
1348
+ ? item["id"]
1349
+ : `responses-${calls.length}`;
1350
+ let args = "{}";
1351
+ const raw = item["arguments"];
1352
+ if (typeof raw === "string")
1353
+ args = raw;
1354
+ else if (typeof raw === "object" && raw !== null) {
1355
+ try {
1356
+ args = JSON.stringify(raw);
1357
+ }
1358
+ catch {
1359
+ args = "{}";
1360
+ }
1361
+ }
1362
+ calls.push({ id: callId, type: "function", function: { name, arguments: args } });
1363
+ }
1364
+ }
1365
+ }
1366
+ if (calls.length === 0 && text.trim() === "") {
1367
+ throw new Error("Empty reply from model (unexpected payload).");
1368
+ }
1369
+ const result = {
1370
+ content: text.length > 0 ? text : null,
1371
+ tool_calls: calls.length > 0 ? calls : undefined,
1372
+ };
1373
+ if (o["status"] === "incomplete")
1374
+ result.truncated = true;
1375
+ const usage = o["usage"];
1376
+ if (usage && typeof usage === "object") {
1377
+ const details = usage["input_tokens_details"];
1378
+ const hit = openAIUsage(usage["input_tokens"], usage["output_tokens"], {
1379
+ read: details && typeof details === "object"
1380
+ ? details["cached_tokens"]
1381
+ : undefined,
1382
+ });
1383
+ if (hit)
1384
+ result.usage = hit;
1385
+ }
1386
+ const effort = o["reasoning"]?.["effort"];
1387
+ if (typeof effort === "string" && effort.trim().length > 0) {
1388
+ const label = effort.trim();
1389
+ result.reasoning = label.length > 24 ? `${label.slice(0, 24)}…` : label;
1390
+ }
1391
+ return result;
1392
+ }
1393
+ export async function readResponsesSSEMessage(res, opts) {
1394
+ const { rawText, events } = await collectSSEText(res);
1395
+ let fullText = "";
1396
+ let sawData = false;
1397
+ let streamingAnnounced = false;
1398
+ function announce(kind, name) {
1399
+ if (kind === "streaming" && !streamingAnnounced) {
1400
+ streamingAnnounced = true;
1401
+ }
1402
+ try {
1403
+ opts?.onPhase?.(kind, name ?? "");
1404
+ }
1405
+ catch {
1406
+ // ignore observer errors
1407
+ }
1408
+ }
1409
+ const slots = new Map();
1410
+ function slotAt(index) {
1411
+ let slot = slots.get(index);
1412
+ if (!slot) {
1413
+ slot = { callId: "", name: "", args: "" };
1414
+ slots.set(index, slot);
1415
+ }
1416
+ return slot;
1417
+ }
1418
+ for (const { event, data } of events) {
1419
+ if (!data || data === "[DONE]")
1420
+ continue;
1421
+ let evt;
1422
+ try {
1423
+ evt = JSON.parse(data);
1424
+ }
1425
+ catch {
1426
+ continue; // malformed JSON data line: skip, never crash
1427
+ }
1428
+ sawData = true;
1429
+ const o = evt;
1430
+ const type = typeof o["type"] === "string" ? o["type"] : "";
1431
+ if (type === "error") {
1432
+ const msg = typeof o["error"]?.["message"] === "string"
1433
+ ? o["error"]["message"]
1434
+ : typeof o["message"] === "string"
1435
+ ? o["message"]
1436
+ : "unknown streaming error";
1437
+ throw new Error(`Model error: ${msg}`.slice(0, 300));
1438
+ }
1439
+ if (type === "response.output_text.delta" && typeof o["delta"] === "string") {
1440
+ const frag = o["delta"];
1441
+ if (frag.length > 0) {
1442
+ fullText += frag;
1443
+ announce("streaming");
1444
+ try {
1445
+ opts?.onToken?.(fullText);
1446
+ }
1447
+ catch {
1448
+ // ignore observer errors
1449
+ }
1450
+ }
1451
+ continue;
1452
+ }
1453
+ if (type === "response.output_item.added") {
1454
+ const item = o["item"];
1455
+ if (item && item["type"] === "function_call") {
1456
+ const index = typeof o["output_index"] === "number" ? o["output_index"] : 0;
1457
+ const slot = slotAt(index);
1458
+ if (typeof item["call_id"] === "string")
1459
+ slot.callId = item["call_id"];
1460
+ if (typeof item["name"] === "string" && item["name"]) {
1461
+ slot.name = item["name"];
1462
+ try {
1463
+ opts?.onToolDelta?.(slot.name, index);
1464
+ }
1465
+ catch {
1466
+ // ignore
1467
+ }
1468
+ announce("tool", slot.name);
1469
+ }
1470
+ }
1471
+ continue;
1472
+ }
1473
+ if (type === "response.function_call_arguments.delta" && typeof o["delta"] === "string") {
1474
+ const index = typeof o["output_index"] === "number" ? o["output_index"] : 0;
1475
+ slotAt(index).args += o["delta"];
1476
+ continue;
1477
+ }
1478
+ // Terminal states carry the authoritative response object: the final
1479
+ // result is built from it (never from accumulated deltas), so delta
1480
+ // drift cannot corrupt tool arguments. `failed` throws; `completed`
1481
+ // and `incomplete` return (incomplete flags truncated downstream).
1482
+ if (type === "response.completed" || type === "response.incomplete") {
1483
+ return parseResponsesObject(o["response"]);
1484
+ }
1485
+ if (type === "response.failed") {
1486
+ const resp = o["response"];
1487
+ const err = resp?.["error"];
1488
+ const msg = (typeof err?.["message"] === "string" ? err["message"] : null) ??
1489
+ "response failed";
1490
+ throw new Error(`Model error: ${msg}`.slice(0, 300));
1491
+ }
1492
+ // created / in_progress / content_part.* / output_item.done / ping:
1493
+ // no model output — ignored (pings must not extend stall budgets, and
1494
+ // collectSSEText already only counts `data:` lines for data-silence).
1495
+ }
1496
+ // Tolerance: a body with no SSE data lines is really single-shot JSON
1497
+ // (the non-streaming response object).
1498
+ if (!sawData) {
1499
+ const candidate = rawText.trim();
1500
+ if (candidate.length > 0) {
1501
+ try {
1502
+ return parseResponsesObject(JSON.parse(candidate));
1503
+ }
1504
+ catch (e) {
1505
+ if (e instanceof Error && e.message.startsWith("Empty reply"))
1506
+ throw e;
1507
+ // not a response object either -> truncation error below
1508
+ }
1509
+ }
1510
+ }
1511
+ throw new Error("Truncated stream from model (connection aborted before response.completed).");
1512
+ }
1035
1513
  // ---- Models-list parsing per kind (pure; ANY failure -> fallback) ----
1036
1514
  function entryId(entry) {
1037
1515
  if (typeof entry === "string")
@@ -1126,11 +1604,14 @@ export async function validateProviderKey(id, apiKey, storedBaseURL) {
1126
1604
  return { ok: true };
1127
1605
  return { ok: false, error: `Gemini HTTP ${res.status}` };
1128
1606
  }
1129
- // OpenAI-kind: GET {base}/models with Bearer.
1607
+ // OpenAI-kind: GET {base}/models with Bearer. Zen also sends the
1608
+ // official-client identity (same gate family as the free-pool UA check).
1130
1609
  const url = modelsUrlForProvider(id, storedBaseURL);
1131
- const headers = {
1132
- Authorization: `Bearer ${apiKey}`,
1133
- };
1610
+ const headers = id === "opencode-zen"
1611
+ ? zenHeaders(apiKey)
1612
+ : {
1613
+ Authorization: `Bearer ${apiKey}`,
1614
+ };
1134
1615
  const res = await fetch(url, { headers });
1135
1616
  if (res.ok)
1136
1617
  return { ok: true };
@@ -51,6 +51,9 @@ export async function requestGoalVerdict(req) {
51
51
  endpointOverride: req.endpointOverride,
52
52
  disableTools: true,
53
53
  maxOutputTokens: req.maxOutputTokens ?? GOAL_JUDGE_MAX_TOKENS,
54
+ // The judge needs prose, not pixels — same strip contract as the
55
+ // compaction summary POST.
56
+ stripMedia: true,
54
57
  ...(req.signal ? { signal: req.signal } : {}),
55
58
  });
56
59
  // Totals keep accumulating: forward real judge usage when present.