mixdog 0.7.17 → 0.7.18

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/UNINSTALL.md +7 -4
  4. package/bin/statusline-lib.mjs +29 -6
  5. package/bin/statusline-route.mjs +273 -0
  6. package/bin/statusline.mjs +31 -8
  7. package/commands/model.md +61 -0
  8. package/hooks/session-start.cjs +15 -1
  9. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  10. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  11. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  12. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  13. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  14. package/package.json +1 -1
  15. package/rules/lead/01-general.md +3 -0
  16. package/scripts/gateway-model.mjs +596 -0
  17. package/scripts/lib/gateway-inventory.mjs +178 -0
  18. package/scripts/lib/gateway-settings.mjs +78 -0
  19. package/scripts/run-mcp.mjs +26 -1
  20. package/scripts/statusline-launcher-smoke.mjs +155 -2
  21. package/scripts/uninstall.mjs +44 -7
  22. package/server-main.mjs +252 -11
  23. package/src/agent/index.mjs +96 -5
  24. package/src/agent/orchestrator/bridge-trace.mjs +18 -0
  25. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +4 -1
  26. package/src/agent/orchestrator/providers/anthropic.mjs +6 -2
  27. package/src/agent/orchestrator/session/loop.mjs +145 -4
  28. package/src/agent/orchestrator/session/trim.mjs +1 -1
  29. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +62 -6
  30. package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
  31. package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
  32. package/src/agent/tool-defs.mjs +10 -3
  33. package/src/channels/lib/runtime-paths.mjs +39 -4
  34. package/src/gateway/claude-current.mjs +255 -0
  35. package/src/gateway/oauth-usage.mjs +598 -0
  36. package/src/gateway/route-meta.mjs +629 -0
  37. package/src/gateway/server.mjs +713 -0
  38. package/src/shared/llm/cost.mjs +1 -1
  39. package/src/shared/seed.mjs +25 -0
  40. package/tools.json +21 -3
