mixdog 0.9.2 → 0.9.3

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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
package/src/repl.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
- * mixdog REPL — styled inline terminal loop over the ported mixdog brain.
2
+ * mixdog REPL — styled inline terminal loop over the mixdog session runtime.
3
3
  *
4
- * Drives the *ported mixdog brain* through mixdog-session-runtime.mjs:
4
+ * Drives the session runtime through mixdog-session-runtime.mjs:
5
5
  * createSession + askSession own agentLoop/provider/tools/compaction, while
6
6
  * this module stays presentation-only: markdown-rendered replies, tool-call
7
7
  * cards, a per-turn statusline footer, slash commands, and arrow-key history.
8
8
  *
9
9
  * Flow: stdin line → runtime.ask(prompt) → onTextDelta streams tokens to
10
- * stdout live → tool calls render as cards → on turn end we re-render
11
- * the assistant text as markdown → statusline footer.
10
+ * stdout live → tool calls render as cards → on turn end we re-render
11
+ * the assistant text as markdown → statusline footer.
12
12
  *
13
- * STREAMING DECISION (approach (a) from the brief):
13
+ * STREAMING DECISION:
14
14
  * Live token streaming via onTextDelta conflicts with post-hoc markdown
15
15
  * rendering (you can't style a heading until you've seen the whole line).
16
16
  * We choose: stream raw tokens live so the turn FEELS alive, then on turn
@@ -6,16 +6,13 @@ kind: retrieval
6
6
 
7
7
  # Role: explorer
8
8
 
9
- Locator only: likely file/symbol/line anchors; no analysis, debugging, decisions,
10
- or recommendations.
9
+ You are a one-shot locator, not a researcher.
11
10
 
12
- Output only:
13
- - `path:line symbol/name short reason`
14
- - or `EXPLORATION_FAILED`
15
-
16
- No preambles/tool-call preambles, bullets, headings, summaries, code quotes,
17
- verdicts, or invented coordinates. Weak anchors: `?`.
11
+ Procedure: send ONE turn containing one `grep` (all synonyms in one
12
+ `pattern:[...]`, output_mode content_with_context) plus one `code_graph`
13
+ call. The results contain path:line — answer immediately from them.
14
+ If both miss, answer `EXPLORATION_FAILED`. Do not send a second lookup turn.
18
15
 
19
- One batched lookup turn; first plausible anchor wins. No verification loop,
20
- synonym sweep, or proof-chasing. Hard stop after 5 tool calls; if uncertain,
21
- return best weak anchors with `?`.
16
+ Answer format, nothing else:
17
+ - up to 5 lines of `path:line symbol/name short reason` (append `?` if weak)
18
+ - or `EXPLORATION_FAILED`
@@ -1,13 +1,17 @@
1
1
  # Lead Tool Use
2
2
 
3
- - Lead owns repo-local shell work: git, build, test, run, and verification commands.
3
+ - Lead owns repo-local shell work: run git/build/test/verification commands via
4
+ `shell` directly; do not delegate them to agents.
4
5
  - Use the current project/workspace selected by the session. Only change the work project when the user asks for a different project or a tool call explicitly needs another project root.
5
- - Use `shell` directly for approved git/build/test/run work; do not delegate those commands to agents.
6
6
  - Use `agent` for scoped implementation, research, review, and debugging, not for git commit/push/stash or Ship.
7
- - Reuse the same agent tag/session for follow-up on the same scope (`send` or
8
- `spawn` with the same tag reuses a live session). Spawn a new tag only for a
9
- genuinely independent scope.
7
+ - A `send` to a reaped/dead tag auto-respawns a FRESH session under the same
8
+ tag (result carries `respawned: true`). That worker has no prior session
9
+ context treat the message as a cold brief and re-supply anchors
10
+ (`file:line`) on it or the next send.
10
11
  - Briefs: minimum characters, maximum information. Fixed one-line fragment
11
12
  fields — `Goal:` `Anchors:` `Allow/Forbid:` `Deliver:` `Verify:` (+`Stop:` for
12
13
  heavy-worker). Omit role-known rules (git/preamble bans, output format),
13
14
  background, motivation; non-actionable tokens are wasted cost.
15
+ - Bridge language is ALWAYS English: every brief, follow-up `send`, and
16
+ steering message to an agent is written in English regardless of the
17
+ user-facing language.
@@ -1,6 +1,8 @@
1
1
  # Tool Use
