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/zen.js CHANGED
@@ -5,11 +5,11 @@
5
5
  // different Zen request shapes and are out of scope.
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import * as path from "node:path";
8
- import { MAX_TOOL_STEPS, allToolDefinitions, getExtensionPromptHints, } from "./tools.js";
8
+ import { MAX_TOOL_STEPS, allToolDefinitions, chatToolDefinitions, getExtensionPromptHints, } from "./tools.js";
9
9
  import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
10
10
  import { discoverLocalProvider } from "./local-discovery.js";
11
- import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
12
- export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
11
+ import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, buildResponsesBody, isResponsesEffortRejection, isStallError, parseResponsesObject, readResponsesSSEMessage, readWithStall, sseHeaderTimeoutMs, sseStallTimeoutMs, zenHeaders, zenRequestId, } from "./adapters.js";
12
+ export { isStallError, readWithStall, sseHeaderTimeoutMs, sseStallTimeoutMs, ZEN_CLIENT_UA, zenHeaders, zenRequestId, zenSessionId } from "./adapters.js";
13
13
  import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
14
14
  import { splitSystemHead } from "./prompt-cache.js";
15
15
  import { SYSTEM_PROMPT } from "./system.js";
@@ -86,6 +86,19 @@ export function reasoningEffortParam(effort, _model) {
86
86
  return undefined;
87
87
  return normalized;
88
88
  }
89
+ // Wire value for the Responses `reasoning.effort` knob, or undefined when
90
+ // the param must be omitted (Auto, or an unknown effort string). Max maps
91
+ // to high — the deepest widely-supported level (same precedent as the
92
+ // Gemini thinkingLevel mapping in adapters.ts).
93
+ export function responsesEffortParam(effort) {
94
+ const normalized = normalizeEffort(effort);
95
+ if (normalized === "auto")
96
+ return undefined;
97
+ if (normalized === "max")
98
+ return "high";
99
+ return normalized;
100
+ }
101
+ import { historyHasMedia, isImageRejection, lowerOpenAIContent, } from "./media.js";
89
102
  export { CHARS_PER_TOKEN, createContextManager, estimateTokensForChars, historyChars, messageChars, } from "./context-manager.js";
90
103
  export { openTodoNeedles } from "./agent/gates.js";
91
104
  function finiteCount(value) {
@@ -170,9 +183,25 @@ export { isCancelError, LoopCancelledError } from "./agent/loop.js";
170
183
  // Retry-After) and weak-network throws both ride this policy. Cancellation
171
184
  // never retries. Delays grow exponentially under a 30s cap, so a fully dead
172
185
  // endpoint costs ~3.5min worst case before the turn fails loudly.
186
+ // 524/529 added for opencode parity (Cloudflare / overloaded gateways).
173
187
  export const MAX_RETRIES = 10;
174
- const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
188
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504, 524, 529]);
175
189
  const RETRY_AFTER_CAP_MS = 30_000;
190
+ // Per-model-call deadline (opencode options.timeout parity): bounds a single
191
+ // chat POST including streaming. Env ATOM_MODEL_TIMEOUT_MS, default 300s
192
+ // (matches opencode header/chunk defaults), max-clamped to 10min. Caller
193
+ // combines with the user-cancel signal per attempt via AbortSignal.any.
194
+ export const DEFAULT_MODEL_TIMEOUT_MS = 300_000;
195
+ export const MAX_MODEL_TIMEOUT_MS = 600_000;
196
+ export function modelTimeoutMs() {
197
+ const raw = process.env.ATOM_MODEL_TIMEOUT_MS;
198
+ if (raw !== undefined) {
199
+ const n = Number(raw.trim());
200
+ if (Number.isFinite(n) && n > 0)
201
+ return Math.min(Math.floor(n), MAX_MODEL_TIMEOUT_MS);
202
+ }
203
+ return DEFAULT_MODEL_TIMEOUT_MS;
204
+ }
176
205
  export function defaultSleep(ms) {
177
206
  return new Promise((resolve) => setTimeout(resolve, ms));
178
207
  }
@@ -220,15 +249,23 @@ function hasStreamBody(res) {
220
249
  return false;
221
250
  }
222
251
  }