@@ -0,0 +1,178 @@
1
+ // Pure gateway model-inventory + target-write helpers shared by
2
+ // scripts/gateway-model.mjs (CLI) and src/agent/index.mjs
3
+ // (gateway_select_model tool). Dependency-injected — the caller passes its
4
+ // already-loaded config object and its own updateSection fn — so this module
5
+ // has ZERO import side effects and one copy of the logic serves both callers
6
+ // regardless of how each resolved the config module.
7
+
8
+ import {
9
+ CLAUDE_CURRENT_CHOICE_ID,
10
+ CLAUDE_CURRENT_MODE,
11
+ formatClaudeCurrentChoiceLabel,
12
+ readClaudeCodeCurrentRoute,
13
+ } from '../../src/gateway/claude-current.mjs';
14
+
15
+ // Enabled providers + their candidate models, from a loaded agent config.
16
+ // (Mirror of the previous inline buildInventory in scripts/gateway-model.mjs.)
17
+ export function buildInventory(config) {
18
+ const providers = config && typeof config.providers === 'object' ? config.providers : {};
19
+ const presets = Array.isArray(config && config.presets) ? config.presets : [];
20
+
21
+ const byProvider = new Map();
22
+ const targetsByProvider = new Map();
23
+ for (const p of presets) {
24
+ if (!p || typeof p !== 'object') continue;
25
+ if (!p.provider || !p.model) continue;
26
+ if (!byProvider.has(p.provider)) byProvider.set(p.provider, new Set());
27
+ byProvider.get(p.provider).add(p.model);
28
+ if (!targetsByProvider.has(p.provider)) targetsByProvider.set(p.provider, []);
29
+ targetsByProvider.get(p.provider).push({
30
+ id: p.id ? `preset::${p.id}` : `preset-name::${p.name || `${p.provider}/${p.model}`}`,
31
+ provider: p.provider,
32
+ model: p.model,
33
+ presetId: p.id || null,
34
+ presetName: p.name || null,
35
+ effort: p.effort || null,
36
+ fast: p.fast === true,
37
+ });
38
+ }
39
+
40
+ const out = [];
41
+ for (const [name, cfg] of Object.entries(providers)) {
42
+ if (!cfg || typeof cfg !== 'object' || cfg.enabled !== true) continue;
43
+ const models = new Set();
44
+ for (const m of byProvider.get(name) || []) models.add(m);
45
+ if (typeof cfg.model === 'string' && cfg.model) models.add(cfg.model);
46
+ const targets = [...(targetsByProvider.get(name) || [])];
47
+ if (typeof cfg.model === 'string' && cfg.model) {
48
+ const exists = targets.some(t => t.model === cfg.model && !t.effort && t.fast !== true);
49
+ if (!exists && !targets.some(t => t.model === cfg.model)) {
50
+ targets.push({ id: `${name}::${cfg.model}`, provider: name, model: cfg.model, presetId: null, presetName: null, effort: null, fast: false });
51
+ }
52
+ }
53
+ out.push({ provider: name, models: [...models], targets });
54
+ }
55
+ out.sort((a, b) => a.provider.localeCompare(b.provider));
56
+ return out;
57
+ }
58
+
59
+ function targetLabel(target) {
60
+ const parts = [target.provider, '/', target.model];
61
+ const suffix = [];
62
+ if (target.presetName) suffix.push(target.presetName);
63
+ if (target.effort) suffix.push(String(target.effort).toUpperCase());
64
+ if (target.fast) suffix.push('fast');
65
+ return suffix.length ? `${parts.join(' ')} · ${suffix.join(' · ')}` : parts.join(' ');
66
+ }
67
+
68
+ // Flatten inventory to elicitation enum choices: { id, label, provider, model }.
69
+ // id encodes provider+model ("provider::model") so the accepted value maps back
70
+ // to a concrete target. A provider with no concrete model yields a model:null
71
+ // choice (resolved by the caller later).
72
+ export function claudeCurrentChoice(inventory) {
73
+ const hasAnthropic = (inventory || []).some(e => e?.provider === 'anthropic-oauth' || e?.provider === 'anthropic');
74
+ if (!hasAnthropic) return null;
75
+ const current = readClaudeCodeCurrentRoute();
76
+ return {
77
+ id: CLAUDE_CURRENT_CHOICE_ID,
78
+ label: `${formatClaudeCurrentChoiceLabel(current)} · follows Claude Code /model`,
79
+ provider: current.provider || 'anthropic-oauth',
80
+ model: current.model,
81
+ mode: CLAUDE_CURRENT_MODE,
82
+ presetId: null,
83
+ presetName: null,
84
+ effort: current.effort || null,
85
+ fast: current.fast === true,
86
+ modelDisplay: current.modelDisplay || null,
87
+ };
88
+ }
89
+
90
+ export function inventoryChoices(inventory, opts = {}) {
91
+ const choices = [];
92
+ if (opts.includeClaudeCurrent !== false) {
93
+ const current = claudeCurrentChoice(inventory);
94
+ if (current) choices.push(current);
95
+ }
96
+ for (const e of inventory) {
97
+ if (Array.isArray(e.targets) && e.targets.length > 0) {
98
+ for (const t of e.targets) {
99
+ choices.push({ ...t, label: targetLabel(t) });
100
+ }
101
+ continue;
102
+ }
103
+ if (!e.models || e.models.length === 0) {
104
+ choices.push({ id: e.provider, label: `${e.provider} (default model)`, provider: e.provider, model: null });
105
+ continue;
106
+ }
107
+ for (const m of e.models) {
108
+ choices.push({ id: `${e.provider}::${m}`, label: `${e.provider} / ${m}`, provider: e.provider, model: m });
109
+ }
110
+ }
111
+ return choices;
112
+ }
113
+
114
+ // Inverse of inventoryChoices id encoding. Splits on the FIRST "::".
115
+ export function parseChoiceId(id) {
116
+ if (typeof id !== 'string' || !id) return { provider: null, model: null };
117
+ if (id === CLAUDE_CURRENT_CHOICE_ID) {
118
+ const current = readClaudeCodeCurrentRoute();
119
+ return {
120
+ provider: current.provider || 'anthropic-oauth',
121
+ model: current.model,
122
+ mode: CLAUDE_CURRENT_MODE,
123
+ effort: current.effort || null,
124
+ fast: current.fast === true,
125
+ modelDisplay: current.modelDisplay || null,
126
+ };
127
+ }
128
+ if (id.startsWith('preset::') || id.startsWith('preset-name::')) return { provider: null, model: null };
129
+ const i = id.indexOf('::');
130
+ if (i === -1) return { provider: id, model: null };
131
+ return { provider: id.slice(0, i), model: id.slice(i + 2) };
132
+ }
133
+
134
+ export function resolveTargetChoice(inventory, provider, model, explicitModel = false) {
135
+ if (provider === CLAUDE_CURRENT_CHOICE_ID || provider === CLAUDE_CURRENT_MODE) {
136
+ const current = readClaudeCodeCurrentRoute();
137
+ return {
138
+ id: CLAUDE_CURRENT_CHOICE_ID,
139
+ provider: current.provider || 'anthropic-oauth',
140
+ model: current.model || model || null,
141
+ mode: CLAUDE_CURRENT_MODE,
142
+ effort: current.effort || null,
143
+ fast: current.fast === true,
144
+ modelDisplay: current.modelDisplay || null,
145
+ };
146
+ }
147
+ const entry = (inventory || []).find(e => e.provider === provider);
148
+ if (!entry) return { provider, model: model || null };
149
+ const matches = (entry.targets || []).filter(t => t.model === model);
150
+ if (matches.length === 1) return matches[0];
151
+ if (!explicitModel && Array.isArray(entry.targets) && entry.targets.length > 0) return entry.targets[0];
152
+ return { provider, model: model || null };
153
+ }
154
+
155
+ // Write the gateway routing target via the injected updateSection (config RMW
156
+ // under lock). Identical to setTarget in scripts/gateway-model.mjs so the CLI
157
+ // --set/--enable and the elicitation tool persist through one code path.
158
+ export function writeGatewayTarget(updateSection, provider, model, meta = {}) {
159
+ updateSection('gateway', (current) => {
160
+ const cur = current && typeof current === 'object' ? current : {};
161
+ const inherit = meta?.mode === CLAUDE_CURRENT_MODE || provider === CLAUDE_CURRENT_CHOICE_ID || provider === CLAUDE_CURRENT_MODE;
162
+ const next = inherit
163
+ ? { ...cur, mode: CLAUDE_CURRENT_MODE, defaultProvider: meta?.provider || 'anthropic-oauth', defaultModel: meta?.model || model || null }
164
+ : { ...cur, mode: 'fixed', defaultProvider: provider, defaultModel: model };
165
+ for (const key of ['presetId', 'presetName', 'effort', 'displayEffort', 'fast']) delete next[key];
166
+ if (!inherit) {
167
+ if (meta?.presetId) next.presetId = meta.presetId;
168
+ if (meta?.presetName) next.presetName = meta.presetName;
169
+ if (meta?.effort) next.effort = meta.effort;
170
+ if (meta?.fast === true) next.fast = true;
171
+ }
172
+ return next;
173
+ });
174
+ updateSection('modules', (current) => {
175
+ const cur = current && typeof current === 'object' ? current : {};
176
+ return { ...cur, gateway: { ...(cur.gateway || {}), enabled: true } };
177
+ });
178
+ }
@@ -0,0 +1,78 @@
1
+ // Shared Claude Code settings.json env writer — the SINGLE source of truth for
2
+ // reading/mutating ~/.claude/settings.json `env.ANTHROPIC_BASE_URL`, used by
3
+ // BOTH scripts/gateway-model.mjs (--enable / --disable) AND scripts/uninstall.mjs
4
+ // (restoreGateway). One helper guarantees enable, disable, and uninstall never
5
+ // diverge on path resolution or write mechanics.
6
+ //
7
+ // Node-only (node:fs / node:os / node:path) — no plugin deps — so uninstall.mjs
8
+ // keeps its zero-dependency restore contract.
9
+ //
10
+ // Reuses the exact safe read-merge-write injectStatusLine() uses in
11
+ // hooks/session-start.cjs:485-532: read settings.json -> JSON.parse -> mutate
12
+ // the object -> write a .mixdog-tmp sibling -> atomic rename. Touches ONLY
13
+ // settings.env.ANTHROPIC_BASE_URL — never any other key.
14
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join, dirname } from 'node:path';
17
+
18
+ // Location: CLAUDE_CONFIG_DIR || ~/.claude (the doctor.mjs claudeConfigBase
19
+ // pattern). SSOT so enable / disable / uninstall all target the same file even
20
+ // when CLAUDE_CONFIG_DIR is set.
21
+ export function resolveSettingsPath() {
22
+ const base = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
23
+ return join(base, 'settings.json');
24
+ }
25
+
26
+ // Set (string url) or remove (url === null) settings.env.ANTHROPIC_BASE_URL.
27
+ // Returns { ok, changed?, path, error?, missing?, dryRun? }. `dryRun` previews
28
+ // without writing. A no-op (already matching / already absent) → ok:true,
29
+ // changed:false. Missing settings.json on remove → ok:true (treated clean);
30
+ // on add → ok:false (cannot create the file from scratch here).
31
+ export function setAnthropicBaseUrl(url, { dryRun = false } = {}) {
32
+ const p = resolveSettingsPath();
33
+ let raw;
34
+ try {
35
+ raw = readFileSync(p, 'utf8');
36
+ } catch (e) {
37
+ if (e && e.code === 'ENOENT') {
38
+ return url === null
39
+ ? { ok: true, changed: false, path: p, missing: true }
40
+ : { ok: false, error: `cannot read ${p}: ${e.message}`, path: p };
41
+ }
42
+ return { ok: false, error: `cannot read ${p}: ${e.message}`, path: p };
43
+ }
44
+ let settings;
45
+ try {
46
+ settings = JSON.parse(raw);
47
+ } catch (e) {
48
+ return { ok: false, error: `cannot parse ${p}: ${e.message}`, path: p };
49
+ }
50
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
51
+ return { ok: false, error: `${p} is not a JSON object`, path: p };
52
+ }
53
+ const envIsObj = settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env);
54
+ if (url === null) {
55
+ // REMOVE: only our key; leave the rest of env untouched. No-op if absent.
56
+ if (!envIsObj || !('ANTHROPIC_BASE_URL' in settings.env)) {
57
+ return { ok: true, changed: false, path: p };
58
+ }
59
+ if (dryRun) return { ok: true, changed: true, path: p, dryRun: true };
60
+ delete settings.env.ANTHROPIC_BASE_URL;
61
+ } else {
62
+ if (!envIsObj) settings.env = {};
63
+ if (settings.env.ANTHROPIC_BASE_URL === url) {
64
+ return { ok: true, changed: false, path: p };
65
+ }
66
+ if (dryRun) return { ok: true, changed: true, path: p, dryRun: true };
67
+ settings.env.ANTHROPIC_BASE_URL = url;
68
+ }
69
+ try {
70
+ mkdirSync(dirname(p), { recursive: true });
71
+ const tmp = p + '.mixdog-tmp';
72
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf8');
73
+ renameSync(tmp, p);
74
+ } catch (e) {
75
+ return { ok: false, error: `cannot write ${p}: ${e.message}`, path: p };
76
+ }
77
+ return { ok: true, changed: true, path: p };
78
+ }
@@ -463,6 +463,7 @@ let cachedInitDone = false; // initialized notification observed from clie
463
463
  let clientInitAnswered = false;