2
2
 
3
- - Independent lookups MUST batch in one turn; serialize only when a call needs a prior result.
3
+ - Independent lookups MUST batch in one turn; serialize only when a call needs
4
+ a prior result. This includes edit loops: batch the post-edit verification
5
+ read with the next target's lookup instead of emitting single-call turns.
4
6
  - Target validity comes first: symbols/callers/deps → `code_graph`; exact text in
5
7
  a verified scope → `grep`; unknown path/name → `find`; structure → `glob`;
6
8
  dirs → `list`; verified file → `read`; broad unknown with no anchor →
@@ -10,10 +12,14 @@
10
12
  `code_graph symbols[]`). Refine from returned paths; do not repeat equivalent
11
13
  patterns or scopes.
12
14
  - On miss/error, normalize the target once and switch tool; on a plausible hit,
13
- stop searching and answer from the framed context. Do not follow
14
- `content_with_context` with `read` unless the needed span is not shown.
15
+ stop searching and answer from the framed context.
16
+ - Lookup budget: retrieval exists to reach the NEXT action (edit, answer,
17
+ handoff), not to build certainty. One anchor is enough to act on; re-reading
18
+ or re-grepping an area you already saw this session is waste. When acting
19
+ and looking are both possible, act.
15
20
  - Avoid read fragmentation: `read` uses `offset`/`limit` only. If you need 2+
16
21
  spans from one or more known files, make one batched `read` call with
17
- `{path,offset,limit}` region objects instead of serial reads.
18
- - `search`/`web_fetch` for external info; `recall` for history.
22
+ `{path,offset,limit}` region objects instead of serial reads. Adjacent spans
23
+ in the same file (within a few hundred lines) are ONE window, not repeated
24
+ reads.
19
25
  - Don't mix `apply_patch` with shell or other state-changing calls in one turn.
@@ -33,6 +33,47 @@ function mixdogAssetDirs(projectDir, kind) {
33
33
  dirs.push(mixdogGlobalDir(kind));
34
34
  return dirs;
35
35
  }
36
+ /**
37
+ * Absolute path to the plugin registry file, or null when the data dir is
38
+ * unresolvable. Included in the skills mtime gate so plugin add/remove
39
+ * (which rewrites registry.json) invalidates the cached skill list even
40
+ * when no surviving skills dir got a newer mtime.
41
+ */
42
+ function pluginRegistryPath() {
43
+ try {
44
+ return join(resolvePluginData(), 'plugins', 'registry.json');
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Read `<resolvePluginData()>/plugins/registry.json` (safe JSON parse, ignore
51
+ * errors) and yield `<root>/skills` for each registered plugin whose `root`
52
+ * exists on disk and has a `skills` subdirectory.
53
+ */
54
+ function pluginSkillDirs() {
55
+ const registryPath = pluginRegistryPath();
56
+ if (!registryPath)
57
+ return [];
58
+ let registry;
59
+ try {
60
+ registry = JSON.parse(readFileSync(registryPath, 'utf-8'));
61
+ } catch {
62
+ return [];
63
+ }
64
+ if (!registry || !Array.isArray(registry.plugins))
65
+ return [];
66
+ const dirs = [];
67
+ for (const entry of registry.plugins) {
68
+ const root = entry && typeof entry.root === 'string' ? entry.root : null;
69
+ if (!root || !existsSync(root))
70
+ continue;
71
+ const skillsDir = join(root, 'skills');
72
+ if (existsSync(skillsDir))
73
+ dirs.push(skillsDir);
74
+ }
75
+ return dirs;
76
+ }
36
77
  /**
37
78
  * Collect available skills (frontmatter only — token efficient).
38
79
  * Full content loaded on demand via loadSkillContent().
@@ -49,6 +90,9 @@ export function collectSkills(cwd) {
49
90
  // first, then user-global. When cwd is missing, only the global dir is
50
91
  // searched.
51
92
  const dirs = mixdogAssetDirs(projectDir, 'skills');
93
+ // Plugin-provided skills load last so project-local and global mixdog
94
+ // skill dirs keep precedence; `seen` below dedupes by frontmatter name.
95
+ dirs.push(...pluginSkillDirs());
52
96
  const seen = new Set();
53
97
  for (const dir of dirs) {
54
98
  if (!existsSync(dir))
@@ -88,6 +132,13 @@ export function collectSkillsCached(cwd) {
88
132
  const projectDir = (typeof cwd === 'string' && cwd.length > 0) ? cwd : null;
89
133
  // Same mixdog-owned dirs collectSkills() reads, used as the freshness gate.
90
134
  const skillsDirs = mixdogAssetDirs(projectDir, 'skills');
135
+ skillsDirs.push(...pluginSkillDirs());
136
+ // registry.json itself gates plugin add/remove: removal deletes the
137
+ // plugin's skills dir (so no dir mtime advances), but saveRegistry()
138
+ // always rewrites this file. maxMtimeRecursive stats plain files directly.
139
+ const registryPath = pluginRegistryPath();
140
+ if (registryPath)
141
+ skillsDirs.push(registryPath);
91
142
  let mtime;
92
143
  const mtimeCached = _mtimeCache.get(key);
93
144
  if (mtimeCached && Date.now() - mtimeCached.checkedAt < _MTIME_TTL_MS) {
@@ -1,4 +1,5 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
1
+ import { readFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { writeFile } from 'fs/promises';
2
3
  import { join } from 'path';
3
4
  import { tmpdir } from 'os';
4
5
  import { randomUUID } from 'crypto';
@@ -273,7 +274,10 @@ function capMcpOutput(content) {
273
274
  const dir = join(tmpdir(), 'mixdog-mcp-output');
274
275
  mkdirSync(dir, { recursive: true });
275
276
  spillPath = join(dir, `mcp-${Date.now()}-${randomUUID().slice(0, 8)}.txt`);
276
- writeFileSync(spillPath, s, 'utf-8');
277
+ // Fire-and-forget: the spill path is returned to the caller
278
+ // immediately (below) for later recovery; the write itself must not
279
+ // block this hot tool-result path.
280
+ writeFile(spillPath, s, 'utf-8').catch(() => { /* spill best-effort */ });
277
281
  } catch { /* spill best-effort */ }