223
- // Curated chat/completions-compatible models, verified from
224
- // https://opencode.ai/docs/zen. Used when the live model list cannot be
225
- // fetched or cannot confirm compatibility (network/auth/429/shape issues).
252
+ // Curated Zen models, verified from https://opencode.ai/docs/zen + the live
253
+ // /models list. Used when the live list cannot be fetched or cannot confirm
254
+ // compatibility (network/auth/429/shape issues). Covers both wire families:
255
+ // chat/completions (default transport) and responses-family (muse-spark-*,
256
+ // served from /responses — see isZenResponsesModel); the live filter below
257
+ // accepts ids from either set, the dispatcher routes by family.
226
258
  export const FALLBACK_MODELS = [
227
259
  "big-pickle",
228
260
  "mimo-v2.5-free",
229
261
  "ling-3.0-flash-fin-free",
230
262
  "nemotron-3-ultra-free",
231
263
  "nemotron-3.5-lightning-free",
264
+ "deepseek-v4-flash-free",
265
+ "muse-spark-1.3-contributor-free",
266
+ "muse-spark-1.2-contributor-free",
267
+ "muse-spark-1.3",
268
+ "muse-spark-1.2",
232
269
  "deepseek-v4-pro",
233
270
  "deepseek-v4-flash",
234
271
  "deepseek-v4-flash-vision-exp",
@@ -244,6 +281,26 @@ export const FALLBACK_MODELS = [
244
281
  "minimax-m2.7",
245
282
  "minimax-m3",
246
283
  ];
284
+ // Responses-family model prefixes: these ids are served from Zen's
285
+ // /responses endpoint (OpenAI Responses API shape), not /chat/completions.
286
+ // Verified from the endpoint column at https://opencode.ai/docs/zen.
287
+ const ZEN_RESPONSES_MODEL_PREFIXES = ["muse-spark-"];
288
+ /** True when a Zen model id must ride the Responses transport. */
289
+ export function isZenResponsesModel(model) {
290
+ return ZEN_RESPONSES_MODEL_PREFIXES.some((p) => model.startsWith(p));
291
+ }
292
+ export const RESPONSES_ENDPOINT_DEFAULT = "https://opencode.ai/zen/v1/responses";
293
+ // Derive the /responses endpoint from a chat/completions endpoint (mirrors
294
+ // modelsUrl above); falls back to the default when the shape is unknown.
295
+ export function responsesEndpointFor(chatEndpoint) {
296
+ const suffix = "/chat/completions";
297
+ if (chatEndpoint.endsWith(suffix)) {
298
+ return chatEndpoint.slice(0, -suffix.length) + "/responses";
299
+ }
300
+ if (chatEndpoint.endsWith("/responses"))
301
+ return chatEndpoint;
302
+ return RESPONSES_ENDPOINT_DEFAULT;
303
+ }
247
304
  export function endpointConfig() {
248
305
  return {
249
306
  endpoint: process.env.OPENCODE_ZEN_ENDPOINT ?? DEFAULT_ENDPOINT,
@@ -292,7 +349,11 @@ function entryId(entry) {
292
349
  export async function fetchModelsWithStatus(endpoint, apiKey) {
293
350
  try {
294
351
  const res = await fetch(modelsUrl(endpoint), {
295
- headers: { Authorization: `Bearer ${apiKey}` },
352
+ // Zen client identity: free `*-free` models are gated upstream on
353
+ // official-client headers (UA + `x-opencode-session`, else 429/400).
354
+ // Anonymous-safe: zenHeaders omits Authorization when no key instead
355
+ // of sending `Bearer `.
356
+ headers: zenHeaders(apiKey),
296
357
  });
297
358
  if (!res.ok)
298
359
  return { models: [...FALLBACK_MODELS], ok: false };
@@ -394,12 +455,14 @@ export async function readSSEMessage(res, opts) {
394
455
  // while a free-tier request sits queued).
395
456
  let lastDataAt = Date.now();
396
457
  // Fail fast when the stream flows (or idles) with no model output: same
397
- // permanent Truncated contract as a dead connection (passes through every
398
- // catch below untouched), same env knob as the per-read byte race. Checked
458
+ // Truncated contract as a dead connection (stalls are retryable upstream —
459
+ // one retry after data, backoff for header stalls), same env knobs as the
460
+ // per-read byte race. Header phase (no data yet) uses the generous header
461
+ // budget; established streams use the tighter chunk budget. Checked
399
462
  // after each drained chunk — legitimately slow generations keep emitting
400
463
  // `data:` lines, so only true silence trips it.
401
464
  function throwIfDataStalled() {
402
- const budget = sseStallTimeoutMs();
465
+ const budget = sawData ? sseStallTimeoutMs() : sseHeaderTimeoutMs();
403
466
  if (Date.now() - lastDataAt > budget) {
404
467
  throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
405
468
  }
@@ -782,7 +845,17 @@ opts, errorLabel = "Zen") {
782
845
  // Server-authoritative unsupported: when a 400 names the effort knob, the
783
846
  // flag below drops it and the loop retries without it (once per call).
784
847
  let effortDropped = false;
848
+ // Same contract for vision input (see src/media.ts): a 400 naming image
849
+ // input retries once with descriptors stripped to prose markers.
850
+ let mediaStripped = false;
785
851
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
852
+ // Per-attempt model deadline: bounds the whole POST+stream (opencode
853
+ // options.timeout parity). User cancel still wins; deadline aborts map
854
+ // to retryable stall errors below, never to LoopCancelledError.
855
+ const deadlineMs = modelTimeoutMs();
856
+ let timedOut = false;
857
+ let timeoutId = null;
858
+ const attemptController = new AbortController();
786
859
  try {
787
860
  throwIfCancelled(signal);
788
861
  try {
@@ -795,13 +868,27 @@ opts, errorLabel = "Zen") {
795
868
  ? undefined
796
869
  : reasoningEffortParam(opts?.reasoningEffort, model);
797
870
  const summaryOpts = opts;
871
+ const mediaMode = opts?.stripMedia === true || mediaStripped
872
+ ? "strip"
873
+ : "send";
798
874
  // Stable-prefix split (prompt-cache architecture): history[0]'s env
799
875
  // tail becomes its own system message so the stable head + tools stay
800
876
  // byte-identical across POSTs for implicit prefix caching. Consecutive
801
877
  // system messages concatenate on every OpenAI-protocol server, so this
802
878
  // is content-neutral. No env tail (tests, old saves) → history passes
803
879
  // through untouched, byte-identical to before.
804
- const messages = splitSystemHead(outgoingHistory);
880
+ // Media lowering runs after the split: histories without descriptors
881
+ // lower byte-identically (lowerOpenAIContent returns the string as-is).
882
+ const messages = splitSystemHead(outgoingHistory).map((m) => {
883
+ const c = m.content;
884
+ if (typeof c !== "string")
885
+ return m;
886
+ const lowered = lowerOpenAIContent(m.role, c, mediaMode);
887
+ // Identity means untouched (no descriptors): keep the original ref
888
+ // so media-free payloads stay byte-identical. Anything else
889
+ // (stripped string or parts array) replaces the content.
890
+ return lowered === c ? m : { ...m, content: lowered };
891
+ });
805
892
  const payload = {
806
893
  model,
807
894
  messages,
@@ -810,9 +897,15 @@ opts, errorLabel = "Zen") {
810
897
  // Compaction path only: tools disabled means NO `tools` key at all
811
898
  // (asserted in tests); the normal loop always sends the schema —
812
899
  // builtins plus extension-registered custom tools, so the model can
813
- // discover and call them exactly like builtins.
900
+ // discover and call them exactly like builtins. update_goal rides
901
+ // along only for live goal turns (includeUpdateGoal, set per POST by
902
+ // the runAgenticLoop* entry points) — otherwise the model cannot
903
+ // misuse what it cannot see.
814
904
  if (!summaryOpts?.disableTools) {
815
- payload["tools"] = allToolDefinitions();
905
+ payload["tools"] =
906
+ opts?.includeUpdateGoal === false
907
+ ? chatToolDefinitions(false)
908
+ : allToolDefinitions();
816
909
  }
817
910
  // Compaction path only: cap output (openai-chat kind uses max_tokens).
818
911
  if (typeof summaryOpts?.maxOutputTokens === "number" &&
@@ -822,6 +915,20 @@ opts, errorLabel = "Zen") {
822
915
  }
823
916
  if (effortParam !== undefined)
824
917
  payload["reasoning_effort"] = effortParam;
918
+ // Base headers per provider: Zen sends the official-client identity
919
+ // (`User-Agent: opencode/*` + `x-opencode-session`/`x-opencode-request`)
920
+ // so free `*-free` models get quota instead of 429 FreeUsageLimitError
921
+ // or 400 MissingSessionID. Session is stable per process, request is
922
+ // fresh per POST attempt. Other openai-chat providers keep the legacy
923
+ // shape byte-identical. Anonymous-safe everywhere: no key omits
924
+ // Authorization (never `Bearer `). Pre-request hooks below can still
925
+ // override/delete any key (string sets, null/undefined deletes).
926
+ const baseHeaders = hookProvider === "opencode-zen"
927
+ ? zenHeaders(apiKey, { requestId: zenRequestId() })
928
+ : {
929
+ "Content-Type": "application/json",
930
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
931
+ };
825
932
  // Pre-request hooks (ticket 08) run per POST attempt: payload
826
933
  // replacement must be a record (else ignored — downstream JSON/fetch
827
934
  // handling is never bypassed); header merge honors deletions.
@@ -833,18 +940,41 @@ opts, errorLabel = "Zen") {
833
940
  model,
834
941
  url: endpoint,
835
942
  payload,
836
- headers: {
837
- "Content-Type": "application/json",
838
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
839
- },
943
+ headers: { ...baseHeaders },
840
944
  })
841
945
  : {
842
946
  payload,
843
- headers: {
844
- "Content-Type": "application/json",
845
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
846
- },
947
+ headers: { ...baseHeaders },
847
948
  };
949
+ // Forward user cancel into the per-attempt controller, then arm the
950
+ // model deadline on the same controller.
951
+ if (signal) {
952
+ if (signal.aborted)
953
+ throw new LoopCancelledError();
954
+ signal.addEventListener("abort", () => {
955
+ try {
956
+ attemptController.abort();
957
+ }
958
+ catch {
959
+ // ignore
960
+ }
961
+ }, { once: true });
962
+ }
963
+ timeoutId = setTimeout(() => {
964
+ timedOut = true;
965
+ try {
966
+ attemptController.abort();
967
+ }
968
+ catch {
969
+ // ignore
970
+ }
971
+ }, deadlineMs);
972
+ try {
973
+ timeoutId.unref?.();
974
+ }
975
+ catch {
976
+ // ignore — environments without unref proceed regardless
977
+ }
848
978
  const res = await fetch(endpoint, {
849
979
  method: "POST",
850
980
  // Anonymous-capable providers (Kilo free models) omit Authorization
@@ -853,7 +983,7 @@ opts, errorLabel = "Zen") {
853
983
  // behavior is unchanged.
854
984
  headers: outgoing.headers,
855
985
  body: JSON.stringify(outgoing.payload),
856
- ...(signal ? { signal } : {}),
986
+ signal: attemptController.signal,
857
987
  });
858
988
  // Post-response observers (ticket 08): every resolved POST (ok and
859
989
  // HTTP-error alike), fail-open — never break the turn. Zero-cost when
@@ -871,6 +1001,27 @@ opts, errorLabel = "Zen") {
871
1001
  }
872
1002
  if (!res.ok) {
873
1003
  const errText = await safeErrorText(res);
1004
+ // The server is the authority on vision support: a 400 naming
1005
+ // image input means this model/deployment takes no images — warn,
1006
+ // strip descriptors to markers, and retry without them (once per
1007
+ // call). Checked before effort so a joint rejection still strips.
1008
+ if (res.status === 400 &&
1009
+ !mediaStripped &&
1010
+ opts?.stripMedia !== true &&
1011
+ historyHasMedia(outgoingHistory) &&
1012
+ isImageRejection(errText)) {
1013
+ mediaStripped = true;
1014
+ try {
1015
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
1016
+ }
1017
+ catch {
1018
+ // ignore observer errors
1019
+ }
1020
+ throwIfCancelled(signal);
1021
+ if (timeoutId)
1022
+ clearTimeout(timeoutId);
1023
+ continue;
1024
+ }
874
1025
  // The server is the authority on effort support: a 400 naming the
875
1026
  // knob means this model/deployment has no such control — warn,
876
1027
  // drop the knob, and retry without it (setting kept). Any other
@@ -887,11 +1038,16 @@ opts, errorLabel = "Zen") {
887
1038
  // ignore observer errors
888
1039
  }
889
1040
  throwIfCancelled(signal);
1041
+ if (timeoutId)
1042
+ clearTimeout(timeoutId);
890
1043
  continue;
891
1044
  }
892
1045
  const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
893
- if (!RETRYABLE_STATUS.has(res.status))
1046
+ if (!RETRYABLE_STATUS.has(res.status)) {
1047
+ if (timeoutId)
1048
+ clearTimeout(timeoutId);
894
1049
  throw err;
1050
+ }
895
1051
  if (attempt < MAX_RETRIES) {
896
1052
  throwIfCancelled(signal);
897
1053
  const delay = getRetryDelay(attempt, res);
@@ -901,11 +1057,15 @@ opts, errorLabel = "Zen") {
901
1057
  catch {
902
1058
  // ignore
903
1059
  }
1060
+ if (timeoutId)
1061
+ clearTimeout(timeoutId);
904
1062
  await sleep(delay);
905
1063
  throwIfCancelled(signal);
906
1064
  lastError = err;
907
1065
  continue;
908
1066
  }
1067
+ if (timeoutId)
1068
+ clearTimeout(timeoutId);
909
1069
  throw err;
910
1070
  }
911
1071
  if (!hasStreamBody(res)) {
@@ -950,26 +1110,287 @@ opts, errorLabel = "Zen") {
950
1110
  const reasoning = parseReasoningLabel(msg);
951
1111
  if (reasoning !== undefined)
952
1112
  result.reasoning = reasoning;
1113
+ if (timeoutId)
1114
+ clearTimeout(timeoutId);
953
1115
  return result;
954
1116
  }
955
- return await readSSEMessage(res, opts);
1117
+ try {
1118
+ const streamed = await readSSEMessage(res, { ...opts, signal: attemptController.signal });
1119
+ if (timeoutId)
1120
+ clearTimeout(timeoutId);
1121
+ return streamed;
1122
+ }
1123
+ catch (streamErr) {
1124
+ if (timeoutId)
1125
+ clearTimeout(timeoutId);
1126
+ throw streamErr;
1127
+ }
956
1128
  }
957
1129
  catch (e) {
1130
+ if (timeoutId)
1131
+ clearTimeout(timeoutId);
1132
+ // User cancel wins over deadline: only the user's own signal maps to
1133
+ // LoopCancelledError. A deadline abort surfaces as a retryable stall.
1134
+ if (signal?.aborted)
1135
+ throw new LoopCancelledError();
1136
+ if (timedOut && !(e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))) {
1137
+ const deadlineErr = new Error(`Truncated stream from model (stall: no output for ${deadlineMs}ms — model deadline; resend to retry).`);
1138
+ if (attempt < MAX_RETRIES) {
1139
+ const delay = getRetryDelay(attempt, undefined);
1140
+ try {
1141
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (model deadline ${deadlineMs}ms)`);
1142
+ }
1143
+ catch {
1144
+ // ignore
1145
+ }
1146
+ try {
1147
+ await sleep(delay);
1148
+ }
1149
+ catch {
1150
+ // a failing sleep must not mask the original error
1151
+ }
1152
+ lastError = deadlineErr;
1153
+ throwIfCancelled(signal);
1154
+ continue;
1155
+ }
1156
+ throw deadlineErr;
1157
+ }
958
1158
  // Cancellations (Ctrl+C / AbortSignal) are final: never retry, never
959
1159
  // reframe — propagate so the caller can roll back + show (cancelled).
960
- if (isCancelError(e) || signal?.aborted)
1160
+ if (isCancelError(e))
961
1161
  throw new LoopCancelledError();
962
1162
  // HTTP failures already handled above (retry or fail-fast): rethrow
963
1163
  // without treating them as retryable network errors.
964
1164
  if (e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))
965
1165
  throw e;
966
- // Parsing/validation failures (empty reply, truncation) are permanent:
967
- // never retry, surface immediately so the caller can roll back.
1166
+ // Empty replies are permanent: never retry, surface immediately.
1167
+ if (e instanceof Error && e.message.startsWith("Empty reply")) {
1168
+ throw e;
1169
+ }
1170
+ // Truncated streams: stall timeouts ride the normal network backoff
1171
+ // (opencode parity — SSE read timed out is retryable); an abort with
1172
+ // zero data gets exactly one immediate retry, then fails loudly.
1173
+ if (e instanceof Error && e.message.startsWith("Truncated stream")) {
1174
+ if (!isStallError(e) && attempt >= 1)
1175
+ throw e;
1176
+ if (attempt < MAX_RETRIES) {
1177
+ const delay = getRetryDelay(attempt, undefined);
1178
+ try {
1179
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e.message.slice(0, 120)})`);
1180
+ }
1181
+ catch {
1182
+ // ignore
1183
+ }
1184
+ try {
1185
+ await sleep(delay);
1186
+ }
1187
+ catch {
1188
+ // a failing sleep must not mask the original error
1189
+ }
1190
+ lastError = e;
1191
+ throwIfCancelled(signal);
1192
+ continue;
1193
+ }
1194
+ throw e;
1195
+ }
1196
+ // Anything else is a network-level throw: retry when attempts remain.
1197
+ if (attempt < MAX_RETRIES) {
1198
+ const delay = getRetryDelay(attempt, undefined);
1199
+ try {
1200
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e instanceof Error ? e.message : String(e)})`);
1201
+ }
1202
+ catch {
1203
+ // ignore
1204
+ }
1205
+ try {
1206
+ await sleep(delay);
1207
+ }
1208
+ catch {
1209
+ // a failing sleep must not mask the original error
1210
+ }
1211
+ lastError = e;
1212
+ continue;
1213
+ }
1214
+ throw e;
1215
+ }
1216
+ }
1217
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1218
+ }
1219
+ // Responses-family chat POST with tools attached (tool_choice omitted, so
1220
+ // the default auto applies). Sends {model, instructions?, input, stream:true}
1221
+ // plus `reasoning: {effort}` whenever opts.reasoningEffort is non-Auto (see
1222
+ // responsesEffortParam) — for every responses model on opencode-zen.
1223
+ // A 400 naming the knob means the model truly lacks it: warn once via
1224
+ // onWarning and retry without it. Parses the Responses SSE event stream
1225
+ // (see readResponsesSSEMessage in adapters.ts).
1226
+ // Retry/rollback/hook/error-label contract mirrors chatCompletion exactly:
1227
+ // network throws and HTTP 429/500/502/503/504 retry up to MAX_RETRIES with
1228
+ // the same backoff; other 4xx fail fast as `{errorLabel} HTTP {status}`;
1229
+ // empty replies and truncated streams (no response.completed) throw
1230
+ // permanently; status "incomplete" returns with `truncated: true`.
1231
+ // Callers must roll back the user turn on failure (see App submit).
1232
+ export async function chatCompletionResponses(endpoint, apiKey, model, history,
1233
+ // providerId (ticket 08): hook attribution for the responses transport —
1234
+ // the dispatcher passes its provider id, direct callers omit it and
1235
+ // default to "opencode-zen". Optional, wire-compatible.
1236
+ opts, errorLabel = "Zen") {
1237
+ const sleep = opts?.sleep ?? defaultSleep;
1238
+ const signal = opts?.signal ?? null;
1239
+ const hookProvider = opts?.providerId ?? "opencode-zen";
1240
+ const contextHooks = contextTransformers();
1241
+ const outgoingHistory = contextHooks.length > 0 ? await applyContextTransform(contextHooks, history) : history;
1242
+ let lastError = null;
1243
+ // Server-authoritative unsupported: when a 400 names the reasoning knob,
1244
+ // the flag below drops it and the loop retries without it (once per call).
1245
+ let effortDropped = false;
1246
+ // Same contract for vision input: a 400 naming image input retries once
1247
+ // with descriptors stripped to prose markers.
1248
+ let mediaStripped = false;
1249
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1250
+ try {
1251
+ throwIfCancelled(signal);
1252
+ try {
1253
+ opts?.onPhase?.("thinking");
1254
+ }
1255
+ catch {
1256
+ // ignore observer errors
1257
+ }
1258
+ const effortParam = effortDropped
1259
+ ? undefined
1260
+ : responsesEffortParam(opts?.reasoningEffort);
1261
+ const summaryOpts = opts;
1262
+ const mediaMode = opts?.stripMedia === true || mediaStripped
1263
+ ? "strip"
1264
+ : "send";
1265
+ const converted = buildResponsesBody(outgoingHistory, model, {
1266
+ // Compaction path only: tools disabled means NO `tools` key at all;
1267
+ // the normal loop always sends the schema so the model can discover
1268
+ // and call tools exactly like builtins. update_goal rides along only
1269
+ // for live goal turns (includeUpdateGoal); otherwise hidden.
1270
+ includeTools: summaryOpts?.disableTools === true ? false : undefined,
1271
+ includeUpdateGoal: opts?.includeUpdateGoal,
1272
+ stripMedia: mediaMode === "strip" ? true : undefined,
1273
+ });
1274
+ // mediaMode is recomputed per attempt, so the strip retry below
1275
+ // rebuilds the body in strip mode on its next pass through the loop.
1276
+ const payload = {
1277
+ model: converted.model,
1278
+ ...(converted.instructions !== undefined
1279
+ ? { instructions: converted.instructions }
1280
+ : {}),
1281
+ input: converted.input,
1282
+ stream: true,
1283
+ };
1284
+ if (converted.tools !== undefined)
1285
+ payload["tools"] = converted.tools;
1286
+ // Compaction path only: cap output (responses kind uses
1287
+ // max_output_tokens, same name as the cap option).
1288
+ if (typeof summaryOpts?.maxOutputTokens === "number" &&
1289
+ Number.isFinite(summaryOpts.maxOutputTokens) &&
1290
+ summaryOpts.maxOutputTokens > 0) {
1291
+ payload["max_output_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
1292
+ }
1293
+ if (effortParam !== undefined)
1294
+ payload["reasoning"] = { effort: effortParam };
1295
+ const preHooks = beforeRequestInterceptors();
1296
+ const outgoing = preHooks.length > 0
1297
+ ? await applyBeforeRequest(preHooks, {
1298
+ provider: hookProvider,
1299
+ model,
1300
+ url: endpoint,
1301
+ payload,
1302
+ headers: { ...zenHeaders(apiKey, { requestId: zenRequestId() }) },
1303
+ })
1304
+ : {
1305
+ payload,
1306
+ headers: { ...zenHeaders(apiKey, { requestId: zenRequestId() }) },
1307
+ };
1308
+ const res = await fetch(endpoint, {
1309
+ method: "POST",
1310
+ // Anonymous-capable: zenHeaders omits Authorization when no key —
1311
+ // never an empty `Bearer `.
1312
+ headers: outgoing.headers,
1313
+ body: JSON.stringify(outgoing.payload),
1314
+ ...(signal ? { signal } : {}),
1315
+ });
1316
+ const postHooks = afterResponseObservers();
1317
+ if (postHooks.length > 0) {
1318
+ await notifyAfterResponse(postHooks, {
1319
+ provider: hookProvider,
1320
+ model,
1321
+ url: endpoint,
1322
+ status: res.status,
1323
+ ok: res.ok,
1324
+ headers: snapshotResponseHeaders(res),
1325
+ });
1326
+ }
1327
+ if (!res.ok) {
1328
+ const errText = await safeErrorText(res);
1329
+ if (res.status === 400 &&
1330
+ !mediaStripped &&
1331
+ opts?.stripMedia !== true &&
1332
+ historyHasMedia(outgoingHistory) &&
1333
+ isImageRejection(errText)) {
1334
+ mediaStripped = true;
1335
+ try {
1336
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
1337
+ }
1338
+ catch {
1339
+ // ignore observer errors
1340
+ }
1341
+ throwIfCancelled(signal);
1342
+ continue;
1343
+ }
1344
+ if (res.status === 400 &&
1345
+ effortParam !== undefined &&
1346
+ !effortDropped &&
1347
+ isResponsesEffortRejection(errText)) {
1348
+ effortDropped = true;
1349
+ try {
1350
+ opts?.onWarning?.(`reasoning effort "${effortParam}" is not supported by ${model} — continuing without it`);
1351
+ }
1352
+ catch {
1353
+ // ignore observer errors
1354
+ }
1355
+ throwIfCancelled(signal);
1356
+ continue;
1357
+ }
1358
+ const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
1359
+ if (!RETRYABLE_STATUS.has(res.status))
1360
+ throw err;
1361
+ if (attempt < MAX_RETRIES) {
1362
+ throwIfCancelled(signal);
1363
+ const delay = getRetryDelay(attempt, res);
1364
+ try {
1365
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (HTTP ${res.status})`);
1366
+ }
1367
+ catch {
1368
+ // ignore
1369
+ }
1370
+ await sleep(delay);
1371
+ throwIfCancelled(signal);
1372
+ lastError = err;
1373
+ continue;
1374
+ }
1375
+ throw err;
1376
+ }
1377
+ if (!hasStreamBody(res)) {
1378
+ // Non-streaming responses object (no chat `choices` shape here —
1379
+ // parseResponsesObject reads the Responses `output` array).
1380
+ const data = await res.json();
1381
+ return parseResponsesObject(data);
1382
+ }
1383
+ return await readResponsesSSEMessage(res, opts);
1384
+ }
1385
+ catch (e) {
1386
+ if (isCancelError(e) || signal?.aborted)
1387
+ throw new LoopCancelledError();
1388
+ if (e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))
1389
+ throw e;
968
1390
  if (e instanceof Error &&
969
1391
  (e.message.startsWith("Empty reply") || e.message.startsWith("Truncated stream"))) {
970
1392
  throw e;
971
1393
  }
