atom-agent 1.2.0 → 1.3.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 (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
package/dist/zen.js CHANGED
@@ -5,14 +5,20 @@
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, TOOL_DEFINITIONS, } from "./tools.js";
8
+ import { MAX_TOOL_STEPS, allToolDefinitions, 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_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, 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, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
12
12
  export { isStallError, readWithStall, sseStallTimeoutMs } 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";
16
+ // Provider hooks (ticket 08): extension context/pre-request/post-response
17
+ // hooks fire per POST in the three transports below (openai-chat,
18
+ // anthropic-messages, gemini-generate), so every provider kind is covered
19
+ // through the chatCompletionForProvider dispatcher. All apply/notify helpers
20
+ // never throw (fail-open), so hooks can never break the turn.
21
+ import { afterResponseObservers, applyBeforeRequest, applyContextTransform, beforeRequestInterceptors, contextTransformers, notifyAfterResponse, snapshotResponseHeaders, } from "./tools/provider-hooks.js";
16
22
  export { MAX_TOOL_STEPS };
17
23
  // Re-exported so existing `SYSTEM_PROMPT` imports keep working; the
18
24
  // owner-editable source of truth lives in src/system.ts.
@@ -22,61 +28,66 @@ export const MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
22
28
  // Task 5 default: strongest tool-reliable chat/completions default available,
23
29
  // verified against the live /models list + https://opencode.ai/docs/zen on
24
30
  // 2026-09-08 (endpoint chat/completions, Tool Calls support, not deprecated,
25
- // in REASONING_EFFORT_SUPPORTED_MODELS, verified 1M context window). Free
26
- // models (big-pickle etc.) stay in FALLBACK_MODELS, selectable via /model.
31
+ // verified 1M context window). Free models (big-pickle etc.) stay in
32
+ // FALLBACK_MODELS, selectable via /model.
27
33
  export const DEFAULT_MODEL = "deepseek-v4-pro";
28
34
  export const AGENTS_CHAR_CAP = 12 * 1024;
29
- // ---- Conversation-history budget (deterministic, no extra model calls) ----
30
- // Long sessions can't bloat context, cost, and latency: the shared loop core
31
- // trims history to BOTH caps before every POST (uniform across providers).
32
- // The caps themselves live in the ContextManager module (re-exported here so
33
- // existing importers keep working); precedence per knob stays env override
34
- // (when valid) → project atom.json → global atom.json → compiled default:
35
- // - ATOM_MAX_HISTORY_MESSAGES, clamped to 10–1000 (default 100)
36
- // - ATOM_MAX_HISTORY_CHARS, clamped to 10_000–2_000_000 (default 200_000)
35
+ // ---- Conversation history: uncapped ----
36
+ // Long sessions ride on compaction, not truncation: the shared loop core
37
+ // sends the full history on every POST (uniform across providers) and
38
+ // auto-compact at ~83% of the verified window is the only pressure valve.
39
+ // There are no message/char caps and no trim step.
37
40
  export { toolStepBudget } from "./agent/loop.js";
38
- // Reasoning effort (session state in the App, default "default").
39
- // Wire values are exactly default/low/medium/high/max. "default" never
40
- // sends a param. NOTE: the user asked for `xhigh`, but the only VERIFIED
41
- // valid values (OpenCode Zen docs/changelog: Thinking Effort
42
- // Default/Max/High/Medium/Low, sent as `reasoning_effort`) use `Max`, so
43
- // the top setting is `Max`, sent on the wire as `max`.
41
+ // Reasoning effort (session state in the App, default "auto").
42
+ // Wire values are low/medium/high/max. "auto" never sends a param: it lets
43
+ // the model decide. The top setting is `Max`, sent on the wire as `max`.
44
+ // Support is assumed for every model on every provider kind — the server is
45
+ // authoritative: a model that truly lacks the knob fails the POST with a
46
+ // 400 naming the effort param, and the transports below retry once without
47
+ // it (see isEffortRejection in adapters.ts). Nothing is preemptively gated
48
+ // by model name, so "(unsupported)" only ever reflects an actual rejection.
44
49
  export const EFFORT_OPTIONS = [
45
- "default",
50
+ "auto",
46
51
  "low",
47
52
  "medium",
48
53
  "high",
49
54
  "max",
50
55
  ];
51
- // Verified-support set for `reasoning_effort`: the chat/completions-family
52
- // models Zen documents Thinking Effort for. Any other model omits the
53
- // param (setting kept, warning shown, status shows "(unsupported)").
54
- export const REASONING_EFFORT_SUPPORTED_MODELS = new Set([
55
- "kimi-k2.5",
56
- "kimi-k2.6",
57
- "glm-5.1",
58
- "glm-5.2",
59
- "deepseek-v4-pro",
60
- "deepseek-v4-flash",
61
- ]);
62
- export function isEffortSupported(model) {
63
- return REASONING_EFFORT_SUPPORTED_MODELS.has(model);
56
+ // Canonicalize a stored/picked effort value. "default" is the pre-auto name
57
+ // for the same level (old saves, old atom.json) and maps to "auto"; unknown
58
+ // values fall back to "auto" instead of stranding the session.
59
+ export function normalizeEffort(value) {
60
+ if (value === "default" || value === "auto")
61
+ return "auto";
62
+ if (value === "low" || value === "medium" || value === "high" || value === "max") {
63
+ return value;
64
+ }
65
+ return "auto";
66
+ }
67
+ // Effort support is provider-wide, never per-model: every known provider
68
+ // kind has a wire mapping (reasoning_effort on openai-chat, thinking on
69
+ // anthropic-messages, thinkingLevel on gemini-generate). Returns false only
70
+ // for an empty model or an unknown provider id — the actual per-model truth
71
+ // comes from the server at POST time (see above).
72
+ export function isEffortSupported(model, provider) {
73
+ if (!model)
74
+ return false;
75
+ if (provider === undefined)
76
+ return true;
77
+ return getProvider(provider) !== undefined;
64
78
  }
65
79
  // Wire value for the POST body, or undefined when the param must be
66
- // omitted (Default, unsupported model, or unknown effort string).
67
- export function reasoningEffortParam(effort, model) {
68
- if (!effort || effort === "default")
69
- return undefined;
70
- if (!isEffortSupported(model))
80
+ // omitted (Auto, or an unknown effort string). The `model` argument is
81
+ // accepted for backward compatibility and intentionally ignored: support is
82
+ // assumed for every model, with server rejection as the only veto.
83
+ export function reasoningEffortParam(effort, _model) {
84
+ const normalized = normalizeEffort(effort);
85
+ if (normalized === "auto")
71
86
  return undefined;
72
- if (effort === "low" || effort === "medium" || effort === "high" || effort === "max") {
73
- return effort;
74
- }
75
- return undefined;
87
+ return normalized;
76
88
  }
77
- export { CHARS_PER_TOKEN, MAX_HISTORY_CHARS, MAX_HISTORY_MESSAGES, createContextManager, estimateTokensForChars, historyCharBudget, historyChars, historyMessageBudget, messageChars, truncateHistoryWithCaps, } from "./context-manager.js";
89
+ export { CHARS_PER_TOKEN, createContextManager, estimateTokensForChars, historyChars, messageChars, } from "./context-manager.js";
78
90
  export { openTodoNeedles } from "./agent/gates.js";
79
- export { truncateHistory } from "./agent/loop.js";
80
91
  function finiteCount(value) {
81
92
  return typeof value === "number" && Number.isFinite(value) && value >= 0
82
93
  ? Math.floor(value)
@@ -727,9 +738,11 @@ export async function readSSEMessage(res, opts) {
727
738
  }
728
739
  // Streaming chat POST with tools attached (tool_choice omitted, so the
729
740
  // default auto applies). Sends {..., stream:true} plus `reasoning_effort`
730
- // ONLY when opts.reasoningEffort is non-Default AND the model is in
731
- // REASONING_EFFORT_SUPPORTED_MODELS (see reasoningEffortParam); otherwise
732
- // the param is omitted. Parses the SSE event stream (see readSSEMessage).
741
+ // whenever opts.reasoningEffort is non-Auto (see reasoningEffortParam)
742
+ // for every model, on every provider routed through this transport.
743
+ // A 400 naming the knob means the model truly lacks it: warn once via
744
+ // onWarning and retry without it. Parses the SSE event stream (see
745
+ // readSSEMessage).
733
746
  // When the response has no SSE body (plain {ok, json()} mocks and other
734
747
  // non-streaming payloads) it falls back to the original single-JSON parse,
735
748
  // unchanged. Returns the raw assistant message: either final content or
@@ -750,10 +763,25 @@ export async function readSSEMessage(res, opts) {
750
763
  // the dispatcher passes providerLabel(provider) for non-zen openai-chat
751
764
  // providers so users see e.g. `OpenAI HTTP 401` instead of `Zen HTTP 401`.
752
765
  // Legacy `function_call` shape is intentionally ignored.
753
- export async function chatCompletion(endpoint, apiKey, model, history, opts, errorLabel = "Zen") {
766
+ export async function chatCompletion(endpoint, apiKey, model, history,
767
+ // providerId (ticket 08): hook attribution for the shared openai-chat
768
+ // transport — the dispatcher passes its provider id, direct zen callers
769
+ // omit it and default to "opencode-zen". Optional, wire-compatible.
770
+ opts, errorLabel = "Zen") {
754
771
  const sleep = opts?.sleep ?? defaultSleep;
755
772
  const signal = opts?.signal ?? null;
773
+ const hookProvider = opts?.providerId ?? "opencode-zen";
774
+ // Context hooks (ticket 08) run once per call — not per retry — over a
775
+ // per-POST copy; the loop transcript array is never mutated. Fail-open:
776
+ // a throwing handler degrades to the untransformed messages. Zero-cost
777
+ // when no hooks are registered: the apply path is skipped entirely (no
778
+ // extra awaits per POST), so hook-free turns keep byte-identical timing.
779
+ const contextHooks = contextTransformers();
780
+ const outgoingHistory = contextHooks.length > 0 ? await applyContextTransform(contextHooks, history) : history;
756
781
  let lastError = null;
782
+ // Server-authoritative unsupported: when a 400 names the effort knob, the
783
+ // flag below drops it and the loop retries without it (once per call).
784
+ let effortDropped = false;
757
785
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
758
786
  try {
759
787
  throwIfCancelled(signal);
@@ -763,7 +791,9 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
763
791
  catch {
764
792
  // ignore observer errors
765
793
  }
766
- const effortParam = reasoningEffortParam(opts?.reasoningEffort, model);
794
+ const effortParam = effortDropped
795
+ ? undefined
796
+ : reasoningEffortParam(opts?.reasoningEffort, model);
767
797
  const summaryOpts = opts;
768
798
  // Stable-prefix split (prompt-cache architecture): history[0]'s env
769
799
  // tail becomes its own system message so the stable head + tools stay
@@ -771,16 +801,18 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
771
801
  // system messages concatenate on every OpenAI-protocol server, so this
772
802
  // is content-neutral. No env tail (tests, old saves) → history passes
773
803
  // through untouched, byte-identical to before.
774
- const messages = splitSystemHead(history);
804
+ const messages = splitSystemHead(outgoingHistory);
775
805
  const payload = {
776
806
  model,
777
807
  messages,
778
808
  stream: true,
779
809
  };
780
810
  // Compaction path only: tools disabled means NO `tools` key at all
781
- // (asserted in tests); the normal loop always sends the schema.
811
+ // (asserted in tests); the normal loop always sends the schema
812
+ // builtins plus extension-registered custom tools, so the model can
813
+ // discover and call them exactly like builtins.
782
814
  if (!summaryOpts?.disableTools) {
783
- payload["tools"] = TOOL_DEFINITIONS;
815
+ payload["tools"] = allToolDefinitions();
784
816
  }
785
817
  // Compaction path only: cap output (openai-chat kind uses max_tokens).
786
818
  if (typeof summaryOpts?.maxOutputTokens === "number" &&
@@ -790,21 +822,73 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
790
822
  }
791
823
  if (effortParam !== undefined)
792
824
  payload["reasoning_effort"] = effortParam;
825
+ // Pre-request hooks (ticket 08) run per POST attempt: payload
826
+ // replacement must be a record (else ignored — downstream JSON/fetch
827
+ // handling is never bypassed); header merge honors deletions.
828
+ // Zero-cost when unregistered (see context hooks above).
829
+ const preHooks = beforeRequestInterceptors();
830
+ const outgoing = preHooks.length > 0
831
+ ? await applyBeforeRequest(preHooks, {
832
+ provider: hookProvider,
833
+ model,
834
+ url: endpoint,
835
+ payload,
836
+ headers: {
837
+ "Content-Type": "application/json",
838
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
839
+ },
840
+ })
841
+ : {
842
+ payload,
843
+ headers: {
844
+ "Content-Type": "application/json",
845
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
846
+ },
847
+ };
793
848
  const res = await fetch(endpoint, {
794
849
  method: "POST",
795
850
  // Anonymous-capable providers (Kilo free models) omit Authorization
796
851
  // when no key is configured — never an empty `Bearer `. Keyed
797
852
  // providers always pass a key (gated by providerNeedsKey), so their
798
853
  // behavior is unchanged.
799
- headers: {
800
- "Content-Type": "application/json",
801
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
802
- },
803
- body: JSON.stringify(payload),
854
+ headers: outgoing.headers,
855
+ body: JSON.stringify(outgoing.payload),
804
856
  ...(signal ? { signal } : {}),
805
857
  });
858
+ // Post-response observers (ticket 08): every resolved POST (ok and
859
+ // HTTP-error alike), fail-open — never break the turn. Zero-cost when
860
+ // unregistered (the header snapshot is only built for live observers).
861
+ const postHooks = afterResponseObservers();
862
+ if (postHooks.length > 0) {
863
+ await notifyAfterResponse(postHooks, {
864
+ provider: hookProvider,
865
+ model,
866
+ url: endpoint,
867
+ status: res.status,
868
+ ok: res.ok,
869
+ headers: snapshotResponseHeaders(res),
870
+ });
871
+ }
806
872
  if (!res.ok) {
807
873
  const errText = await safeErrorText(res);
874
+ // The server is the authority on effort support: a 400 naming the
875
+ // knob means this model/deployment has no such control — warn,
876
+ // drop the knob, and retry without it (setting kept). Any other
877
+ // 400 keeps failing loudly below.
878
+ if (res.status === 400 &&
879
+ effortParam !== undefined &&
880
+ !effortDropped &&
881
+ isEffortRejection(errText)) {
882
+ effortDropped = true;
883
+ try {
884
+ opts?.onWarning?.(`reasoning effort "${effortParam}" is not supported by ${model} — continuing without it`);
885
+ }
886
+ catch {
887
+ // ignore observer errors
888
+ }
889
+ throwIfCancelled(signal);
890
+ continue;
891
+ }
808
892
  const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
809
893
  if (!RETRYABLE_STATUS.has(res.status))
810
894
  throw err;
@@ -956,11 +1040,16 @@ export function loadAgentsPrompt(cwd = process.cwd()) {
956
1040
  }
957
1041
  }
958
1042
  export function buildSystemPrompt(cwd = process.cwd()) {
959
- // Two layers: src/system.ts base one-liner + repo AGENTS.md overlay.
960
- // Owner knobs: edit the one-liner in src/system.ts for the base identity;
961
- // add repo instructions to AGENTS.md for the overlay.
1043
+ // Three layers: src/system.ts base one-liner + repo AGENTS.md overlay +
1044
+ // extension prompt hints (ticket 06). The hints ride the existing assembly
1045
+ // no parallel prompt pipeline: when none are registered the result is
1046
+ // byte-identical to the two-layer form.
962
1047
  const extra = loadAgentsPrompt(cwd);
963
- return extra ? `${SYSTEM_PROMPT}\n\n${extra}` : SYSTEM_PROMPT;
1048
+ const base = extra ? `${SYSTEM_PROMPT}\n\n${extra}` : SYSTEM_PROMPT;
1049
+ const hints = getExtensionPromptHints();
1050
+ if (hints.length === 0)
1051
+ return base;
1052
+ return `${base}\n\n## Extension hints\n${hints.map((h) => `- ${h}`).join("\n")}`;
964
1053
  }
965
1054
  function providerHttpError(provider, status, text) {
966
1055
  return new Error(`${providerLabel(provider)} HTTP ${status}: ${text.slice(0, 300)}`);
@@ -968,7 +1057,17 @@ function providerHttpError(provider, status, text) {
968
1057
  export async function chatCompletionAnthropic(apiKey, model, history, opts) {
969
1058
  const sleep = opts?.sleep ?? defaultSleep;
970
1059
  const signal = opts?.signal ?? null;
1060
+ // Ticket 08: same per-POST hook contract as the openai-chat path above
1061
+ // (context once per call, pre/post per attempt, all fail-open, zero-cost
1062
+ // when unregistered).
1063
+ const anthropicContextHooks = contextTransformers();
1064
+ const outgoingHistory = anthropicContextHooks.length > 0
1065
+ ? await applyContextTransform(anthropicContextHooks, history)
1066
+ : history;
971
1067
  let lastError = null;
1068
+ // Same server-authoritative unsupported contract as the openai-chat path:
1069
+ // a 400 naming the thinking knob drops it for the rest of the call.
1070
+ let anthropicEffortDropped = false;
972
1071
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
973
1072
  try {
974
1073
  throwIfCancelled(signal);
@@ -979,7 +1078,7 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
979
1078
  // ignore
980
1079
  }
981
1080
  const summaryOpts = opts;
982
- const base = buildAnthropicBody(history, model, {
1081
+ const base = buildAnthropicBody(outgoingHistory, model, {
983
1082
  includeTools: !summaryOpts?.disableTools,
984
1083
  });
985
1084
  const body = { ...base, stream: true };
@@ -990,14 +1089,62 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
990
1089
  summaryOpts.maxOutputTokens > 0) {
991
1090
  body["max_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
992
1091
  }
1092
+ // /effort maps to the native thinking budget (Auto omits it; a cap too
1093
+ // small for the 1024 minimum omits it too — see anthropicThinkingFor).
1094
+ const anthropicEffort = anthropicEffortDropped
1095
+ ? undefined
1096
+ : reasoningEffortParam(opts?.reasoningEffort, model);
1097
+ const anthropicBudget = anthropicEffort !== undefined
1098
+ ? anthropicThinkingFor(anthropicEffort, typeof body["max_tokens"] === "number"
1099
+ ? body["max_tokens"]
1100
+ : ANTHROPIC_MAX_TOKENS)
1101
+ : undefined;
1102
+ if (anthropicBudget !== undefined) {
1103
+ body["thinking"] = { type: "enabled", budget_tokens: anthropicBudget };
1104
+ }
1105
+ const anthropicPreHooks = beforeRequestInterceptors();
1106
+ const outgoing = anthropicPreHooks.length > 0
1107
+ ? await applyBeforeRequest(anthropicPreHooks, {
1108
+ provider: "anthropic",
1109
+ model,
1110
+ url: "https://api.anthropic.com/v1/messages",
1111
+ payload: body,
1112
+ headers: anthropicHeaders(apiKey),
1113
+ })
1114
+ : { payload: body, headers: anthropicHeaders(apiKey) };
993
1115
  const res = await fetch("https://api.anthropic.com/v1/messages", {
994
1116
  method: "POST",
995
- headers: anthropicHeaders(apiKey),
996
- body: JSON.stringify(body),
1117
+ headers: outgoing.headers,
1118
+ body: JSON.stringify(outgoing.payload),
997
1119
  ...(signal ? { signal } : {}),
998
1120
  });
1121
+ const anthropicPostHooks = afterResponseObservers();
1122
+ if (anthropicPostHooks.length > 0) {
1123
+ await notifyAfterResponse(anthropicPostHooks, {
1124
+ provider: "anthropic",
1125
+ model,
1126
+ url: "https://api.anthropic.com/v1/messages",
1127
+ status: res.status,
1128
+ ok: res.ok,
1129
+ headers: snapshotResponseHeaders(res),
1130
+ });
1131
+ }
999
1132
  if (!res.ok) {
1000
1133
  const errText = await safeErrorText(res);
1134
+ if (res.status === 400 &&
1135
+ anthropicBudget !== undefined &&
1136
+ !anthropicEffortDropped &&
1137
+ isEffortRejection(errText)) {
1138
+ anthropicEffortDropped = true;
1139
+ try {
1140
+ opts?.onWarning?.(`reasoning effort "${anthropicEffort}" is not supported by ${model} — continuing without it`);
1141
+ }
1142
+ catch {
1143
+ // ignore observer errors
1144
+ }
1145
+ throwIfCancelled(signal);
1146
+ continue;
1147
+ }
1001
1148
  const err = providerHttpError("anthropic", res.status, errText);
1002
1149
  if (!RETRYABLE_STATUS.has(res.status))
1003
1150
  throw err;
@@ -1057,7 +1204,17 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1057
1204
  export async function chatCompletionGemini(apiKey, model, history, opts) {
1058
1205
  const sleep = opts?.sleep ?? defaultSleep;
1059
1206
  const signal = opts?.signal ?? null;
1207
+ // Ticket 08: same per-POST hook contract as the openai-chat path above
1208
+ // (context once per call, pre/post per attempt, all fail-open, zero-cost
1209
+ // when unregistered).
1210
+ const geminiContextHooks = contextTransformers();
1211
+ const outgoingHistory = geminiContextHooks.length > 0
1212
+ ? await applyContextTransform(geminiContextHooks, history)
1213
+ : history;
1060
1214
  let lastError = null;
1215
+ // Same server-authoritative unsupported contract as the other paths: a
1216
+ // 400 naming the thinking knob drops it for the rest of the call.
1217
+ let geminiEffortDropped = false;
1061
1218
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1062
1219
  try {
1063
1220
  throwIfCancelled(signal);
@@ -1068,7 +1225,7 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1068
1225
  // ignore
1069
1226
  }
1070
1227
  const summaryOpts = opts;
1071
- const body = buildGeminiBody(history, model, {
1228
+ const body = buildGeminiBody(outgoingHistory, model, {
1072
1229
  includeTools: !summaryOpts?.disableTools,
1073
1230
  ...(typeof summaryOpts?.maxOutputTokens === "number" &&
1074
1231
  Number.isFinite(summaryOpts.maxOutputTokens) &&
@@ -1076,14 +1233,65 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1076
1233
  ? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
1077
1234
  : {}),
1078
1235
  });
1236
+ // /effort maps to the native thinkingLevel (Auto omits it; Max rides
1237
+ // high, the deepest level the API offers). Merged into
1238
+ // generationConfig so a compaction maxOutputTokens cap survives.
1239
+ const geminiEffort = geminiEffortDropped
1240
+ ? undefined
1241
+ : reasoningEffortParam(opts?.reasoningEffort, model);
1242
+ const geminiLevel = geminiEffort !== undefined ? geminiThinkingLevelFor(geminiEffort) : undefined;
1243
+ if (geminiLevel !== undefined) {
1244
+ const gc = typeof body.generationConfig === "object" && body.generationConfig !== null
1245
+ ? { ...body.generationConfig }
1246
+ : {};
1247
+ body.generationConfig = {
1248
+ ...gc,
1249
+ thinkingConfig: { thinkingLevel: geminiLevel },
1250
+ };
1251
+ }
1252
+ const geminiPreHooks = beforeRequestInterceptors();
1253
+ const outgoing = geminiPreHooks.length > 0
1254
+ ? await applyBeforeRequest(geminiPreHooks, {
1255
+ provider: "google-gemini",
1256
+ model,
1257
+ url: geminiChatUrl(model),
1258
+ payload: body,
1259
+ headers: geminiHeaders(apiKey),
1260
+ })
1261
+ : { payload: body, headers: geminiHeaders(apiKey) };
1079
1262
  const res = await fetch(geminiChatUrl(model), {
1080
1263
  method: "POST",
1081
- headers: geminiHeaders(apiKey),
1082
- body: JSON.stringify(body),
1264
+ headers: outgoing.headers,
1265
+ body: JSON.stringify(outgoing.payload),
1083
1266
  ...(signal ? { signal } : {}),
1084
1267
  });
1268
+ const geminiPostHooks = afterResponseObservers();
1269
+ if (geminiPostHooks.length > 0) {
1270
+ await notifyAfterResponse(geminiPostHooks, {
1271
+ provider: "google-gemini",
1272
+ model,
1273
+ url: geminiChatUrl(model),
1274
+ status: res.status,
1275
+ ok: res.ok,
1276
+ headers: snapshotResponseHeaders(res),
1277
+ });
1278
+ }
1085
1279
  if (!res.ok) {
1086
1280
  const errText = await safeErrorText(res);
1281
+ if (res.status === 400 &&
1282
+ geminiLevel !== undefined &&
1283
+ !geminiEffortDropped &&
1284
+ isEffortRejection(errText)) {
1285
+ geminiEffortDropped = true;
1286
+ try {
1287
+ opts?.onWarning?.(`reasoning effort "${geminiEffort}" is not supported by ${model} — continuing without it`);
1288
+ }
1289
+ catch {
1290
+ // ignore observer errors
1291
+ }
1292
+ throwIfCancelled(signal);
1293
+ continue;
1294
+ }
1087
1295
  const err = providerHttpError("google-gemini", res.status, errText);
1088
1296
  if (!RETRYABLE_STATUS.has(res.status))
1089
1297
  throw err;
@@ -1115,10 +1323,20 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1115
1323
  throwIfCancelled(signal);
1116
1324
  const res2 = await fetch(geminiGenerateUrl(model), {
1117
1325
  method: "POST",
1118
- headers: geminiHeaders(apiKey),
1119
- body: JSON.stringify(body),
1326
+ headers: outgoing.headers,
1327
+ body: JSON.stringify(outgoing.payload),
1120
1328
  ...(signal ? { signal } : {}),
1121
1329
  });
1330
+ if (geminiPostHooks.length > 0) {
1331
+ await notifyAfterResponse(geminiPostHooks, {
1332
+ provider: "google-gemini",
1333
+ model,
1334
+ url: geminiGenerateUrl(model),
1335
+ status: res2.status,
1336
+ ok: res2.ok,
1337
+ headers: snapshotResponseHeaders(res2),
1338
+ });
1339
+ }
1122
1340
  if (!res2.ok) {
1123
1341
  const errText2 = await safeErrorText(res2);
1124
1342
  throw providerHttpError("google-gemini", res2.status, errText2);
@@ -1169,9 +1387,11 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1169
1387
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1170
1388
  }
1171
1389
  // Provider dispatcher: openai-chat reuses chatCompletion with the provider's
1172
- // error label; anthropic/gemini go through their adapters. reasoning_effort is only
1173
- // ever attached for opencode-zen (via reasoningEffortParam); all other
1174
- // providers never receive the param.
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.
1175
1395
  export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
1176
1396
  const def = getProvider(provider);
1177
1397
  if (!def)
@@ -1188,7 +1408,14 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1188
1408
  const endpoint = provider === "opencode-zen"
1189
1409
  ? (opts?.endpointOverride ?? chatEndpointFor(provider, opts?.baseURL))
1190
1410
  : chatEndpointFor(provider, opts?.baseURL);
1191
- const effortOpts = provider === "opencode-zen" ? { reasoningEffort: opts?.reasoningEffort } : {};
1411
+ // Effort passes through for every openai-chat provider (zen, OpenAI,
1412
+ // DeepSeek, Mistral, Kilo, openai-compatible, local runtimes): the shared
1413
+ // transport sends reasoning_effort when non-Auto and falls back without it
1414
+ // on a server rejection. Anthropic/Gemini kinds receive opts directly
1415
+ // above and map effort to their native thinking knobs.
1416
+ const effortOpts = opts?.reasoningEffort !== undefined
1417
+ ? { reasoningEffort: opts.reasoningEffort }
1418
+ : {};
1192
1419
  const chatOpts = {
1193
1420
  onToken: opts?.onToken,
1194
1421
  onPhase: opts?.onPhase,
@@ -1197,6 +1424,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1197
1424
  onThinking: opts?.onThinking,
1198
1425
  sleep: opts?.sleep,
1199
1426
  signal: opts?.signal,
1427
+ // Ticket 08: provider-hook attribution for the shared openai-chat
1428
+ // transport (otherwise every kind would report "opencode-zen").
1429
+ providerId: provider,
1200
1430
  ...effortOpts,
1201
1431
  // Compaction path only (undefined for the normal loop → tools sent).
1202
1432
  ...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
@@ -1205,9 +1435,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1205
1435
  : {}),
1206
1436
  };
1207
1437
  // Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
1208
- // retry) with its registry endpoint + label; HTTP failures are reframed
1209
- // into concise actionable Kilo errors (see src/kilo.ts). reasoning_effort
1210
- // is never attached (zen-only gating above).
1438
+ // retry, effort with server-rejection fallback) with its registry
1439
+ // endpoint + label; HTTP failures are reframed into concise actionable
1440
+ // Kilo errors (see src/kilo.ts).
1211
1441
  if (provider === "kilo") {
1212
1442
  try {
1213
1443
  return await chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));