278
282
  const spillNote = spillPath
279
283
  ? `\n\n... [full output spilled to ${spillPath}] ...`
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Anthropic effort API alignment (Claude Code effort.ts + configureEffortParams).
2
+ * Anthropic "effort" (extended-thinking budget) request parameter handling.
3
3
  */
4
4
 
5
5
  export const EFFORT_LEVELS = Object.freeze(['low', 'medium', 'high', 'max']);
@@ -0,0 +1,93 @@
1
+ // Shared catalog-driven max_tokens resolution for both Anthropic providers
2
+ // (OAuth and API-key). Extracted from anthropic-oauth.mjs so anthropic.mjs
3
+ // (the API-key twin) gets the same sonnet-5+ / haiku fix instead of drifting
4
+ // with its own hardcoded table.
5
+ //
6
+ // Resolution order:
7
+ // 1. MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS env override, if set to a positive
8
+ // int, wins outright (returned as the safety cap itself).
9
+ // 2. Catalog outputTokens for the model id (trusted over hardcoded
10
+ // heuristics when present — the API reports real per-model limits),
11
+ // clamped to [MAX_TOKENS_FLOOR, safetyCap].
12
+ // 3. Static per-model table / family heuristic fallback when the catalog
13
+ // has no entry for this model, also capped at the safety cap.
14
+
15
+ export const MAX_TOKENS_FLOOR = 8192;
16
+ export const DEFAULT_SAFETY_CAP = 65536;
17
+ const ENV_VAR = 'MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS';
18
+
19
+ // Per-model max_tokens when the model id is explicitly listed. New models
20
+ // (e.g., Sonnet 4.7) won't match a specific entry and fall through to the
21
+ // family-based heuristic below. Conservative defaults — model may support
22
+ // more but we'd rather stay within safe bounds.
23
+ const MAX_TOKENS = {
24
+ 'claude-opus-4-8': 65536,
25
+ 'claude-opus-4-7': 65536,
26
+ 'claude-opus-4-6': 65536,
27
+ 'claude-sonnet-4-6': 16384,
28
+ 'claude-haiku-4-5-20251001': 8192,
29
+ };
30
+
31
+ // Strict-positive env override parsing. Invalid values ("0", negatives,
32
+ // garbage, whitespace) are treated as UNSET — not as "use the default cap
33
+ // outright" — so catalog/fallback still decide for low-cap models. Raw env
34
+ // truthiness must never bypass resolution.
35
+ export function envAnthropicMaxOutputOverride() {
36
+ const raw = process.env[ENV_VAR];
37
+ if (raw == null || String(raw).trim() === '') return null;
38
+ const n = Number(raw);
39
+ if (!Number.isFinite(n) || n <= 0) return null;
40
+ return Math.floor(n);
41
+ }
42
+
43
+ export function resolveAnthropicSafetyCap() {
44
+ return envAnthropicMaxOutputOverride() ?? DEFAULT_SAFETY_CAP;
45
+ }
46
+
47
+ // Pure lookup over an already-loaded catalog array (disk cache or in-memory
48
+ // mirror) — no I/O, no caching strategy. Callers own how/when the array is
49
+ // loaded/refreshed.
50
+ export function lookupCatalogOutputTokens(models, id) {
51
+ if (!id || !Array.isArray(models)) return null;
52
+ const entry = models.find(m => m?.id === id);
53
+ const out = Number(entry?.outputTokens);
54
+ return Number.isFinite(out) && out > 0 ? out : null;
55
+ }
56
+
57
+ // Static per-model table + family heuristic, used when the catalog has no
58
+ // entry for this model id. Sonnet 5+ ships a much larger output budget than
59
+ // the legacy 4.x line (this is the claude-sonnet-5 fix: 16384 was starving
60
+ // visible output once extended thinking ate into the same hard cap). Keep
61
+ // sonnet-4-x conservative at 16384; only bump 5+.
62
+ export function fallbackAnthropicMaxTokens(model) {
63
+ if (MAX_TOKENS[model]) return MAX_TOKENS[model];
64
+ const id = String(model || '').toLowerCase();
65
+ if (id.includes('opus')) return 65536;
66
+ if (id.includes('fable')) return 65536;
67
+ const sonnetVersion = id.match(/^claude-sonnet-(\d+)/);
68
+ if (sonnetVersion) return Number(sonnetVersion[1]) >= 5 ? 65536 : 16384;
69
+ if (id.includes('sonnet')) return 16384;
70
+ if (id.includes('haiku')) return 8192;
71
+ return 8192;
72
+ }
73
+
74
+ // catalogLookup(model) -> number|null. Providers supply their own strategy
75
+ // for sourcing the catalog array (in-memory mirror + disk fallback for OAuth,
76
+ // plain disk read for the API-key twin — see anthropic.mjs).
77
+ export function resolveAnthropicMaxTokens(model, { catalogLookup } = {}) {
78
+ const envOverride = envAnthropicMaxOutputOverride();
79
+ if (envOverride != null) return envOverride;
80
+ const safetyCap = DEFAULT_SAFETY_CAP;
81
+ let catalogValue = null;
82
+ if (typeof catalogLookup === 'function') {
83
+ try {
84
+ catalogValue = catalogLookup(model);
85
+ } catch {
86
+ catalogValue = null;
87
+ }
88
+ }
89
+ if (Number.isFinite(catalogValue) && catalogValue > 0) {
90
+ return Math.max(MAX_TOKENS_FLOOR, Math.min(catalogValue, safetyCap));
91
+ }
92
+ return Math.min(fallbackAnthropicMaxTokens(model), safetyCap);
93
+ }
@@ -19,6 +19,7 @@ import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
19
19
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
20
20
  import { enrichModels } from './model-catalog.mjs';