972
- // Anything else is a network-level throw: retry when attempts remain.
973
1394
  if (attempt < MAX_RETRIES) {
974
1395
  const delay = getRetryDelay(attempt, undefined);
975
1396
  try {
@@ -1007,6 +1428,20 @@ opts, errorLabel = "Zen") {
1007
1428
  // on display). A length-truncated response (`finish_reason: "length"`) does
1008
1429
  // not throw: the loop fails each carried tool call inline with a repair
1009
1430
  // error and continues to the next model round.
1431
+ // Per-POST goal-tool visibility: update_goal rides the schema only while a
1432
+ // live goal turn is engaged (guarded — a throwing accessor reads as no
1433
+ // goal, exactly like the loop's readLiveGoal). Evaluated per POST so a goal
1434
+ // set, paused, or cleared mid-turn reshapes the very next schema; callers
1435
+ // without a goal hook (compaction, web, tests) read as no-goal and send the
1436
+ // legacy full surface only when they leave includeUpdateGoal undefined.
1437
+ function isGoalTurnLive(opts) {
1438
+ try {
1439
+ return opts?.goal?.getGoal?.()?.active === true;
1440
+ }
1441
+ catch {
1442
+ return false;
1443
+ }
1444
+ }
1010
1445
  export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
1011
1446
  return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
1012
1447
  onToken: o?.onToken,
@@ -1017,6 +1452,7 @@ export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
1017
1452
  sleep: o?.sleep,
1018
1453
  reasoningEffort: o?.reasoningEffort,
1019
1454
  signal: o?.signal,
1455
+ includeUpdateGoal: isGoalTurnLive(opts),
1020
1456
  }), history, opts);
