atom-agent 1.1.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 (74) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +18 -8
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +1637 -255
  5. package/dist/adapters.js +112 -21
  6. package/dist/agent/gates.js +14 -1
  7. package/dist/agent/goal-evaluator.js +69 -0
  8. package/dist/agent/loop-guard.js +11 -13
  9. package/dist/agent/loop.js +716 -132
  10. package/dist/agent/normalize.js +9 -2
  11. package/dist/cli.js +25 -3
  12. package/dist/compact.js +169 -17
  13. package/dist/config.js +43 -7
  14. package/dist/context-manager.js +16 -198
  15. package/dist/context-windows.js +4 -2
  16. package/dist/env-block.js +46 -8
  17. package/dist/extension-commands.js +196 -0
  18. package/dist/extension-ui.js +153 -0
  19. package/dist/extensions.js +1571 -0
  20. package/dist/goal.js +583 -0
  21. package/dist/project-trust.js +96 -0
  22. package/dist/providers.js +6 -6
  23. package/dist/scheduler.js +159 -41
  24. package/dist/session.js +23 -5
  25. package/dist/sessions.js +543 -0
  26. package/dist/system.js +89 -13
  27. package/dist/telemetry-dashboard.js +28 -0
  28. package/dist/telemetry.js +39 -0
  29. package/dist/tools/compaction-hooks.js +165 -0
  30. package/dist/tools/custom.js +189 -0
  31. package/dist/tools/dir-cache.js +7 -0
  32. package/dist/tools/filesystem.js +3 -2
  33. package/dist/tools/intercept.js +145 -0
  34. package/dist/tools/overrides.js +105 -0
  35. package/dist/tools/provider-hooks.js +224 -0
  36. package/dist/tools/registry.js +247 -17
  37. package/dist/tools/ripgrep.js +256 -0
  38. package/dist/tools/search.js +119 -58
  39. package/dist/tools/shared.js +39 -0
  40. package/dist/tools/shell.js +7 -5
  41. package/dist/tools/web.js +6 -6
  42. package/dist/tools.js +45 -0
  43. package/dist/ui/diff-view.js +7 -2
  44. package/dist/ui/live-host.js +18 -0
  45. package/dist/ui/live-tail.js +9 -3
  46. package/dist/ui/markdown.js +26 -2
  47. package/dist/ui/palette.js +3 -1
  48. package/dist/ui/side-by-side.js +2 -2
  49. package/dist/ui/status-bar.js +80 -5
  50. package/dist/ui/status-host.js +22 -0
  51. package/dist/ui/stream-store.js +48 -0
  52. package/dist/ui/tool-inspector.js +7 -1
  53. package/dist/ui/transcript.js +92 -38
  54. package/dist/zen.js +370 -87
  55. package/documentation/architecture.md +114 -0
  56. package/documentation/cli.md +82 -0
  57. package/documentation/compaction.md +50 -0
  58. package/documentation/configuration.md +111 -0
  59. package/documentation/development.md +62 -0
  60. package/documentation/extensions.md +160 -0
  61. package/documentation/getting-started.md +63 -0
  62. package/documentation/goals.md +41 -0
  63. package/documentation/index.md +41 -0
  64. package/documentation/observability.md +70 -0
  65. package/documentation/permissions.md +66 -0
  66. package/documentation/providers.md +78 -0
  67. package/documentation/sessions.md +92 -0
  68. package/documentation/skills.md +57 -0
  69. package/documentation/tools.md +94 -0
  70. package/documentation/troubleshooting.md +54 -0
  71. package/examples/extensions/01-audit-gate.js +24 -0
  72. package/examples/extensions/02-notes-tool.js +32 -0
  73. package/examples/extensions/03-custom-command.js +32 -0
  74. 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, } 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")
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")
69
86
  return undefined;
