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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/UNINSTALL.md +7 -4
- package/bin/statusline-lib.mjs +29 -6
- package/bin/statusline-route.mjs +273 -0
- package/bin/statusline.mjs +31 -8
- package/commands/model.md +61 -0
- package/hooks/session-start.cjs +15 -1
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +1 -1
- package/rules/lead/01-general.md +3 -0
- package/scripts/gateway-model.mjs +596 -0
- package/scripts/lib/gateway-inventory.mjs +178 -0
- package/scripts/lib/gateway-settings.mjs +78 -0
- package/scripts/run-mcp.mjs +26 -1
- package/scripts/statusline-launcher-smoke.mjs +155 -2
- package/scripts/uninstall.mjs +44 -7
- package/server-main.mjs +252 -11
- package/src/agent/index.mjs +96 -5
- package/src/agent/orchestrator/bridge-trace.mjs +18 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +4 -1
- package/src/agent/orchestrator/providers/anthropic.mjs +6 -2
- package/src/agent/orchestrator/session/loop.mjs +145 -4
- package/src/agent/orchestrator/session/trim.mjs +1 -1
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +62 -6
- package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
- package/src/agent/tool-defs.mjs +10 -3
- package/src/channels/lib/runtime-paths.mjs +39 -4
- package/src/gateway/claude-current.mjs +255 -0
- package/src/gateway/oauth-usage.mjs +598 -0
- package/src/gateway/route-meta.mjs +629 -0
- package/src/gateway/server.mjs +713 -0
- package/src/shared/llm/cost.mjs +1 -1
- package/src/shared/seed.mjs +25 -0
- 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
|
+
}
|
package/scripts/run-mcp.mjs
CHANGED
|
@@ -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: {
|
|
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-'));
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
// mixdog uninstall restore helper.
|
|
5
5
|
//
|
|
6
|
-
// Restores the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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/
|
|
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/
|
|
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)
|
|
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.');
|