1021
1457
  }
1022
1458
  // AGENTS.md loading: <cwd>/AGENTS.md (or $OPENCODE_AGENTS_PATH when set)
@@ -1068,6 +1504,9 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1068
1504
  // Same server-authoritative unsupported contract as the openai-chat path:
1069
1505
  // a 400 naming the thinking knob drops it for the rest of the call.
1070
1506
  let anthropicEffortDropped = false;
1507
+ // Vision fallback (see src/media.ts): a 400 naming image input retries
1508
+ // once with descriptors stripped to markers.
1509
+ let anthropicMediaStripped = false;
1071
1510
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1072
1511
  try {
1073
1512
  throwIfCancelled(signal);
@@ -1080,6 +1519,8 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1080
1519
  const summaryOpts = opts;
1081
1520
  const base = buildAnthropicBody(outgoingHistory, model, {
1082
1521
  includeTools: !summaryOpts?.disableTools,
1522
+ includeUpdateGoal: opts?.includeUpdateGoal !== false,
1523
+ stripMedia: opts?.stripMedia === true || anthropicMediaStripped,
1083
1524
  });
1084
1525
  const body = { ...base, stream: true };
1085
1526
  // Compaction cap (anthropic kind uses max_tokens; default is already
@@ -1131,6 +1572,23 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1131
1572
  }