70
- if (!isEffortSupported(model))
71
- 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)
@@ -337,11 +348,20 @@ export async function fetchModels(endpoint, apiKey) {
337
348
  // - Slots with an id but no name at [DONE] are dropped with an onWarning
338
349
  // message and never returned (keeps assistant/tool pairing valid).
339
350
  // - A stream that ends without [DONE] throws a truncation error.
351
+ // - A response flagged `finish_reason: "length"` (output limit cut the
352
+ // response off, so tool arguments are incomplete) does NOT throw: it
353
+ // returns normally with `truncated: true` so the loop can fail each carried
354
+ // tool call inline and continue the turn. Transport failures (aborted
355
+ // connections, stalls, empty replies) keep throwing.
340
356
  // - A stream silent longer than the stall budget (env ATOM_STALL_TIMEOUT_MS,
341
357
  // default 60s; the clock resets on every received chunk) throws a
342
358
  // Truncated-stream stall error — permanent, never retried, same contract
343
359
  // as a dead connection (verified live: free-tier routers can stall a
344
360
  // 200-OK stream mid-generation for minutes).
361
+ // - Queue comments (`: ...`) and keep-alives carry bytes but no model output:
362
+ // only `data:` payload lines refresh the data-silence clock, so minutes of
363
+ // `: KILO PROCESSING` while queued fail fast instead of hanging the turn
364
+ // (same budget, same permanent contract).
345
365
  // - A stream with zero "data:" lines is treated as a non-SSE JSON payload
346
366
  // (tolerance for bodies that are really single-shot JSON) and parsed as
347
367
  // choices[0].message like the non-streaming fallback.
@@ -361,9 +381,29 @@ export async function readSSEMessage(res, opts) {
361
381
  // reasoning label seen in any delta.
362
382
  let streamUsage;
363
383
  let streamReasoning;
384
+ // Output-limit flag (see contract above): set when any streamed choice
385
+ // reports `finish_reason: "length"`. Returned on the result — never thrown.
386
+ let lengthTruncated = false;
364
387
  // Accumulated thinking text (see onThinking): kept apart from fullText so
365
388
  // reasoning never leaks into the answer, history, or tool arguments.
366
389
  let fullThinking = "";
390
+ // Data-silence tracking (see throwIfDataStalled): timestamp of the last
391
+ // `data:` payload line. Queue comments (`: KILO PROCESSING`) and keep-alive
392
+ // comments carry bytes but no model output — they advance the raw stream
393
+ // but must NOT extend the stall budget (live-proven: minutes of comments
394
+ // while a free-tier request sits queued).
395
+ let lastDataAt = Date.now();
396
+ // 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
399
+ // after each drained chunk — legitimately slow generations keep emitting
400
+ // `data:` lines, so only true silence trips it.
401
+ function throwIfDataStalled() {
402
+ const budget = sseStallTimeoutMs();
403
+ if (Date.now() - lastDataAt > budget) {
404
+ throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
405
+ }
406
+ }
367
407
  function announceStreaming() {
368
408
  if (!streamingAnnounced) {
369
409
  streamingAnnounced = true;
@@ -386,6 +426,7 @@ export async function readSSEMessage(res, opts) {
386
426
  if (!line.startsWith("data:"))
387
427
  return; // event:/id:/retry: ignored
388
428
  sawData = true;
429
+ lastDataAt = Date.now();
389
430
  let payload = line.slice("data:".length);
390
431
  if (payload.startsWith(" "))
391
432
  payload = payload.slice(1);
@@ -410,6 +451,10 @@ export async function readSSEMessage(res, opts) {
410
451
  }
411
452
  const choice = evt
412
453
  ?.choices?.[0];
454
+ // Output-limit marker rides on the choice, beside the delta — any chunk
455
+ // reporting it means the tool arguments below are incomplete.
456
+ if (choice?.finish_reason === "length")
457
+ lengthTruncated = true;
413
458
  const delta = (choice?.delta ?? choice?.message);
414
459
  if (typeof delta !== "object" || delta === null)
415
460
  return;
@@ -533,6 +578,7 @@ export async function readSSEMessage(res, opts) {
533
578
  rawText += text;
534
579
  buffer += text;
535
580
  drainBuffer();
581
+ throwIfDataStalled();
536
582
  if (sawDone) {
537
583
  try {
538
584
  await reader.cancel?.();
@@ -569,6 +615,7 @@ export async function readSSEMessage(res, opts) {
569
615
  rawText += text;
570
616
  buffer += text;
571
617
  drainBuffer();
618
+ throwIfDataStalled();
572
619
  if (sawDone)
573
620
  break;
574
621
  }
@@ -612,6 +659,10 @@ export async function readSSEMessage(res, opts) {
612
659
  throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
613
660
  }
614
661
  // Tolerance: a body with no SSE data lines is really single-shot JSON.
662
+ // (The data-silence bound applies only once real SSE traffic exists, so
663
+ // whole-body JSON payloads are never false-tripped by it.)
664
+ if (sawData)
665
+ throwIfDataStalled();
615
666
  if (!sawData) {
616
667
  const candidate = rawText.trim();
617
668
  if (candidate.length > 0) {
@@ -628,6 +679,8 @@ export async function readSSEMessage(res, opts) {
628
679
  content,
629
680
  tool_calls: calls.length > 0 ? calls : undefined,
630
681
  };
682
+ if (data?.choices?.[0]?.finish_reason === "length")
683
+ result.truncated = true;
631
684
  const usage = parseUsage(data?.usage);
632
685
  if (usage !== undefined)
633
686
  result.usage = usage;
@@ -675,6 +728,8 @@ export async function readSSEMessage(res, opts) {
675
728
  content: fullText.length > 0 ? fullText : null,
676
729
  tool_calls: calls.length > 0 ? calls : undefined,
677
730
  };
731
+ if (lengthTruncated)
732
+ result.truncated = true;
678
733
  if (streamUsage !== undefined)
679
734
  result.usage = streamUsage;
680
735
  if (streamReasoning !== undefined)
@@ -683,16 +738,21 @@ export async function readSSEMessage(res, opts) {
683
738
  }
684
739
  // Streaming chat POST with tools attached (tool_choice omitted, so the
685
740
  // default auto applies). Sends {..., stream:true} plus `reasoning_effort`
686
- // ONLY when opts.reasoningEffort is non-Default AND the model is in
687
- // REASONING_EFFORT_SUPPORTED_MODELS (see reasoningEffortParam); otherwise
688
- // 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).
689
746
  // When the response has no SSE body (plain {ok, json()} mocks and other
690
747
  // non-streaming payloads) it falls back to the original single-JSON parse,
691
748
  // unchanged. Returns the raw assistant message: either final content or
692
749
  // tool_calls the caller must execute, plus `usage`/`reasoning` only when
693
750
  // the response actually carried them (usage: top-level `usage` on JSON or
694
751
  // SSE final chunks; reasoning: message/delta reasoning metadata).
695
- // Throws on HTTP error, empty reply, or a truncated stream.
752
+ // Throws on HTTP error, empty reply, or a truncated stream (aborted
753
+ // connection / stall / missing [DONE]). A response flagged
754
+ // `finish_reason: "length"` instead returns normally with `truncated: true`
755
+ // (the loop fails its tool calls inline and continues).
696
756
  // - Network throws and HTTP 429/500/502/503/504 are retried up to
697
757
  // MAX_RETRIES (10) with 1s→2s→4s… backoff, honoring Retry-After capped
698
758
  // at 30s.
@@ -703,10 +763,25 @@ export async function readSSEMessage(res, opts) {
703
763
  // the dispatcher passes providerLabel(provider) for non-zen openai-chat
704
764
  // providers so users see e.g. `OpenAI HTTP 401` instead of `Zen HTTP 401`.
705
765
  // Legacy `function_call` shape is intentionally ignored.
706
- 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") {
707
771
  const sleep = opts?.sleep ?? defaultSleep;
708
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;
709
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;
710
785
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
711
786
  try {
712
787
  throwIfCancelled(signal);
@@ -716,7 +791,9 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
716
791
  catch {
717
792
  // ignore observer errors
718
793
  }
719
- const effortParam = reasoningEffortParam(opts?.reasoningEffort, model);
794
+ const effortParam = effortDropped
795
+ ? undefined
796
+ : reasoningEffortParam(opts?.reasoningEffort, model);
720
797
  const summaryOpts = opts;
721
798
  // Stable-prefix split (prompt-cache architecture): history[0]'s env
722
799
  // tail becomes its own system message so the stable head + tools stay
@@ -724,16 +801,18 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
724
801
  // system messages concatenate on every OpenAI-protocol server, so this
725
802
  // is content-neutral. No env tail (tests, old saves) → history passes
726
803
  // through untouched, byte-identical to before.
727
- const messages = splitSystemHead(history);
804
+ const messages = splitSystemHead(outgoingHistory);
728
805
  const payload = {
729
806
  model,
730
807
  messages,
731
808
  stream: true,
732
809
  };
733
810
  // Compaction path only: tools disabled means NO `tools` key at all
734
- // (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.
735
814
  if (!summaryOpts?.disableTools) {
736
- payload["tools"] = TOOL_DEFINITIONS;
815
+ payload["tools"] = allToolDefinitions();
737
816
  }
738
817
  // Compaction path only: cap output (openai-chat kind uses max_tokens).
739
818
  if (typeof summaryOpts?.maxOutputTokens === "number" &&
@@ -743,21 +822,73 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
743
822
  }
744
823
  if (effortParam !== undefined)
745
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
+ };
746
848
  const res = await fetch(endpoint, {
747
849
  method: "POST",
748
850
  // Anonymous-capable providers (Kilo free models) omit Authorization
749
851
  // when no key is configured — never an empty `Bearer `. Keyed
750
852
  // providers always pass a key (gated by providerNeedsKey), so their
751
853
  // behavior is unchanged.
752
- headers: {
753
- "Content-Type": "application/json",
754
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
755
- },
756
- body: JSON.stringify(payload),
854
+ headers: outgoing.headers,
855
+ body: JSON.stringify(outgoing.payload),
757
856
  ...(signal ? { signal } : {}),
758
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
+ }
759
872
  if (!res.ok) {
760
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
+ }
761
892
  const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
762
893
  if (!RETRYABLE_STATUS.has(res.status))
763
894
  throw err;
@@ -811,6 +942,8 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
811
942
  content,
812
943
  tool_calls: calls.length > 0 ? calls : undefined,
813
944
  };
945
+ if (data?.choices?.[0]?.finish_reason === "length")
946
+ result.truncated = true;
814
947
  const usage = parseUsage(data?.usage);
815
948
  if (usage !== undefined)
816
949
  result.usage = usage;
@@ -861,16 +994,19 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
861
994
  }
862
995
  // Agentic loop for one user turn: thin wrapper over the shared runLoopWithChat
863
996
  // core below (single loop implementation). Send → while the response carries
864
- // tool_calls (max MAX_TOOL_STEPS tool rounds), append the assistant message,
865
- // execute each tool locally, append {role:'tool'} results, resend.
997
+ // tool_calls (uncapped by default; explicit opts.maxSteps still caps), append
998
+ // the assistant message, execute each tool locally, append {role:'tool'}
999
+ // results, resend.
866
1000
  // Streaming: each POST streams SSE tokens (onToken gets the growing text,
867
1001
  // onPhase reports thinking|streaming|tool|retry|done, onToolDelta fires when
868
1002
  // a tool name first appears mid-stream). A model that returns no tool_calls
869
1003
  // ends the loop (graceful fallback for models without tool support). Tool
870
1004
  // errors are results the model sees — NOTHING is rolled back here; only a
871
- // POST failure (HTTP/network/empty/truncated) throws (and the caller rolls
1005
+ // POST failure (HTTP/network/empty/stalled-stream) throws (and the caller rolls
872
1006
  // back the user turn, as before; the caller preserves any streamed partial
873
- // on display).
1007
+ // on display). A length-truncated response (`finish_reason: "length"`) does
1008
+ // not throw: the loop fails each carried tool call inline with a repair
1009
+ // error and continues to the next model round.
874
1010
  export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
875
1011
  return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
876
1012
  onToken: o?.onToken,
@@ -904,11 +1040,16 @@ export function loadAgentsPrompt(cwd = process.cwd()) {
904
1040
  }
905
1041
  }
906
1042
  export function buildSystemPrompt(cwd = process.cwd()) {
907
- // Two layers: src/system.ts base one-liner + repo AGENTS.md overlay.
908
- // Owner knobs: edit the one-liner in src/system.ts for the base identity;
909
- // 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.
910
1047
  const extra = loadAgentsPrompt(cwd);
911
- 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")}`;
912
1053
  }
913
1054
  function providerHttpError(provider, status, text) {
914
1055
  return new Error(`${providerLabel(provider)} HTTP ${status}: ${text.slice(0, 300)}`);
@@ -916,7 +1057,17 @@ function providerHttpError(provider, status, text) {
916
1057
  export async function chatCompletionAnthropic(apiKey, model, history, opts) {
917
1058
  const sleep = opts?.sleep ?? defaultSleep;
918
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;
919
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;
920
1071
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
921
1072
  try {
922
1073
  throwIfCancelled(signal);
@@ -927,7 +1078,7 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
927
1078
  // ignore
928
1079
  }
929
1080
  const summaryOpts = opts;
930
- const base = buildAnthropicBody(history, model, {
1081
+ const base = buildAnthropicBody(outgoingHistory, model, {
931
1082
  includeTools: !summaryOpts?.disableTools,
932
1083
  });
933
1084
  const body = { ...base, stream: true };
@@ -938,14 +1089,62 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
938
1089
  summaryOpts.maxOutputTokens > 0) {
939
1090
  body["max_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
940
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) };
941
1115
  const res = await fetch("https://api.anthropic.com/v1/messages", {
942
1116
  method: "POST",
943
- headers: anthropicHeaders(apiKey),
944
- body: JSON.stringify(body),
1117
+ headers: outgoing.headers,
1118
+ body: JSON.stringify(outgoing.payload),
945
1119
  ...(signal ? { signal } : {}),
946
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
+ }
947
1132
  if (!res.ok) {
948
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
+ }
949
1148
  const err = providerHttpError("anthropic", res.status, errText);
950
1149
  if (!RETRYABLE_STATUS.has(res.status))
951
1150
  throw err;
@@ -1005,7 +1204,17 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1005
1204
  export async function chatCompletionGemini(apiKey, model, history, opts) {
1006
1205
  const sleep = opts?.sleep ?? defaultSleep;
1007
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;
1008
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;
1009
1218
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1010
1219
  try {
1011
1220
  throwIfCancelled(signal);
@@ -1016,7 +1225,7 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1016
1225
  // ignore
1017
1226
  }
1018
1227
  const summaryOpts = opts;
1019
- const body = buildGeminiBody(history, model, {
1228
+ const body = buildGeminiBody(outgoingHistory, model, {
1020
1229
  includeTools: !summaryOpts?.disableTools,
1021
1230
  ...(typeof summaryOpts?.maxOutputTokens === "number" &&
1022
1231
  Number.isFinite(summaryOpts.maxOutputTokens) &&
@@ -1024,14 +1233,65 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1024
1233
  ? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
1025
1234
  : {}),
1026
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) };
1027
1262
  const res = await fetch(geminiChatUrl(model), {
1028
1263
  method: "POST",
1029
- headers: geminiHeaders(apiKey),
1030
- body: JSON.stringify(body),
1264
+ headers: outgoing.headers,
1265
+ body: JSON.stringify(outgoing.payload),
1031
1266
  ...(signal ? { signal } : {}),
1032
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
+ }
1033
1279
  if (!res.ok) {
1034
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
+ }
1035
1295
  const err = providerHttpError("google-gemini", res.status, errText);
1036
1296
  if (!RETRYABLE_STATUS.has(res.status))
1037
1297
  throw err;
@@ -1063,10 +1323,20 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1063
1323
  throwIfCancelled(signal);
1064
1324
  const res2 = await fetch(geminiGenerateUrl(model), {
1065
1325
  method: "POST",
1066
- headers: geminiHeaders(apiKey),
1067
- body: JSON.stringify(body),
1326
+ headers: outgoing.headers,
1327
+ body: JSON.stringify(outgoing.payload),
1068
1328
  ...(signal ? { signal } : {}),
1069
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
+ }
1070
1340
  if (!res2.ok) {
1071
1341
  const errText2 = await safeErrorText(res2);
1072
1342
  throw providerHttpError("google-gemini", res2.status, errText2);
@@ -1117,9 +1387,11 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1117
1387
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1118
1388
  }
1119
1389
  // Provider dispatcher: openai-chat reuses chatCompletion with the provider's
1120
- // error label; anthropic/gemini go through their adapters. reasoning_effort is only
1121
- // ever attached for opencode-zen (via reasoningEffortParam); all other
1122
- // 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.
1123
1395
  export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
1124
1396
  const def = getProvider(provider);
1125
1397
  if (!def)
@@ -1136,7 +1408,14 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1136
1408
  const endpoint = provider === "opencode-zen"
1137
1409
  ? (opts?.endpointOverride ?? chatEndpointFor(provider, opts?.baseURL))
1138
1410
  : chatEndpointFor(provider, opts?.baseURL);
1139
- 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
+ : {};
1140
1419
  const chatOpts = {
1141
1420
  onToken: opts?.onToken,
1142
1421
  onPhase: opts?.onPhase,
@@ -1145,6 +1424,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1145
1424
  onThinking: opts?.onThinking,
1146
1425
  sleep: opts?.sleep,
1147
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,
1148
1430
  ...effortOpts,
1149
1431
  // Compaction path only (undefined for the normal loop → tools sent).
1150
1432
  ...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
@@ -1153,9 +1435,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1153
1435
  : {}),
1154
1436
  };
1155
1437
  // Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
1156
- // retry) with its registry endpoint + label; HTTP failures are reframed
1157
- // into concise actionable Kilo errors (see src/kilo.ts). reasoning_effort
1158
- // 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).
1159
1441
  if (provider === "kilo") {
1160
1442
  try {
1161
1443
  return await chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
@@ -1166,16 +1448,17 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1166
1448
  }
1167
1449
  return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
1168
1450
  }
1169
- export { evaluateTurnEnd, isCodePath, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
1451
+ export { evaluateTurnEnd, isCodePath, MAX_TODO_ROUNDS, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
1170
1452
  // Parallel independent tool calls: batch PLANNING lives in src/scheduler.ts
1171
1453
  // (effect metadata + conflict rules, no per-tool branches); this module only
1172
1454
  // plans via planToolBatches below and executes (serial singletons in program
1173
- // order, read batches concurrently, results committed in call order).
1455
+ // order, disjoint batches concurrently, results committed in call order).
1174
1456
  //
1175
- // Parallel-safe = batchable reads only (see TOOL_EFFECTS in scheduler.ts).
1176
- // Excluded on purpose:
1177
- // - write/edit/bash mutate or spawn with an unbounded footprint (bash can
1178
- // touch anything, so no footprint check could clear it) always singletons;
1457
+ // Parallel-safe = batchable reads plus disjoint-file writes (see TOOL_EFFECTS
1458
+ // and canonicalFileKey in scheduler.ts). Approvals for batched writes resolve
1459
+ // serially in call order before any member executes. Excluded on purpose:
1460
+ // - bash mutates/spawns with an unbounded footprint (it can touch anything,
1461
+ // so no footprint check could clear it) — always a singleton;
1179
1462
  // - ask_question blocks on a UI modal (parallel prompts make no sense);
1180
1463
  // - todowrite/todo_update share module-global todo state (read-modify-write
1181
1464
  // races); todo_get is pure but sub-millisecond, so batching it buys
@@ -1190,7 +1473,7 @@ export function planToolBatches(calls) {
1190
1473
  }
1191
1474
  import { runLoopWithChat } from "./agent/loop.js";
1192
1475
  export { runLoopWithChat } from "./agent/loop.js";
1193
- export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
1476
+ export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, emptyResponseFollowUp, isEmptyReplyError, MAX_EMPTY_ROUNDS, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
1194
1477
  export async function runAgenticLoopForProvider(provider, apiKey, model, history, opts) {
1195
1478
  return runLoopWithChat((h, o) => chatCompletionForProvider(provider, apiKey, model, h, {
1196
1479
  onToken: o?.onToken,