464
464
  let internalIdSeq = -1; // negative ids reserved for supervisor-internal requests
465
465
  const pendingFromClient = new Map(); // request id (from client) → { method }
466
+ const pendingFromChild = new Map(); // request id (from child/server) → { method }
466
467
  const pendingInternal = new Set(); // internal ids (init replay) — drop responses
467
468
  let stdinBuf = '';
468
469
  let stdoutBuf = '';
@@ -740,6 +741,11 @@ function handleClientLine(line) {
740
741
  }
741
742
  if (item.id !== undefined && item.method) {
742
743
  pendingFromClient.set(item.id, { method: item.method, ts: Date.now() });
744
+ } else if (item.id !== undefined && pendingFromChild.has(item.id)) {
745
+ // Response to a server-initiated request (for example
746
+ // elicitation/create). Forward below, but release the tracking entry
747
+ // now so a later stale response with the same id is not treated live.
748
+ pendingFromChild.delete(item.id);
743
749
  }
744
750
  }
745
751
  }
@@ -784,11 +790,13 @@ function handleClientLine(line) {
784
790
  : '[run-mcp] Invalid Request: missing or invalid method';
785
791
  sendErrorToClient(id, code, message);
786
792
  pendingFromClient.delete(item.id);
793
+ pendingFromChild.delete(item.id);
787
794
  }