1132
1573
  if (!res.ok) {
1133
1574
  const errText = await safeErrorText(res);
1575
+ // Vision fallback (see src/media.ts): a 400 naming image input
1576
+ // retries once with descriptors stripped to markers.
1577
+ if (res.status === 400 &&
1578
+ !anthropicMediaStripped &&
1579
+ opts?.stripMedia !== true &&
1580
+ historyHasMedia(outgoingHistory) &&
1581
+ isImageRejection(errText)) {
1582
+ anthropicMediaStripped = true;
1583
+ try {
1584
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
1585
+ }
1586
+ catch {
1587
+ // ignore observer errors
1588
+ }
1589
+ throwIfCancelled(signal);
1590
+ continue;
1591
+ }
1134
1592
  if (res.status === 400 &&
1135
1593
  anthropicBudget !== undefined &&
1136
1594
  !anthropicEffortDropped &&
@@ -1215,6 +1673,9 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1215
1673
  // Same server-authoritative unsupported contract as the other paths: a
1216
1674
  // 400 naming the thinking knob drops it for the rest of the call.
1217
1675
  let geminiEffortDropped = false;
1676
+ // Vision fallback (see src/media.ts): a 400 naming image input retries
1677
+ // once with descriptors stripped to markers.
1678
+ let geminiMediaStripped = false;
1218
1679
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1219
1680
  try {
1220
1681
  throwIfCancelled(signal);
@@ -1227,11 +1688,13 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1227
1688
  const summaryOpts = opts;
1228
1689
  const body = buildGeminiBody(outgoingHistory, model, {
1229
1690
  includeTools: !summaryOpts?.disableTools,
1691
+ includeUpdateGoal: opts?.includeUpdateGoal !== false,
1230
1692
  ...(typeof summaryOpts?.maxOutputTokens === "number" &&
1231
1693
  Number.isFinite(summaryOpts.maxOutputTokens) &&
1232
1694
  summaryOpts.maxOutputTokens > 0
1233
1695
  ? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
1234
1696
  : {}),
1697
+ stripMedia: opts?.stripMedia === true || geminiMediaStripped,
1235
1698
  });
1236
1699
  // /effort maps to the native thinkingLevel (Auto omits it; Max rides
1237
1700
  // high, the deepest level the API offers). Merged into
@@ -1278,6 +1741,23 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1278
1741
  }
1279
1742
  if (!res.ok) {
1280
1743
  const errText = await safeErrorText(res);
1744
+ // Vision fallback (see src/media.ts): a 400 naming image input
1745
+ // retries once with descriptors stripped to markers.
1746
+ if (res.status === 400 &&
1747
+ !geminiMediaStripped &&
1748
+ opts?.stripMedia !== true &&
1749
+ historyHasMedia(outgoingHistory) &&
1750
+ isImageRejection(errText)) {
1751
+ geminiMediaStripped = true;
1752
+ try {
1753
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
1754
+ }
1755
+ catch {
1756
+ // ignore observer errors
1757
+ }
1758
+ throwIfCancelled(signal);
1759
+ continue;
1760
+ }
1281
1761
  if (res.status === 400 &&
1282
1762
  geminiLevel !== undefined &&
1283
1763
  !geminiEffortDropped &&
@@ -1387,11 +1867,13 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1387
1867
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1388
1868
  }
1389
1869
  // Provider dispatcher: openai-chat reuses chatCompletion with the provider's
1390
- // error label; anthropic/gemini go through their adapters. Effort rides
1391
- // every kind: reasoning_effort on openai-chat (all providers, all models),
1392
- // thinking budgets on anthropic-messages, thinkingLevel on gemini-generate.
1393
- // Auto omits the knob; a model that truly lacks it 400s and the transports
1394
- // above retry once without it.
1870
+ // error label; anthropic/gemini go through their adapters; Zen
1871
+ // responses-family models (muse-spark-*) ride chatCompletionResponses.
1872
+ // Effort rides every kind: reasoning_effort on openai-chat (all providers,
1873
+ // all models), reasoning.effort on responses, thinking budgets on
1874
+ // anthropic-messages, thinkingLevel on gemini-generate. Auto omits the knob
1875
+ // everywhere; a model that truly lacks it 400s and the transports above
1876
+ // retry once without it.
1395
1877
  export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
1396
1878
  const def = getProvider(provider);
1397
1879
  if (!def)
@@ -1430,9 +1912,17 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1430
1912
  ...effortOpts,
1431
1913
  // Compaction path only (undefined for the normal loop → tools sent).
1432
1914
  ...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
1915
+ // Goal-tool visibility (undefined for compaction/summary callers →
1916
+ // legacy full surface; the loop entry points always set it per POST).
1917
+ ...(opts?.includeUpdateGoal !== undefined
1918
+ ? { includeUpdateGoal: opts.includeUpdateGoal }
1919
+ : {}),
1433
1920
  ...(opts?.maxOutputTokens !== undefined
1434
1921
  ? { maxOutputTokens: opts.maxOutputTokens }
1435
1922
  : {}),
1923
+ // Media strip (compaction/summarization callers set it; the normal
1924
+ // loop leaves it undefined → images expand natively).
1925
+ ...(opts?.stripMedia !== undefined ? { stripMedia: opts.stripMedia } : {}),
1436
1926
  };
1437
1927
  // Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
1438
1928
  // retry, effort with server-rejection fallback) with its registry
@@ -1446,6 +1936,13 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1446
1936
  throw normalizeKiloChatError(e, apiKey);
1447
1937
  }
1448
1938
  }
1939
+ // Zen responses-family (muse-spark-*, free contributor tiers included):
1940
+ // same chatOpts surface (effort/tools/compaction/media/hooks), Responses
1941
+ // wire shape + /responses endpoint. Only opencode-zen serves this family;
1942
+ // every other provider keeps the chat path below byte-identical.
1943
+ if (provider === "opencode-zen" && isZenResponsesModel(model)) {
1944
+ return chatCompletionResponses(responsesEndpointFor(endpoint), apiKey, model, history, chatOpts, providerLabel(provider));
1945
+ }
1449
1946
  return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
1450
1947
  }
1451
1948
  export { evaluateTurnEnd, isCodePath, MAX_TODO_ROUNDS, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
@@ -1486,6 +1983,7 @@ export async function runAgenticLoopForProvider(provider, apiKey, model, history
1486
1983
  reasoningEffort: o?.reasoningEffort,
1487
1984
  baseURL: opts?.baseURL,
1488
1985
  endpointOverride: opts?.endpointOverride,
1986
+ includeUpdateGoal: isGoalTurnLive(opts),
1489
1987
  }), history, opts);
1490
1988
  }
1491
1989
  // Per-provider model list: live list per kind with curated fallback on ANY