21
21
  import { makeModelCache } from './model-cache.mjs';
22
+ import { resolveAnthropicMaxTokens } from './anthropic-max-tokens.mjs';
22
23
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
23
24
  import {
24
25
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
@@ -55,7 +56,7 @@ import { scanLeakedToolCalls, createToolCallDedupe } from './anthropic-leaked-to
55
56
  // hammer /v1/models. 24h TTL matches the upstream client cadence.
56
57
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
57
58
  // Bump when the on-disk cache shape changes so stale-shape entries are
58
- // discarded instead of misread (mirrors openai-oauth's schema-version gate).
59
+ // discarded instead of misread.
59
60
  const ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION = 1;
60
61
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
61
62
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
@@ -222,8 +223,7 @@ function _compareVersion(a, b) {
222
223
  }
223
224
 
224
225
  // Newest HIGH-TIER chat model by version, read from the SYNC in-memory catalog
225
- // mirror. Symmetric with resolveLatestGrokModel / resolveLatestCodexModel.
226
- // Anthropic ships three families: opus / sonnet / haiku. "Latest" is the
226
+ // mirror. Anthropic ships three families: opus / sonnet / haiku. "Latest" is the
227
227
  // highest version across opus + sonnet only — haiku is the cheap tier and is
228
228
  // never the flagship default. Returns null until listModels() populates the
229
229
  // mirror; callers must warm the catalog (ensureLatestAnthropicModel) when null.
@@ -305,12 +305,11 @@ const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_
305
305
  const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
306
306
  const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
307
307
 
308
- // Anthropic OAuth contract for first-party OAuth clients.
309
- // Opus/Sonnet requests are gated on a specific system-prompt prefix.
310
- // Mixdog keeps that upstream client contract for OAuth routing. Haiku is not
308
+ // Anthropic OAuth contract for first-party OAuth clients: Opus/Sonnet
309
+ // requests are gated on this exact system-prompt prefix. Haiku is not
311
310
  // gated and ignores this prefix.
312
311
  const CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
313
- const OAUTH_BETA_HEADERS = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,extended-cache-ttl-2025-04-11,advanced-tool-use-2025-11-20';
312
+ const OAUTH_BETA_HEADERS = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,extended-cache-ttl-2025-04-11';
314
313
  const DEFAULT_CLI_VERSION = '2.1.77';
315
314
 
316
315
  function resolveCliVersion() {
@@ -374,37 +373,6 @@ function buildSystemBlocks(systemMsgs, model, systemTtl, tier3Ttl) {
374
373
  return blocks;
375
374
  }
376
375
 
377
- // Per-model max_tokens when the model id is explicitly listed. New models
378
- // (e.g., Sonnet 4.7) won't match a specific entry and fall through to the
379
- // family-based heuristic below. Conservative defaults — model may support
380
- // more but we'd rather stay within safe bounds.
381
- const MAX_TOKENS = {
382
- 'claude-opus-4-8': 65536,
383
- 'claude-opus-4-7': 65536,
384
- 'claude-opus-4-6': 65536,
385
- 'claude-sonnet-4-6': 16384,
386
- 'claude-haiku-4-5-20251001': 8192,
387
- };
388
-
389
- // Sanity floor/ceiling for resolveMaxTokens. Catalog-reported outputTokens
390
- // (from the Anthropic API, cached to disk) can be trusted above these
391
- // hardcoded fallbacks, but we still clamp to a safety cap so a bad/huge
392
- // catalog value can't blow the thinking+output budget, and floor so a
393
- // missing/zero catalog value never degenerates to an unusable cap.
394
- const MAX_TOKENS_FLOOR = 8192;
395
- const DEFAULT_SAFETY_CAP = 65536;
396
- // Parse MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS strictly: only a finite positive
397
- // number is a valid override. Invalid values ("0", negatives, garbage,
398
- // whitespace) are treated as unset — NOT as "use the default cap outright" —
399
- // so resolveMaxTokens still consults catalog/fallback for low-cap models.
400
- function _envMaxOutputOverride() {
401
- const raw = process.env.MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS;
402
- if (raw == null || String(raw).trim() === '') return null;
403
- const n = Number(raw);
404
- if (!Number.isFinite(n) || n <= 0) return null;
405
- return Math.floor(n);
406
- }
407
-
408
376
  // Catalog-reported outputTokens for a model id, read from the in-memory
409
377
  // catalog mirror (lazily populated from the disk cache if the mirror hasn't
410
378
  // been warmed yet by listModels()). Never throws — any failure just means
@@ -425,37 +393,17 @@ function _catalogOutputTokens(model) {
425
393
  }
426
394
  }
427
395
 
428
- function _fallbackMaxTokens(model) {
429
- if (MAX_TOKENS[model]) return MAX_TOKENS[model];
430
- const id = String(model || '').toLowerCase();
431
- if (id.includes('opus')) return 65536;
432
- if (id.includes('fable')) return 65536;
433
- // Sonnet 5+ ships a much larger output budget than the legacy 4.x line
434
- // (this is the claude-sonnet-5 fix: 16384 was starving visible output
435
- // once extended thinking ate into the same hard cap). Keep sonnet-4-x
436
- // conservative at 16384; only bump 5+.
437
- const sonnetVersion = id.match(/^claude-sonnet-(\d+)/);
438
- if (sonnetVersion) return Number(sonnetVersion[1]) >= 5 ? 65536 : 16384;
439
- if (id.includes('sonnet')) return 16384;
440
- if (id.includes('haiku')) return 8192;
441
- return 8192;
442
- }
443
-
444
- // resolveMaxTokens: catalog-driven max_tokens for a model id.
396
+ // resolveMaxTokens: catalog-driven max_tokens for a model id. Thin wrapper
397
+ // around the shared anthropic-max-tokens helper (also used by the API-key
398
+ // twin in anthropic.mjs) this provider supplies its own in-memory-mirror-
399
+ // first catalog lookup strategy.
445
400
  // 1. MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS env override, if set, wins outright.
446
401
  // 2. Catalog outputTokens (trusted over hardcoded heuristics when present),
447
402
  // clamped to [MAX_TOKENS_FLOOR, safetyCap].
448
403
  // 3. Static MAX_TOKENS table / family heuristic fallback when the catalog
449
404
  // has no entry for this model, also clamped to the safety cap.
450
405
  function resolveMaxTokens(model) {
451
- const envOverride = _envMaxOutputOverride();
452
- if (envOverride != null) return envOverride;
453
- const safetyCap = DEFAULT_SAFETY_CAP;
454
- const catalogValue = _catalogOutputTokens(model);
455
- if (catalogValue != null) {
456
- return Math.max(MAX_TOKENS_FLOOR, Math.min(catalogValue, safetyCap));
457
- }
458
- return Math.min(_fallbackMaxTokens(model), safetyCap);
406
+ return resolveAnthropicMaxTokens(model, { catalogLookup: _catalogOutputTokens });
459
407
  }
460
408
 
461
409
  const MIN_THINKING_BUDGET = 1024;
@@ -492,8 +440,8 @@ function credentialCandidates() {
492
440
  // Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
493
441
  // credentials file carries no explicit expiresAt — without it expiresAt stays 0,
494
442
  // which ensureAuth reads as "never expires", disabling proactive refresh. Claude
495
- // OAuth tokens are opaque so this returns 0 and the file's expiresAt governs; kept
496
- // for parity with the other OAuth providers. JWT `exp` is epoch SECONDS (RFC 7519).
443
+ // OAuth tokens are opaque so this returns 0 and the file's expiresAt governs.
444
+ // JWT `exp` is epoch SECONDS (RFC 7519).
497
445
  function _expiryFromAccessToken(token) {
498
446
  try {
499
447
  const parts = String(token || '').split('.');
@@ -718,7 +666,7 @@ export class ReauthRequired extends Error {
718
666
  }
719
667
  }
720
668
 
721
- // --- Message conversion (mirrors anthropic.mjs) ---
669
+ // --- Message conversion ---
722
670
 
723
671
  function withCacheControl(block, ttl = CACHE_TTL_VOLATILE) {
724
672
  if (!block || typeof block !== 'object' || block.cache_control) return block;
@@ -1068,7 +1016,8 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1068
1016
 
1069
1017
  // Synthesize + dispatch a recovered leaked call exactly like the native
1070
1018
  // content_block_stop path (push into toolCalls, flag state, eager
1071
- // onToolCall). A generated id mirrors the `toolu_`-prefixed native shape.
1019
+ // onToolCall). A generated id uses the same `toolu_`-prefixed shape as
1020
+ // Anthropic's native tool-call ids.
1072
1021
  const dispatchLeakedCall = (recovered) => {
1073
1022
  let args = recovered?.arguments;
1074
1023
  if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
@@ -1790,6 +1739,11 @@ export class AnthropicOAuthProvider {
1790
1739
  if (body.speed === 'fast') {
1791
1740
  this.fastModeBetaHeaderLatched = true;
1792
1741
  }
1742
+ // advanced-tool-use-2025-11-20 beta is only needed when this request
1743
+ // actually carries deferred (defer_loading) tools — gate the header on
1744
+ // that instead of sending it unconditionally on every request.
1745
+ const hasDeferredTools = Array.isArray(body.tools)
1746
+ && body.tools.some((t) => t && t.defer_loading === true);
1793
1747
  // Known tool names for the leaked-tool-call guard in parseSSEStream:
1794
1748
  // recovered leaked calls are only synthesized when they name a tool
1795
1749
  // actually offered to this request (native + lowered). Derived from the
@@ -1866,7 +1820,7 @@ export class AnthropicOAuthProvider {
1866
1820
  'anthropic-beta': buildAnthropicBetaHeaders({
1867
1821
  base: OAUTH_BETA_HEADERS,
1868
1822
  fastMode: this.fastModeBetaHeaderLatched,
1869
- toolSearch: true,
1823
+ toolSearch: hasDeferredTools,
1870
1824
  effort: shouldIncludeEffortBeta(useModel, opts),
1871
1825
  }),
1872
1826
  'anthropic-dangerous-direct-browser-access': 'true',
@@ -22,6 +22,8 @@ import {
22
22
  } from './anthropic-effort.mjs';
23
23
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
24
24
  import { enrichModels } from './model-catalog.mjs';
25
+ import { makeModelCache } from './model-cache.mjs';
26
+ import { resolveAnthropicMaxTokens } from './anthropic-max-tokens.mjs';
25
27
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
26
28
 
27
29
  const require = createRequire(import.meta.url);
@@ -159,14 +161,49 @@ function _normalizeAnthropicModel(raw, provider = 'anthropic') {
159
161
  }
160
162
  // Family-based heuristic so new model ids (including custom user-configured
161
163
  // ones) resolve a sensible max_tokens without requiring a code change.
164
+ // The API-key provider has no catalog cache of its own — it reads the same
165
+ // anthropic-oauth-models.json disk cache (read-only) that the OAuth provider
166
+ // maintains. Both providers hit the same Anthropic /v1/models catalog, so a
167
+ // per-model outputTokens entry is valid regardless of which auth path wrote
168
+ // it. If this provider is ever run standalone without the OAuth provider
169
+ // ever having populated the cache, loadSync() simply returns null and we
170
+ // fall through to the shared static heuristic in anthropic-max-tokens.mjs.
171
+ const ANTHROPIC_OAUTH_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
172
+ const _sharedOAuthModelCache = makeModelCache({
173
+ fileName: 'anthropic-oauth-models.json',
174
+ ttlMs: ANTHROPIC_OAUTH_MODEL_CACHE_TTL_MS,
175
+ version: 1,
176
+ });
177
+
178
+ // In-memory mirror populated by this provider's own listModels() fetch.
179
+ // API-key-only installs never have the OAuth provider write the shared disk
180
+ // cache, so without this mirror catalog outputTokens would stay invisible to
181
+ // resolveMaxTokens until an OAuth session runs. listModels() results flow in
182
+ // here (memory only — the disk cache stays OAuth-owned/read-only for us).
183
+ let _apiKeyCatalogMirror = null;
184
+
185
+ function _catalogOutputTokensFromSharedCache(model) {
186
+ if (!model) return null;
187
+ try {
188
+ const models = Array.isArray(_apiKeyCatalogMirror)
189
+ ? _apiKeyCatalogMirror
190
+ : _sharedOAuthModelCache.loadSync();
191
+ if (!Array.isArray(models)) return null;
192
+ const entry = models.find(m => m?.id === model);
193
+ const out = Number(entry?.outputTokens);
194
+ return Number.isFinite(out) && out > 0 ? out : null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
162
200
  function resolveMaxTokens(model) {
163
- const id = String(model || '').toLowerCase();
164
- if (id.includes('opus')) return 32768;
165
- if (id.includes('sonnet')) return 16384;
166
- if (id.includes('haiku')) return 8192;
167
- return 8192;
201
+ return resolveAnthropicMaxTokens(model, { catalogLookup: _catalogOutputTokensFromSharedCache });
168
202
  }
169
203
 
204
+ // Test-only escape hatch for scripts/anthropic-maxtokens-test.mjs.
205
+ export const _test = { resolveMaxTokens };
206
+
170
207
  const MIN_THINKING_BUDGET = 1024;
171
208
  const THINKING_OUTPUT_RESERVE = 1024;
172
209
 
@@ -464,8 +501,7 @@ export class AnthropicProvider {
464
501
  const usedSlots = toolsBpUsed + systemBpUsed;
465
502
  // Env override for BP strategy. ANTHROPIC_MSG_SLOTS=0 disables
466
503
  // message caching entirely. Any value >=1 first marks the previous
467
- // user text turn; a second free slot marks the tail. Mirrors
468
- // anthropic-oauth.mjs for twin parity.
504
+ // user text turn; a second free slot marks the tail.
469
505
  const msgSlotsCap = Number.parseInt(process.env.ANTHROPIC_MSG_SLOTS, 10);
470
506
  const defaultMsgSlots = Math.max(0, 4 - usedSlots);
471
507
  const msgSlots = ttls.messages
@@ -826,6 +862,9 @@ export class AnthropicProvider {
826
862
  .map((m) => _normalizeAnthropicModel(m, this.name))
827
863
  .filter(Boolean);
828
864
  const enriched = await enrichModels(normalized);
865
+ // Feed the resolver-visible mirror so API-key-only installs get
866
+ // catalog outputTokens without depending on the OAuth disk cache.
867
+ if (enriched.length) _apiKeyCatalogMirror = enriched.slice();
829
868
  return enriched.length ? enriched : MODELS;
830
869
  }
831
870
  catch (err) {
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
4
4
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
5
5
  import { getAgentApiKey, getOpenAIUsageSessionKey } from '../../../shared/config.mjs';
6
+ import { num, round } from './lib/usage-primitives.mjs';
6
7
 
7
8
  const CACHE_FILE = 'api-usage-cache.json';
8
9
  const LIVE_TTL_MS = 5 * 60_000;
@@ -10,19 +11,6 @@ const STALE_TTL_MS = 60 * 60_000;
10
11
  const DISK_JSON_MEMORY_TTL_MS = 1000;
11
12
  let diskJsonCache = { at: 0, file: '', value: null };
12
13
 
13
- function num(value, fallback = null) {
14
- if (value === null || value === undefined || value === '') return fallback;
15
- const n = Number(value);
16
- return Number.isFinite(n) ? n : fallback;
17
- }
18
-
19
- function round(value, digits = 4) {
20
- const n = Number(value);
21
- if (!Number.isFinite(n)) return null;
22
- const scale = 10 ** digits;
23
- return Math.round(n * scale) / scale;
24
- }
25
-
26
14
  function cachePath() {
27
15
  return join(resolvePluginData(), CACHE_FILE);
28
16
  }
@@ -33,7 +33,7 @@ const DEFAULT_MODEL = MODELS[0].id;
33
33
  // VERSION-based (parse gemini-X.Y) rather than release-date based.
34
34
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
35
35
  // Bump when the on-disk cache shape changes so stale-shape entries are
36
- // discarded instead of misread (mirrors openai-oauth's schema-version gate).
36
+ // discarded instead of misread.
37
37
  const GEMINI_MODEL_CACHE_SCHEMA_VERSION = 1;
38
38
 
39
39
  // De-dupes concurrent force-refreshes so they share one HTTP round-trip,
@@ -114,7 +114,7 @@ export function normalizeGrokModelId(id) {
114
114
  }
115
115
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
116
116
  // Bump when the on-disk cache shape changes so stale-shape entries are
117
- // discarded instead of misread (mirrors openai-oauth's schema-version gate).
117
+ // discarded instead of misread.
118
118
  const GROK_MODEL_CACHE_SCHEMA_VERSION = 1;
119
119
  const DISCOVERY_TIMEOUT_MS = 15_000;
120
120
  const TOKEN_TIMEOUT_MS = 30_000;
@@ -0,0 +1,32 @@
1
+ // Shared numeric/string primitives for provider usage-accounting modules.
2
+ //
3
+ // These are byte-for-byte identical helpers that were duplicated across
4
+ // api-usage.mjs, oauth-usage.mjs and opencode-go-usage.mjs. Extracting them
5
+ // here is behavior-preserving: each exported function reproduces the exact
6
+ // logic of the copies it replaces.
7
+ //
8
+ // NOTE ON `num`: api-usage.mjs and oauth-usage.mjs use the guarded variant
9
+ // below (treats null/undefined/'' as the fallback before Number()).
10
+ // opencode-go-usage.mjs uses an *unguarded* variant (Number('') === 0), which
11
+ // is observably different, so it keeps its own local `num` and is NOT wired to
12
+ // this module.
13
+
14
+ export function num(value, fallback = null) {
15
+ if (value === null || value === undefined || value === '') return fallback;
16
+ const n = Number(value);
17
+ return Number.isFinite(n) ? n : fallback;
18
+ }
19
+
20
+ export function round(value, digits = 4) {
21
+ const n = Number(value);
22
+ if (!Number.isFinite(n)) return null;
23
+ const scale = 10 ** digits;
24
+ return Math.round(n * scale) / scale;
25
+ }
26
+
27
+ // cleanString (oauth-usage.mjs) and clean (opencode-go-usage.mjs) were
28
+ // identical: trim strings, coerce empties/non-strings to null.
29
+ export function cleanString(value) {
30
+ const s = typeof value === 'string' ? value.trim() : '';
31
+ return s || null;
32
+ }