788
795
  }
789
796
  } else if (msg && msg.id !== undefined && msg.method) {
790
797
  sendErrorToClient(msg.id, -32603, '[run-mcp] mcp child unavailable; retry');
791
798
  pendingFromClient.delete(msg.id);
799
+ pendingFromChild.delete(msg.id);
792
800
  }
793
801
  }
794
802
  }
@@ -843,6 +851,12 @@ function handleChildLine(line) {
843
851
  for (const item of scanned) {
844
852
  if (item && item.id !== undefined) {
845
853
  if (pendingInternal.has(item.id)) { internalIds.add(item.id); pendingInternal.delete(item.id); _maybeResolveLivenessPong(item.id); }
854
+ else if (item.method) {
855
+ // Server-initiated request bound for the MCP client, e.g.
856
+ // elicitation/create. This is not a response to a client request;
857
+ // track it so the client's later response can flow back to child.
858
+ pendingFromChild.set(item.id, { method: item.method, ts: Date.now() });
859
+ }
846
860
  else {
847
861
  pendingFromClient.delete(item.id);
848
862
  // Same latch as the scalar path below — keep the invariant consistent
@@ -871,6 +885,15 @@ function handleChildLine(line) {
871
885
  _maybeResolveLivenessPong(scanned.id);
872
886
  return;
873
887
  }
888
+ if (scanned.method) {
889
+ // Server-initiated JSON-RPC request. MCP features such as elicitation are
890
+ // bidirectional: the child asks the client to show UI, then the client
891
+ // replies with the same id. Forward it instead of treating it as an
892
+ // unknown response.
893
+ pendingFromChild.set(scanned.id, { method: scanned.method, ts: Date.now() });
894
+ writeToClient(line);
895
+ return;
896
+ }
874
897
  if (!pendingFromClient.has(scanned.id)) {
875
898
  // Unknown id — neither an internal replay nor an outstanding client
876
899
  // request. Forwarding it would let a stale/rogue child line surface
@@ -932,6 +955,7 @@ function handleChildGone(why) {
932
955
  supLog(`[child-stderr-tail exitCode=${why.exitCode ?? 'n/a'} signal=${why.signal ?? 'n/a'} bytes=0] (empty)`);
933
956
  }
934
957
  const _pendingClientAtGone = pendingFromClient.size;
958
+ const _pendingChildAtGone = pendingFromChild.size;
935
959
  const _pendingInternalAtGone = pendingInternal.size;
936
960
  // First-boot recovery invariant: the client's initialize must receive exactly
937
961
  // one success response, from whichever child completes the handshake. If it
@@ -956,6 +980,7 @@ function handleChildGone(why) {
956
980
  if (_preserveInitId !== undefined && _preservedInit !== undefined) {
957
981
  pendingFromClient.set(_preserveInitId, _preservedInit);
958
982
  }
983
+ pendingFromChild.clear();
959
984
  pendingInternal.clear();
960
985
  // Fresh child = fresh response path; discard any in-flight liveness probe.
961
986
  _livenessPingId = null;
@@ -986,7 +1011,7 @@ function handleChildGone(why) {
986
1011
  process.stderr.write(_crashMsg + '\n');
987
1012
  supLog(_crashMsg);
988
1013
  } else {
989
- const _respawnMsg = `[run-mcp] ${why.log} — respawning (#${recentRestarts.length}); pendingClient=${_pendingClientAtGone} pendingInternal=${_pendingInternalAtGone} shuttingDown=${shuttingDown}`;
1014
+ const _respawnMsg = `[run-mcp] ${why.log} — respawning (#${recentRestarts.length}); pendingClient=${_pendingClientAtGone} pendingChild=${_pendingChildAtGone} pendingInternal=${_pendingInternalAtGone} shuttingDown=${shuttingDown}`;
990
1015
  process.stderr.write(_respawnMsg + '\n');
991
1016
  supLog(_respawnMsg);
992
1017
  }
@@ -9,9 +9,16 @@ import { fileURLToPath } from 'url';
9
9
  const REPO = join(fileURLToPath(import.meta.url), '..', '..');
10
10
  const LAUNCHER = join(REPO, 'bin', 'statusline-launcher.mjs');
11
11
  const LIB = join(REPO, 'bin', 'statusline-lib.mjs');
12
+ const ROUTE = join(REPO, 'bin', 'statusline-route.mjs');
13
+ const CLAUDE_CURRENT = join(REPO, 'src', 'gateway', 'claude-current.mjs');
12
14
  const SAMPLE = JSON.stringify({
13
15
  model: { display_name: 'Sonnet 4.5' },
14
16
  session_id: 'smoke-session',
17
+ context_window: { used_percentage: 1 },
18
+ rate_limits: {
19
+ five_hour: { used_percentage: 12, resets_at: Math.floor((Date.now() + 60 * 60_000) / 1000) },
20
+ seven_day: { used_percentage: 34 },
21
+ },
15
22
  });
16
23
 
17
24
  let failed = 0;
@@ -24,10 +31,18 @@ function assert(label, cond) {
24
31
  // The launcher reads ~/.claude/plugins/installed_plugins.json — point HOME at
25
32
  // a temp dir so we control the manifest without touching the real one.
26
33
  const tmp = mkdtempSync(join(tmpdir(), 'mixdog-sl-launcher-'));
27
- function run(homeDir) {
34
+ function run(homeDir, extraEnv = {}) {
35
+ const dataDir = join(homeDir, '.claude', 'plugins', 'data', 'mixdog-trib-plugin');
28
36
  return spawnSync(process.execPath, [LAUNCHER], {
29
37
  input: SAMPLE,
30
- env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, CLAUDE_CONFIG_DIR: join(homeDir, '.claude') },
38
+ env: {
39
+ ...process.env,
40
+ HOME: homeDir,
41
+ USERPROFILE: homeDir,
42
+ CLAUDE_CONFIG_DIR: join(homeDir, '.claude'),
43
+ CLAUDE_PLUGIN_DATA: dataDir,
44
+ ...extraEnv,
45
+ },
31
46
  encoding: 'utf8',
32
47
  });
33
48
  }
@@ -39,8 +54,11 @@ try {
39
54
  // path is actually exercised, not the native shim.
40
55
  const installDir = join(tmp, 'fake-install');
41
56
  mkdirSync(join(installDir, 'bin'), { recursive: true });
57
+ mkdirSync(join(installDir, 'src', 'gateway'), { recursive: true });
42
58
  copyFileSync(LAUNCHER, join(installDir, 'bin', 'statusline-launcher.mjs'));
43
59
  copyFileSync(LIB, join(installDir, 'bin', 'statusline-lib.mjs'));
60
+ copyFileSync(ROUTE, join(installDir, 'bin', 'statusline-route.mjs'));
61
+ copyFileSync(CLAUDE_CURRENT, join(installDir, 'src', 'gateway', 'claude-current.mjs'));
44
62
 
45
63
  const manifestDir = join(tmp, '.claude', 'plugins');
46
64
  mkdirSync(manifestDir, { recursive: true });
@@ -68,6 +86,141 @@ try {
68
86
  assert('non-empty output with lib-only install', (ok.stdout || '').trim().length > 0);
69
87
  assert('launcher output matches direct renderStatusLine (lib path exercised)',
70
88
  (ok.stdout || '') === direct);
89
+ assert('rate-limit fallback renders without gateway quota',
90
+ (ok.stdout || '').includes('5H') && (ok.stdout || '').includes('7D'));
91
+
92
+ // (1b) Gateway status overrides the displayed model/context, but when the
93
+ // gateway advert has no quota windows, native Claude Code OAuth rate limits
94
+ // from stdin must still render as the fallback.
95
+ const runtimeRoot = join(tmp, 'runtime');
96
+ mkdirSync(runtimeRoot, { recursive: true });
97
+ writeFileSync(join(runtimeRoot, 'active-instance.json'), JSON.stringify({
98
+ gateway_port: 3468,
99
+ gateway_server_pid: process.pid,
100
+ gateway_provider: 'openai-oauth',
101
+ gateway_model: 'gpt-5.5',
102
+ gateway_model_display: 'GPT-5.5',
103
+ gateway_context_window: 272000,
104
+ gateway_context_used_pct: 3,
105
+ gateway_updated_at: Date.now(),
106
+ }));
107
+ const gatewayFallback = run(tmp, { MIXDOG_RUNTIME_ROOT: runtimeRoot });
108
+ assert('gateway status keeps native OAuth rate-limit fallback',
109
+ (gatewayFallback.stdout || '').includes('GPT-5.5')
110
+ && (gatewayFallback.stdout || '').includes('5H')
111
+ && (gatewayFallback.stdout || '').includes('7D'));
112
+
113
+ writeFileSync(join(runtimeRoot, 'active-instance.json'), JSON.stringify({
114
+ gateway_port: 3468,
115
+ gateway_server_pid: process.pid,
116
+ gateway_provider: 'deepseek',
117
+ gateway_provider_kind: 'api',
118
+ gateway_model: 'deepseek-v4-pro',
119
+ gateway_model_display: 'DeepSeek V4 Pro',
120
+ gateway_context_window: 128000,
121
+ gateway_context_used_pct: 2,
122
+ gateway_route_spend: { label: 'SESS', costUsd: 0.1234, source: 'local-route' },
123
+ gateway_updated_at: Date.now(),
124
+ }));
125
+ const apiSpend = run(tmp, { MIXDOG_RUNTIME_ROOT: runtimeRoot, COLUMNS: '140' });
126
+ assert('api route shows current-section spend instead of native OAuth fallback',
127
+ (apiSpend.stdout || '').includes('SESS')
128
+ && (apiSpend.stdout || '').includes('$0.123')
129
+ && !(apiSpend.stdout || '').includes('5H 12%'));
130
+
131
+ writeFileSync(join(runtimeRoot, 'active-instance.json'), JSON.stringify({
132
+ gateway_port: 3468,
133
+ gateway_server_pid: process.pid,
134
+ gateway_provider: 'opencode-go',
135
+ gateway_provider_kind: 'quota-api',
136
+ gateway_model: 'glm-5.2',
137
+ gateway_model_display: 'GLM-5.2',
138
+ gateway_context_window: 1000000,
139
+ gateway_context_used_pct: 4,
140
+ gateway_quota_windows: [
141
+ { label: '5H', limitUsd: 12, usedUsd: 1.2, remainingUsd: 10.8, usedPct: 10, resetAt: Date.now() + 30 * 60_000 },
142
+ { label: '7D', limitUsd: 30, usedUsd: 3, remainingUsd: 27, usedPct: 10, resetAt: Date.now() + 6 * 24 * 60 * 60_000 },
143
+ { label: 'M', limitUsd: 60, usedUsd: 6, remainingUsd: 54, usedPct: 10, resetAt: Date.now() + 20 * 24 * 60 * 60_000 },
144
+ ],
145
+ gateway_route_spend: { label: 'SESS', costUsd: 0.4321, source: 'local-route' },
146
+ gateway_updated_at: Date.now(),
147
+ }));
148
+ const opencodeGoSpend = run(tmp, { MIXDOG_RUNTIME_ROOT: runtimeRoot, COLUMNS: '140' });
149
+ assert('opencode-go route shows quota windows plus current-section spend',
150
+ (opencodeGoSpend.stdout || '').includes('GLM-5.2')
151
+ && (opencodeGoSpend.stdout || '').includes('5H')
152
+ && (opencodeGoSpend.stdout || '').includes('7D')
153
+ && (opencodeGoSpend.stdout || '').includes('M')
154
+ && (opencodeGoSpend.stdout || '').includes('SESS')
155
+ && (opencodeGoSpend.stdout || '').includes('$0.432')
156
+ && (opencodeGoSpend.stdout || '').includes('↻'));
157
+
158
+ writeFileSync(join(runtimeRoot, 'active-instance.json'), JSON.stringify({
159
+ gateway_port: 3468,
160
+ gateway_server_pid: process.pid,
161
+ gateway_provider: 'grok-oauth',
162
+ gateway_provider_kind: 'oauth',
163
+ gateway_model: 'grok-build',
164
+ gateway_model_display: 'Grok Build',
165
+ gateway_context_window: 256000,
166
+ gateway_context_used_pct: 5,
167
+ gateway_quota_windows: [
168
+ { label: 'M', source: 'grok-build-billing', usedPct: 46.16, usedCredits: 69245, limitCredits: 150000, remainingCredits: 80755, resetAt: Date.now() + 20 * 24 * 60 * 60_000 },
169
+ ],
170
+ gateway_updated_at: Date.now(),
171
+ }));
172
+ const grokCredits = run(tmp, { MIXDOG_RUNTIME_ROOT: runtimeRoot, COLUMNS: '140' });
173
+ assert('grok oauth route shows Grok Build credit quota',
174
+ (grokCredits.stdout || '').includes('Grok Build')
175
+ && (grokCredits.stdout || '').includes('M')
176
+ && (grokCredits.stdout || '').includes('cr left')
177
+ && (grokCredits.stdout || '').includes('↻'));
178
+
179
+ // (1c) On the first statusline tick the gateway advert may not exist yet.
180
+ // The configured route should still win immediately so Claude Code's native
181
+ // model display does not flash before the gateway publishes active status.
182
+ const coldHome = mkdtempSync(join(tmpdir(), 'mixdog-sl-cold-'));
183
+ const coldData = join(coldHome, '.claude', 'plugins', 'data', 'mixdog-trib-plugin');
184
+ mkdirSync(coldData, { recursive: true });
185
+ writeFileSync(join(coldData, 'mixdog-config.json'), JSON.stringify({
186
+ modules: { gateway: { enabled: true } },
187
+ gateway: { defaultProvider: 'openai-oauth', defaultModel: 'gpt-5.5' },
188
+ }));
189
+ writeFileSync(join(coldData, 'openai-oauth-models.json'), JSON.stringify({
190
+ models: [{ id: 'gpt-5.5', display: 'GPT-5.5', contextWindow: 272000 }],
191
+ }));
192
+ const coldRuntime = join(coldHome, 'runtime');
193
+ mkdirSync(coldRuntime, { recursive: true });
194
+ const cold = run(tmp, { CLAUDE_PLUGIN_DATA: coldData, MIXDOG_RUNTIME_ROOT: coldRuntime });
195
+ assert('configured gateway route renders before active advert',
196
+ (cold.stdout || '').includes('GPT-5.5')
197
+ && !(cold.stdout || '').includes('Sonnet'));
198
+ try { rmSync(coldHome, { recursive: true, force: true }); } catch {}
199
+
200
+ const inheritHome = mkdtempSync(join(tmpdir(), 'mixdog-sl-inherit-'));
201
+ const inheritData = join(inheritHome, '.claude', 'plugins', 'data', 'mixdog-trib-plugin');
202
+ mkdirSync(inheritData, { recursive: true });
203
+ mkdirSync(join(inheritHome, '.claude', 'plugins'), { recursive: true });
204
+ writeFileSync(
205
+ join(inheritHome, '.claude', 'plugins', 'installed_plugins.json'),
206
+ JSON.stringify({ plugins: { 'mixdog@trib-plugin': [{ installPath: installDir }] } })
207
+ );
208
+ writeFileSync(join(inheritHome, '.claude', 'settings.json'), JSON.stringify({
209
+ model: 'opus[1m]',
210
+ effortLevel: 'high',
211
+ alwaysThinkingEnabled: true,
212
+ }));
213
+ writeFileSync(join(inheritData, 'mixdog-config.json'), JSON.stringify({
214
+ modules: { gateway: { enabled: true } },
215
+ gateway: { mode: 'claude-current' },
216
+ }));
217
+ const inheritRuntime = join(inheritHome, 'runtime');
218
+ mkdirSync(inheritRuntime, { recursive: true });
219
+ const inherit = run(inheritHome, { CLAUDE_PLUGIN_DATA: inheritData, MIXDOG_RUNTIME_ROOT: inheritRuntime, COLUMNS: '140' });
220
+ assert('claude-current route resolves opus[1m] to Opus 4.8 before active advert',
221
+ (inherit.stdout || '').includes('Opus 4.8')
222
+ && (inherit.stdout || '').includes('HIGH'));
223
+ try { rmSync(inheritHome, { recursive: true, force: true }); } catch {}
71
224
 
72
225
  // (2) Manifest missing → fail-soft: exit 0 + minimal `mixdog` line.
73
226
  const empty = mkdtempSync(join(tmpdir(), 'mixdog-sl-empty-'));
@@ -3,10 +3,13 @@
3
3
 
4
4
  // mixdog uninstall restore helper.
5
5
  //
6
- // Restores the two user-owned surfaces mixdog may have modified at install
7
- // time — the global ~/.claude/CLAUDE.md and the autoMemoryEnabled /
8
- // awaySummaryEnabled keys in ~/.claude/settings.json — from the snapshot the
9
- // install flow captured under the backup root. It deletes NOTHING on its own;
6
+ // Restores the user-owned surfaces mixdog may have modified the global
7
+ // ~/.claude/CLAUDE.md and the autoMemoryEnabled / awaySummaryEnabled keys in
8
+ // ~/.claude/settings.json — from the snapshot the install flow captured under
9
+ // the backup root. ALSO removes the gateway's env.ANTHROPIC_BASE_URL entry
10
+ // from settings.json (snapshot-free, runtime addition from
11
+ // `/mixdog:model --enable`) so uninstall never leaves Claude Code's main model
12
+ // pointing at a dead local gateway. It deletes NOTHING else on its own;
10
13
  // data/plugin/backup removal is printed as manual steps for the user to run.
11
14
  //
12
15
  // Restore contract (produced in parallel by the install/seed flow):
@@ -24,6 +27,10 @@ import path from 'node:path';
24
27
  import os from 'node:os';
25
28
  import { fileURLToPath } from 'node:url';
26
29
  import { createRequire } from 'node:module';
30
+ // Shared settings.json env writer — SAME helper gateway-model.mjs --enable /
31
+ // --disable use, so gateway cleanup here can never diverge from how the entry
32
+ // was written. Node-only, no plugin deps (keeps the zero-dep restore contract).
33
+ import { resolveSettingsPath, setAnthropicBaseUrl } from './lib/gateway-settings.mjs';
27
34
 
28
35
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
36
  const require = createRequire(import.meta.url);
@@ -67,7 +74,7 @@ function readFileOrNull(p) {
67
74
 
68
75
  // (a) Restore ~/.claude/CLAUDE.md.
69
76
  function restoreClaudeMd() {
70
- log('\n[1/2] CLAUDE.md');
77
+ log('\n[1/3] CLAUDE.md');
71
78
  const snapshot = readFileOrNull(CLAUDE_MD_SNAPSHOT);
72
79
 
73
80
  if (snapshot !== null) {
@@ -102,7 +109,7 @@ function restoreClaudeMd() {
102
109
 
103
110
  // (b) Restore autoMemoryEnabled / awaySummaryEnabled in settings.json.
104
111
  function restoreSettings() {
105
- log('\n[2/2] settings.json');
112
+ log('\n[2/3] settings.json');
106
113
  const snapshotRaw = readFileOrNull(SETTINGS_SNAPSHOT);
107
114
  if (snapshotRaw === null) {
108
115
  log(' nothing to restore (no settings snapshot)');
@@ -165,7 +172,36 @@ function restoreSettings() {
165
172
  log(` updated ${SETTINGS_PATH}: ${changes.join(', ')}`);
166
173
  }
167
174
 
168
- // (c) Print remaining manual steps this script deletes nothing.
175
+ // (c) Gateway cleanup ALWAYS remove the gateway's ANTHROPIC_BASE_URL env
176
+ // entry from settings.json so an uninstall never leaves Claude Code pointing at
177
+ // a dead local gateway (broken main model). Runs unconditionally — even if the
178
+ // user never called `/mixdog:model --disable` — and is snapshot-free because
179
+ // the key is a runtime addition (only `/mixdog:model --enable` writes it), not
180
+ // a pre-install user value. Delegates to the SHARED setAnthropicBaseUrl(null)
181
+ // helper — the SAME code path --enable / --disable use — so removal can never
182
+ // diverge from how the entry was written (incl. CLAUDE_CONFIG_DIR resolution).
183
+ function restoreGateway() {
184
+ log('\n[3/3] gateway (settings.json env)');
185
+ const res = setAnthropicBaseUrl(null, { dryRun: DRY_RUN });
186
+ const target = res.path || resolveSettingsPath();
187
+ if (!res.ok) {
188
+ log(` skipped: ${res.error}`);
189
+ return;
190
+ }
191
+ if (!res.changed) {
192
+ log(res.missing
193
+ ? ' nothing to clean (no settings.json)'
194
+ : ' nothing to clean (no gateway ANTHROPIC_BASE_URL in settings.json)');
195
+ return;
196
+ }
197
+ if (res.dryRun) {
198
+ log(` ${tag()}remove env.ANTHROPIC_BASE_URL from ${target} (gateway restore)`);
199
+ return;
200
+ }
201
+ log(` removed env.ANTHROPIC_BASE_URL from ${target} — Claude Code reverts to api.anthropic.com`);
202
+ }
203
+
204
+ // (d) Print remaining manual steps — this script deletes nothing.
169
205
  function printManualSteps() {
170
206
  const dataDir = path.join(HOME, '.claude', 'plugins', 'data', 'mixdog-trib-plugin');
171
207
  log('\nManual steps (not performed by this script):');
@@ -188,6 +224,7 @@ function main() {
188
224
 
189
225
  restoreClaudeMd();
190
226
  restoreSettings();
227
+ restoreGateway();
191
228
  printManualSteps();
192
229
 
193
230
  log(DRY_RUN ? '\nDry run complete. Nothing was modified.' : '\nRestore complete.');