mixdog 0.7.14 → 0.7.16
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/hooks/session-start.cjs +10 -2
- package/hooks/shim-launcher.cjs +15 -1
- package/lib/keychain-cjs.cjs +32 -5
- package/lib/plugin-paths.cjs +10 -2
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +1 -1
- package/setup/config-merge.mjs +1 -8
- package/setup/launch-core.mjs +184 -73
- package/setup/locate-claude.mjs +22 -4
- package/setup/setup-server.mjs +37 -21
- package/src/agent/orchestrator/config.mjs +7 -11
- package/src/agent/orchestrator/tools/builtin/path-utils.mjs +23 -0
- package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +36 -10
- package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +13 -4
- package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +11 -1
- package/src/agent/orchestrator/tools/graph-manifest.json +7 -7
- package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +11 -1
- package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
- package/src/channels/index.mjs +10 -1
- package/src/channels/lib/executor.mjs +59 -24
- package/src/channels/lib/hook-pipe-server.mjs +8 -1
- package/src/memory/lib/runtime-fetcher.mjs +19 -3
- package/src/search/lib/web-tools.mjs +13 -1
- package/src/shared/config.mjs +26 -4
- package/src/shared/open-url.mjs +35 -10
- package/src/shared/user-cwd.mjs +14 -2
- package/src/shared/wsl.mjs +35 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"description": "Claude Code all-in-one agent plugin — autonomous agents, continuous memory, cost-aware sub-agents, and syntax-aware code editing.",
|
|
5
5
|
"hooks": "./hooks/hooks.json",
|
|
6
6
|
"mcpServers": {
|
package/hooks/session-start.cjs
CHANGED
|
@@ -1257,11 +1257,19 @@ async function runRulesPart() {
|
|
|
1257
1257
|
// only when it is not already alive and never opens a browser window, so
|
|
1258
1258
|
// this is a cheap no-op once the server is running.
|
|
1259
1259
|
try {
|
|
1260
|
-
spawn('bun', [path.join(PLUGIN_ROOT, 'setup', 'launch.mjs'), '--prewarm'], {
|
|
1260
|
+
const _prewarm = spawn('bun', [path.join(PLUGIN_ROOT, 'setup', 'launch.mjs'), '--prewarm'], {
|
|
1261
1261
|
detached: true,
|
|
1262
1262
|
stdio: 'ignore',
|
|
1263
1263
|
windowsHide: true,
|
|
1264
|
-
})
|
|
1264
|
+
});
|
|
1265
|
+
// ENOENT (bun not on PATH) and other spawn failures surface ASYNC as an
|
|
1266
|
+
// 'error' event, NOT via the try/catch above. Without a listener Node
|
|
1267
|
+
// throws it as an uncaught exception and crashes the hook. Fail soft:
|
|
1268
|
+
// prewarm is a best-effort optimization, never required for a session.
|
|
1269
|
+
_prewarm.on('error', (e) => {
|
|
1270
|
+
teeStderr(`[session-start] prewarm spawn failed (non-fatal): ${(e && e.message) || e}\n`);
|
|
1271
|
+
});
|
|
1272
|
+
_prewarm.unref();
|
|
1265
1273
|
} catch {}
|
|
1266
1274
|
|
|
1267
1275
|
try {
|
package/hooks/shim-launcher.cjs
CHANGED
|
@@ -48,4 +48,18 @@ const child = spawnSync(shimPath, process.argv.slice(2), {
|
|
|
48
48
|
windowsHide: true,
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// spawnSync reports a failure to even START the child (EACCES on a non-exec
|
|
52
|
+
// bit, ENOEXEC on a corrupt/foreign binary, etc.) via child.error, and leaves
|
|
53
|
+
// child.status === null. The old `child.status ?? 0` collapsed that null to a
|
|
54
|
+
// SUCCESS exit code, masking a broken shim as a clean run. Surface it instead.
|
|
55
|
+
if (child.error) {
|
|
56
|
+
process.stderr.write(`[mixdog-shim] failed to launch ${shimPath}: ${child.error.message}\n`);
|
|
57
|
+
process.exit(126); // 126 = command found but not executable / could not run
|
|
58
|
+
}
|
|
59
|
+
if (child.status === null) {
|
|
60
|
+
// No error object but no exit code either: the child was killed by a signal
|
|
61
|
+
// (child.signal set) or otherwise terminated abnormally. Report non-zero.
|
|
62
|
+
process.stderr.write(`[mixdog-shim] ${shimPath} terminated abnormally${child.signal ? ` (signal ${child.signal})` : ''}\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
process.exit(child.status);
|
package/lib/keychain-cjs.cjs
CHANGED
|
@@ -6,7 +6,19 @@ const fs = require('fs');
|
|
|
6
6
|
const { resolvePluginData } = require('./plugin-paths.cjs');
|
|
7
7
|
|
|
8
8
|
const SERVICE = 'mixdog';
|
|
9
|
-
|
|
9
|
+
// Shared bound for every synchronous keychain backend (DPAPI/PowerShell on
|
|
10
|
+
// Windows, security(1) on macOS). A single env override keeps them consistent.
|
|
11
|
+
const KEYCHAIN_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_TIMEOUT_MS || 15000);
|
|
12
|
+
const POWERSHELL_TIMEOUT_MS = KEYCHAIN_TIMEOUT_MS;
|
|
13
|
+
|
|
14
|
+
// CommonJS module: cannot import the ESM src/shared/wsl.mjs, so inline an
|
|
15
|
+
// equivalent WSL check (process.platform reports 'linux' inside WSL).
|
|
16
|
+
function isWSL() {
|
|
17
|
+
if (process.platform !== 'linux') return false;
|
|
18
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true;
|
|
19
|
+
try { return /microsoft|wsl/i.test(fs.readFileSync('/proc/version', 'utf8')); }
|
|
20
|
+
catch { return false; }
|
|
21
|
+
}
|
|
10
22
|
|
|
11
23
|
// ---------------------------------------------------------------------------
|
|
12
24
|
// Helpers
|
|
@@ -81,19 +93,30 @@ function dpApiFile(account) {
|
|
|
81
93
|
// darwin — security(1)
|
|
82
94
|
// ---------------------------------------------------------------------------
|
|
83
95
|
|
|
96
|
+
// Bound security(1) calls so a stuck Keychain prompt (locked keychain, GUI
|
|
97
|
+
// unlock dialog with no display) cannot block hook/server callers forever —
|
|
98
|
+
// matching the Windows DPAPI and Linux keytar timeouts.
|
|
99
|
+
function darwinRun(args) {
|
|
100
|
+
const r = run('security', args, { timeout: KEYCHAIN_TIMEOUT_MS });
|
|
101
|
+
if (r.error && r.error.code === 'ETIMEDOUT') {
|
|
102
|
+
throw new Error(`[keychain] security command timed out after ${KEYCHAIN_TIMEOUT_MS}ms`);
|
|
103
|
+
}
|
|
104
|
+
return r;
|
|
105
|
+
}
|
|
106
|
+
|
|
84
107
|
function darwinGet(account) {
|
|
85
|
-
const r =
|
|
108
|
+
const r = darwinRun(['find-generic-password', '-a', account, '-s', SERVICE, '-w']);
|
|
86
109
|
if (r.status !== 0) return null;
|
|
87
110
|
return r.stdout.trimEnd();
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
function darwinSet(account, value) {
|
|
91
|
-
const r =
|
|
114
|
+
const r = darwinRun(['add-generic-password', '-a', account, '-s', SERVICE, '-w', value, '-U']);
|
|
92
115
|
if (r.status !== 0) throw new Error(`[keychain] security set failed: ${r.stderr || r.stdout}`);
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
function darwinDelete(account) {
|
|
96
|
-
const r =
|
|
119
|
+
const r = darwinRun(['delete-generic-password', '-a', account, '-s', SERVICE]);
|
|
97
120
|
if (r.status !== 0) throw new Error(`[keychain] security delete failed: ${r.stderr || r.stdout}`);
|
|
98
121
|
}
|
|
99
122
|
|
|
@@ -112,7 +135,11 @@ function loadKeytar() {
|
|
|
112
135
|
throw new Error(
|
|
113
136
|
'[keychain] keytar is not installed — run: npm install keytar\n' +
|
|
114
137
|
' Requires libsecret-dev (Debian/Ubuntu) or libsecret (other distros).\n' +
|
|
115
|
-
' Cannot access credentials on this platform without it.'
|
|
138
|
+
' Cannot access credentials on this platform without it.' +
|
|
139
|
+
(isWSL()
|
|
140
|
+
? '\n Detected WSL: there is usually no Secret Service here; ' +
|
|
141
|
+
'set the relevant MIXDOG_*/PROVIDER_API_KEY env var instead.'
|
|
142
|
+
: '')
|
|
116
143
|
);
|
|
117
144
|
}
|
|
118
145
|
return _keytarMod;
|
package/lib/plugin-paths.cjs
CHANGED
|
@@ -29,6 +29,14 @@ const fs = require('fs');
|
|
|
29
29
|
const DEFAULT_PLUGIN = 'mixdog';
|
|
30
30
|
const DEFAULT_MARKETPLACE = 'trib-plugin';
|
|
31
31
|
|
|
32
|
+
// Claude config base — honours CLAUDE_CONFIG_DIR when set, otherwise the
|
|
33
|
+
// real Claude Code default of ~/.claude (matches settings-loader.cjs,
|
|
34
|
+
// statusline, doctor, install). Only ADDS the env override; never relocates
|
|
35
|
+
// the default base.
|
|
36
|
+
function claudeConfigBase() {
|
|
37
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
function readPluginManifestName(root) {
|
|
33
41
|
try {
|
|
34
42
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
@@ -46,14 +54,14 @@ function resolvePluginData() {
|
|
|
46
54
|
if (/^\d+\.\d+\.\d+/.test(dirName)) {
|
|
47
55
|
const pluginName = path.basename(path.join(root, '..'));
|
|
48
56
|
const marketplace = path.basename(path.join(root, '..', '..'));
|
|
49
|
-
return path.join(
|
|
57
|
+
return path.join(claudeConfigBase(), 'plugins', 'data', `${pluginName}-${marketplace}`);
|
|
50
58
|
}
|
|
51
59
|
// Marketplace layout: .../marketplaces/{marketplace}/
|
|
52
60
|
// The root dir itself is the marketplace. Plugin name lives in the
|
|
53
61
|
// manifest; fall back to DEFAULT_PLUGIN when it's unreadable.
|
|
54
62
|
const marketplace = dirName;
|
|
55
63
|
const pluginName = readPluginManifestName(root);
|
|
56
|
-
return path.join(
|
|
64
|
+
return path.join(claudeConfigBase(), 'plugins', 'data', `${pluginName}-${marketplace}`);
|
|
57
65
|
}
|
|
58
66
|
throw new Error('[plugin-paths] CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_ROOT are both unset — cannot resolve plugin data dir outside of Claude Code.');
|
|
59
67
|
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"description": "Claude Code all-in-one bridge plugin: role-based bridge workers, continuous memory, and syntax-aware code editing.",
|
|
5
5
|
"author": "mixdog contributors <dev@tribgames.com>",
|
|
6
6
|
"license": "MIT",
|
package/setup/config-merge.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getSearchApiKey,
|
|
13
13
|
getAgentApiKey,
|
|
14
14
|
diagnoseDiscordTokenValue,
|
|
15
|
+
AGENT_PROVIDER_ENV,
|
|
15
16
|
} from '../src/shared/config.mjs';
|
|
16
17
|
|
|
17
18
|
const SUPPORTED_PROVIDERS = [
|
|
@@ -19,14 +20,6 @@ const SUPPORTED_PROVIDERS = [
|
|
|
19
20
|
'tavily', 'firecrawl', 'exa',
|
|
20
21
|
];
|
|
21
22
|
|
|
22
|
-
const AGENT_PROVIDER_ENV = Object.freeze({
|
|
23
|
-
openai: 'OPENAI_API_KEY',
|
|
24
|
-
anthropic: 'ANTHROPIC_API_KEY',
|
|
25
|
-
gemini: 'GEMINI_API_KEY',
|
|
26
|
-
deepseek: 'DEEPSEEK_API_KEY',
|
|
27
|
-
xai: 'XAI_API_KEY',
|
|
28
|
-
});
|
|
29
|
-
|
|
30
23
|
function envSecretPresent(account) {
|
|
31
24
|
const key = 'MIXDOG_' + String(account || '').replace(/[.\s]+/g, '_').toUpperCase();
|
|
32
25
|
if (process.env[key]) return true;
|
package/setup/launch-core.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// is derived inside launchConfigUi().
|
|
15
15
|
|
|
16
16
|
import { spawn, execSync } from 'child_process';
|
|
17
|
-
import { openSync, closeSync, readFileSync, writeSync, writeFileSync, appendFileSync } from 'fs';
|
|
17
|
+
import { openSync, closeSync, readFileSync, writeSync, writeFileSync, appendFileSync, statSync, unlinkSync } from 'fs';
|
|
18
18
|
import { join, dirname } from 'path';
|
|
19
19
|
import { fileURLToPath } from 'url';
|
|
20
20
|
import http from 'http';
|
|
@@ -122,6 +122,154 @@ function sleep(ms) {
|
|
|
122
122
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Cross-process launch mutex. Only ONE launcher runs the version-check →
|
|
126
|
+
// reclaim critical section at a time. Now that prewarm takes over every
|
|
127
|
+
// session, two concurrent session starts can both observe the same stale
|
|
128
|
+
// server; without serialization the slower one kills the faster one's
|
|
129
|
+
// freshly-spawned correct server. Exclusive-create lock file + stale-takeover
|
|
130
|
+
// (a holder that crashed mid-launch leaves a lock no older than STALE_MS).
|
|
131
|
+
// Best-effort: on timeout or an unexpected fs error we proceed lock-free
|
|
132
|
+
// rather than deadlock the launcher.
|
|
133
|
+
const LAUNCH_LOCK_PATH = join(tmpdir(), `mixdog-setup-launch-${PORT}.lock`);
|
|
134
|
+
const LAUNCH_LOCK_STALE_MS = 30000;
|
|
135
|
+
|
|
136
|
+
async function acquireLaunchLock(trace, timeoutMs = 20000) {
|
|
137
|
+
const deadline = Date.now() + timeoutMs;
|
|
138
|
+
while (Date.now() <= deadline) {
|
|
139
|
+
try {
|
|
140
|
+
const fd = openSync(LAUNCH_LOCK_PATH, 'wx'); // exclusive create — EEXIST if held
|
|
141
|
+
writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
return true;
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e?.code !== 'EEXIST') { trace(`launch-lock: unexpected ${e?.code || e} → proceeding lock-free`); return false; }
|
|
146
|
+
let stale = false;
|
|
147
|
+
try { stale = (Date.now() - statSync(LAUNCH_LOCK_PATH).mtimeMs) > LAUNCH_LOCK_STALE_MS; } catch {}
|
|
148
|
+
if (stale) { trace(`launch-lock: stale lock → reclaiming`); try { unlinkSync(LAUNCH_LOCK_PATH); } catch {} continue; }
|
|
149
|
+
await sleep(200);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
trace(`launch-lock: wait timeout → proceeding lock-free`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function releaseLaunchLock() {
|
|
157
|
+
try { unlinkSync(LAUNCH_LOCK_PATH); } catch {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// POSIX listener-pid resolution with graceful degradation across minimal
|
|
161
|
+
// images. Tries lsof → ss → fuser; returns the first listener pid found, or
|
|
162
|
+
// null if none of the tools exist / none report a listener. A missing tool
|
|
163
|
+
// throws ENOENT from execSync, which we swallow and move to the next probe so
|
|
164
|
+
// a bare container without lsof does not wedge port reclaim.
|
|
165
|
+
function resolveListenerPidPosix(trace) {
|
|
166
|
+
// lsof: -ti prints bare pids, -sTCP:LISTEN restricts to the listening socket.
|
|
167
|
+
try {
|
|
168
|
+
const out = execSync(`lsof -ti :${PORT} -sTCP:LISTEN`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
169
|
+
const n = parseInt(out.split('\n')[0], 10);
|
|
170
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (trace) trace(`reclaimPort: lsof unavailable/failed (${e?.code || e?.message || e}) → trying ss`);
|
|
173
|
+
}
|
|
174
|
+
// ss (iproute2): -H no header, -l listening, -t tcp, -n numeric, -p process.
|
|
175
|
+
// The pid lives in the trailing `users:(("name",pid=1234,fd=7))` field.
|
|
176
|
+
try {
|
|
177
|
+
const out = execSync(`ss -Hltnp 'sport = :${PORT}'`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
178
|
+
const m = out.match(/pid=(\d+)/);
|
|
179
|
+
if (m) {
|
|
180
|
+
const n = parseInt(m[1], 10);
|
|
181
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (trace) trace(`reclaimPort: ss unavailable/failed (${e?.code || e?.message || e}) → trying fuser`);
|
|
185
|
+
}
|
|
186
|
+
// fuser: prints the pids holding the tcp port to stdout (diagnostics on
|
|
187
|
+
// stderr). Take the first numeric token.
|
|
188
|
+
try {
|
|
189
|
+
const out = execSync(`fuser ${PORT}/tcp 2>/dev/null`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
190
|
+
const m = out.match(/\d+/);
|
|
191
|
+
if (m) {
|
|
192
|
+
const n = parseInt(m[0], 10);
|
|
193
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (trace) trace(`reclaimPort: fuser unavailable/failed (${e?.code || e?.message || e}) → no listener pid resolved`);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Reclaim PORT from a version-mismatched setup-server: resolve the LISTENER
|
|
202
|
+
// pid, kill it (tree on win32, process-group on unix), wait for the port to
|
|
203
|
+
// free, then spawn the current-version server window-free. Throws LaunchError
|
|
204
|
+
// if the port cannot be reclaimed. Caller MUST hold the launch lock so two
|
|
205
|
+
// launchers never race the kill+respawn.
|
|
206
|
+
async function reclaimPort(pluginRoot, pluginData, remoteRoot, trace) {
|
|
207
|
+
let stalePid = null;
|
|
208
|
+
let pidResolveError = null;
|
|
209
|
+
let killError = null;
|
|
210
|
+
try {
|
|
211
|
+
if (process.platform === 'win32') {
|
|
212
|
+
// -State Listen is REQUIRED: Get-NetTCPConnection -LocalPort returns the
|
|
213
|
+
// LISTENING socket AND every ESTABLISHED/TIME_WAIT connection to it, so a
|
|
214
|
+
// bare `-First 1` can resolve a client/proxy PID (or 0) instead of the
|
|
215
|
+
// server — taskkill then misses the real owner and the port never frees.
|
|
216
|
+
const out = execSync(
|
|
217
|
+
`powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess"`,
|
|
218
|
+
{ encoding: 'utf8', timeout: 3000, windowsHide: true },
|
|
219
|
+
).trim();
|
|
220
|
+
const n = parseInt(out, 10);
|
|
221
|
+
if (Number.isFinite(n) && n > 0) stalePid = n;
|
|
222
|
+
} else {
|
|
223
|
+
// Resolve the LISTENER pid. lsof is the primary tool, but minimal
|
|
224
|
+
// Linux/WSL/container images often ship without it — falling back to
|
|
225
|
+
// ss (iproute2) then fuser keeps port reclaim working there instead
|
|
226
|
+
// of wedging on a missing-binary ENOENT. Each probe is independent and
|
|
227
|
+
// best-effort; the first that yields a pid wins.
|
|
228
|
+
stalePid = resolveListenerPidPosix(trace);
|
|
229
|
+
}
|
|
230
|
+
} catch (e) { pidResolveError = e; }
|
|
231
|
+
if (stalePid !== null) {
|
|
232
|
+
try {
|
|
233
|
+
if (process.platform === 'win32') {
|
|
234
|
+
// /T kills the whole process tree — the setup-server spawns child
|
|
235
|
+
// browser/server descendants that survive a PID-only Stop-Process.
|
|
236
|
+
execSync(`taskkill /T /F /PID ${stalePid}`, { timeout: 3000, windowsHide: true });
|
|
237
|
+
} else {
|
|
238
|
+
// Detached setup-server is its own process-group leader; the negative
|
|
239
|
+
// PID signals the group, reaping descendants a bare kill would orphan.
|
|
240
|
+
try { execSync(`kill -KILL -${stalePid}`, { timeout: 3000 }); }
|
|
241
|
+
catch { execSync(`kill -KILL ${stalePid}`, { timeout: 3000 }); }
|
|
242
|
+
}
|
|
243
|
+
} catch (e) { killError = e; }
|
|
244
|
+
}
|
|
245
|
+
// Poll until the port stops answering (250ms ticks, 5000ms deadline — a hair
|
|
246
|
+
// longer than the kill's own 3000ms timeout so a slow OS port release is not
|
|
247
|
+
// misread as a kill failure).
|
|
248
|
+
const killDeadline = Date.now() + 5000;
|
|
249
|
+
let portFree = false;
|
|
250
|
+
while (Date.now() < killDeadline) {
|
|
251
|
+
await sleep(250);
|
|
252
|
+
portFree = !(await ping(300));
|
|
253
|
+
if (portFree) break;
|
|
254
|
+
}
|
|
255
|
+
if (!portFree) {
|
|
256
|
+
const errorDetails = [
|
|
257
|
+
pidResolveError && ` PID resolution failure: ${pidResolveError?.message || String(pidResolveError)}`,
|
|
258
|
+
killError && ` kill failure: ${killError?.message || String(killError)}`,
|
|
259
|
+
].filter(Boolean).join('\n');
|
|
260
|
+
throw new LaunchError(
|
|
261
|
+
`Port ${PORT} is in use by a different mixdog plugin instance and could not be reclaimed (kill of PID ${stalePid ?? 'unknown'} failed or timed out).\n` +
|
|
262
|
+
` expected plugin root: ${pluginRoot}\n` +
|
|
263
|
+
` actual plugin root: ${remoteRoot}\n` +
|
|
264
|
+
(errorDetails ? errorDetails + '\n' : '') +
|
|
265
|
+
`Stop the other setup-server (or change PORT) and retry.\n`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
trace(`reclaimPort: killed stalePid=${stalePid}, port freed, spawning current server`);
|
|
269
|
+
await spawnServerWithLog(pluginRoot, pluginData, { openOnStart: false });
|
|
270
|
+
trace(`reclaimPort: spawnServerWithLog completed`);
|
|
271
|
+
}
|
|
272
|
+
|
|
125
273
|
async function waitForServer(timeoutMs = 4000, shouldStop = () => false) {
|
|
126
274
|
const deadline = Date.now() + timeoutMs;
|
|
127
275
|
let ready = false;
|
|
@@ -386,22 +534,13 @@ export async function launchConfigUi(options = {}) {
|
|
|
386
534
|
const alive = await aliveProbe();
|
|
387
535
|
__trace(`aliveProbe result alive=${alive}`);
|
|
388
536
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
__trace(`prewarm: server already alive → no-op`);
|
|
397
|
-
return CONFIG_UI_URL;
|
|
398
|
-
}
|
|
399
|
-
__trace(`prewarm: !alive → spawnServerWithLog (window-free)`);
|
|
400
|
-
await spawnServerWithLog(pluginRoot, pluginData, { openOnStart: false });
|
|
401
|
-
__trace(`prewarm: spawnServerWithLog completed`);
|
|
402
|
-
return CONFIG_UI_URL;
|
|
403
|
-
}
|
|
404
|
-
|
|
537
|
+
// Singleton / current-version invariant is enforced below for BOTH prewarm
|
|
538
|
+
// and interactive launches: prewarm no longer early-returns on a live
|
|
539
|
+
// server, so a stale old-version setup-server left running across a plugin
|
|
540
|
+
// update is taken over and replaced window-free at session start — instead
|
|
541
|
+
// of lingering until a manual /mixdog:config (the recurrence we hit). PORT
|
|
542
|
+
// is a fixed constant, so exactly one setup-server can own it; reclaiming a
|
|
543
|
+
// version-mismatched owner is the correct singleton behaviour even here.
|
|
405
544
|
if (!alive) {
|
|
406
545
|
// Boot window-free; the unconditional requestOpen below owns opening the
|
|
407
546
|
// window. Spawning with openOnStart:1 here would double-open and races a
|
|
@@ -425,68 +564,40 @@ export async function launchConfigUi(options = {}) {
|
|
|
425
564
|
const actual = normalize(remoteRoot);
|
|
426
565
|
__trace(`compare expected=${expected} actual=${actual} mismatch=${expected !== actual}`);
|
|
427
566
|
if (expected !== actual) {
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
567
|
+
// Serialize the reclaim across processes: prewarm now takes over every
|
|
568
|
+
// session, so two concurrent launchers can both observe this stale
|
|
569
|
+
// server. Under the lock the recheck is authoritative — whoever acquires
|
|
570
|
+
// first reclaims, and the next acquirer re-reads a now-current server and
|
|
571
|
+
// skips, so a freshly-spawned correct server is never killed.
|
|
572
|
+
const locked = await acquireLaunchLock(__trace);
|
|
434
573
|
try {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
{ encoding: 'utf8', timeout: 3000, windowsHide: true },
|
|
439
|
-
).trim();
|
|
440
|
-
const n = parseInt(out, 10);
|
|
441
|
-
if (Number.isFinite(n) && n > 0) stalePid = n;
|
|
574
|
+
const recheckRoot = await fetchPluginPath();
|
|
575
|
+
if (recheckRoot && normalize(recheckRoot) !== expected) {
|
|
576
|
+
await reclaimPort(pluginRoot, pluginData, recheckRoot, __trace);
|
|
442
577
|
} else {
|
|
443
|
-
|
|
444
|
-
const n = parseInt(out.split('\n')[0], 10);
|
|
445
|
-
if (Number.isFinite(n) && n > 0) stalePid = n;
|
|
578
|
+
__trace(`takeover: server current under lock (recheckRoot=${recheckRoot}) → skip`);
|
|
446
579
|
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
try {
|
|
450
|
-
if (process.platform === 'win32') {
|
|
451
|
-
// /T kills the whole process tree — the setup-server spawns child
|
|
452
|
-
// browser/server descendants that survive a PID-only Stop-Process,
|
|
453
|
-
// leaking processes and holding the port.
|
|
454
|
-
execSync(`taskkill /T /F /PID ${stalePid}`, { timeout: 3000, windowsHide: true });
|
|
455
|
-
} else {
|
|
456
|
-
// setup-server is launched detached (its own process-group leader),
|
|
457
|
-
// so the port-owning PID equals the group id. Negative PID signals
|
|
458
|
-
// the whole group, reaping descendants a bare kill would orphan.
|
|
459
|
-
try { execSync(`kill -KILL -${stalePid}`, { timeout: 3000 }); }
|
|
460
|
-
catch { execSync(`kill -KILL ${stalePid}`, { timeout: 3000 }); }
|
|
461
|
-
}
|
|
462
|
-
} catch (e) { killError = e; }
|
|
463
|
-
}
|
|
464
|
-
// Poll until port is free (250ms ticks, 3000ms deadline).
|
|
465
|
-
const killDeadline = Date.now() + 3000;
|
|
466
|
-
let portFree = false;
|
|
467
|
-
while (Date.now() < killDeadline) {
|
|
468
|
-
await sleep(250);
|
|
469
|
-
portFree = !(await ping(300));
|
|
470
|
-
if (portFree) break;
|
|
580
|
+
} finally {
|
|
581
|
+
if (locked) releaseLaunchLock();
|
|
471
582
|
}
|
|
472
|
-
if (!portFree) {
|
|
473
|
-
const errorDetails = [
|
|
474
|
-
pidResolveError && ` PID resolution failure: ${pidResolveError?.message || String(pidResolveError)}`,
|
|
475
|
-
killError && ` kill failure: ${killError?.message || String(killError)}`,
|
|
476
|
-
].filter(Boolean).join('\n');
|
|
477
|
-
throw new LaunchError(
|
|
478
|
-
`Port ${PORT} is in use by a different mixdog plugin instance and could not be reclaimed (kill of PID ${stalePid ?? 'unknown'} failed or timed out).\n` +
|
|
479
|
-
` expected plugin root: ${pluginRoot}\n` +
|
|
480
|
-
` actual plugin root: ${remoteRoot}\n` +
|
|
481
|
-
(errorDetails ? errorDetails + '\n' : '') +
|
|
482
|
-
`Stop the other setup-server (or change PORT) and retry.\n`,
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
__trace(`takeover: killed stalePid=${stalePid}, port freed, spawning new server`);
|
|
486
|
-
await spawnServerWithLog(pluginRoot, pluginData, { openOnStart: false });
|
|
487
|
-
__trace(`takeover: spawnServerWithLog completed`);
|
|
488
583
|
}
|
|
489
584
|
}
|
|
585
|
+
|
|
586
|
+
// Ensure the server (just spawned, taken over, or concurrently swapped by
|
|
587
|
+
// another launcher) is actually answering before we report success or open a
|
|
588
|
+
// window — absorbs the brief port-down window during a concurrent reclaim so
|
|
589
|
+
// requestOpen never hits a refused port and prewarm never returns early.
|
|
590
|
+
if (!await waitForServer(15000)) {
|
|
591
|
+
throw new LaunchError(`setup-server did not become ready at ${CONFIG_UI_URL}/ before launch.\n`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Prewarm stops here: the current-version server is up (spawned or taken
|
|
595
|
+
// over above) and must NOT open a browser window.
|
|
596
|
+
if (prewarm) {
|
|
597
|
+
__trace(`prewarm: current-version server ensured → return`);
|
|
598
|
+
return CONFIG_UI_URL;
|
|
599
|
+
}
|
|
600
|
+
|
|
490
601
|
// Invariant: a non-prewarm launch ALWAYS opens the window through the
|
|
491
602
|
// idempotent /open endpoint once the server is ready — regardless of which
|
|
492
603
|
// process (this launcher, a concurrent every-session prewarm, or a
|
package/setup/locate-claude.mjs
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
// locate-claude.mjs — shared PATH scan for the Claude Code CLI executable.
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
4
|
+
import { existsSync, statSync, accessSync, constants as fsConstants } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
|
|
7
|
+
// POSIX: a PATH match is only a real candidate if it is a regular file the
|
|
8
|
+
// current user may EXECUTE. existsSync alone would return a non-exec data file
|
|
9
|
+
// (e.g. a `claude` config/dir or a 0644 stray) and the caller would then fail
|
|
10
|
+
// to spawn it. On win32 executability is governed by PATHEXT, not a mode bit,
|
|
11
|
+
// so existence is the right test there.
|
|
12
|
+
function isUsable(p) {
|
|
13
|
+
const win32 = process.platform === 'win32';
|
|
14
|
+
try {
|
|
15
|
+
if (!existsSync(p)) return false;
|
|
16
|
+
if (win32) return true;
|
|
17
|
+
if (!statSync(p).isFile()) return false;
|
|
18
|
+
accessSync(p, fsConstants.X_OK);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
7
25
|
export function resolveClaudeExecutable() {
|
|
8
26
|
const win32 = process.platform === 'win32';
|
|
9
27
|
try {
|
|
10
28
|
const cmd = win32 ? 'where' : 'which';
|
|
11
29
|
const out = execFileSync(cmd, ['claude'], { encoding: 'utf8', windowsHide: true }).trim();
|
|
12
30
|
const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
|
13
|
-
if (first &&
|
|
31
|
+
if (first && isUsable(first)) return first;
|
|
14
32
|
} catch { /* PATH scan */ }
|
|
15
33
|
|
|
16
34
|
const pathSep = win32 ? ';' : ':';
|
|
@@ -30,9 +48,9 @@ export function resolveClaudeExecutable() {
|
|
|
30
48
|
if (existsSync(bare)) return bare;
|
|
31
49
|
} else {
|
|
32
50
|
const candidate = join(dir, base);
|
|
33
|
-
if (
|
|
51
|
+
if (isUsable(candidate)) return candidate;
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
}
|
|
37
55
|
return null;
|
|
38
|
-
}
|
|
56
|
+
}
|
package/setup/setup-server.mjs
CHANGED
|
@@ -15,10 +15,11 @@ import { listSchedules } from '../src/shared/schedules-store.mjs';
|
|
|
15
15
|
import { ensureDataSeeds } from '../src/shared/seed.mjs';
|
|
16
16
|
import { backupUserData, markUserDataInitialized, shouldSeedMissingUserData } from '../src/shared/user-data-guard.mjs';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
|
-
import { readSection, writeSection, updateSection, saveSecret, deleteSecret, hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey, getAgentApiKey, getDiscordToken, getWebhookAuthtoken, diagnoseDiscordTokenValue } from '../src/shared/config.mjs';
|
|
18
|
+
import { readSection, writeSection, updateSection, saveSecret, deleteSecret, hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey, getAgentApiKey, getDiscordToken, getWebhookAuthtoken, diagnoseDiscordTokenValue, AGENT_PROVIDER_ENV, AGENT_PROVIDER_ENV_ALIASES } from '../src/shared/config.mjs';
|
|
19
19
|
import { applyDefaults as applyChannelsDefaults } from '../src/channels/lib/config.mjs';
|
|
20
20
|
import { validateCronExpression } from '../src/channels/lib/scheduler.mjs';
|
|
21
21
|
import { mergeAgentConfig, mergeMemoryConfig, mergeSearchConfig, mergeConfig, mergeEndpointConfig, mergeWebhookEndpointConfig } from './config-merge.mjs';
|
|
22
|
+
import { isWSL } from '../src/shared/wsl.mjs';
|
|
22
23
|
|
|
23
24
|
// C2 — Origin/Referer guard for mutating routes.
|
|
24
25
|
// Returns true when the request is safe to handle (same-origin loopback UI,
|
|
@@ -142,15 +143,11 @@ const SUPPORTED_PROVIDERS = [
|
|
|
142
143
|
'anthropic-oauth', 'openai-oauth', 'openai-api', 'gemini-api', 'xai-api', 'grok-oauth',
|
|
143
144
|
'tavily', 'firecrawl', 'exa',
|
|
144
145
|
];
|
|
145
|
-
|
|
146
|
+
// AGENT_PROVIDER_ENV / AGENT_PROVIDER_ENV_ALIASES are the SSOT (src/shared/config.mjs);
|
|
147
|
+
// derive the id list so a provider added there flows through every detection path
|
|
148
|
+
// (keyStored, envKeys, secret presence) here automatically.
|
|
149
|
+
const AGENT_KEY_PROVIDER_IDS = Object.keys(AGENT_PROVIDER_ENV);
|
|
146
150
|
const SEARCH_KEY_PROVIDER_IDS = ['firecrawl', 'tavily', 'exa'];
|
|
147
|
-
const AGENT_PROVIDER_ENV = Object.freeze({
|
|
148
|
-
openai: 'OPENAI_API_KEY',
|
|
149
|
-
anthropic: 'ANTHROPIC_API_KEY',
|
|
150
|
-
gemini: 'GEMINI_API_KEY',
|
|
151
|
-
deepseek: 'DEEPSEEK_API_KEY',
|
|
152
|
-
xai: 'XAI_API_KEY',
|
|
153
|
-
});
|
|
154
151
|
|
|
155
152
|
function envSecretPresent(account) {
|
|
156
153
|
const key = 'MIXDOG_' + String(account || '').replace(/[.\s]+/g, '_').toUpperCase();
|
|
@@ -465,21 +462,19 @@ async function detectAuth(config = {}) {
|
|
|
465
462
|
result.copilot = existsSync(join(configDir, 'github-copilot', 'hosts.json'))
|
|
466
463
|
|| existsSync(join(configDir, 'github-copilot', 'apps.json'));
|
|
467
464
|
result.envKeys = {};
|
|
468
|
-
for (const [
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// shows xAI as available. keyStored semantics (keychain-only) stay unchanged.
|
|
476
|
-
result.envKeys.xai = result.envKeys.xai || !!process.env.GROK_API_KEY;
|
|
465
|
+
for (const [id, env] of Object.entries(AGENT_PROVIDER_ENV)) result.envKeys[id] = !!process.env[env];
|
|
466
|
+
// Honor the shared last-resort env aliases (e.g. GROK_API_KEY for xai) so a
|
|
467
|
+
// GROK_API_KEY-only env still shows xAI as available, matching getAgentApiKey.
|
|
468
|
+
// keyStored semantics (keychain-only) stay unchanged.
|
|
469
|
+
for (const [id, aliases] of Object.entries(AGENT_PROVIDER_ENV_ALIASES)) {
|
|
470
|
+
if (!result.envKeys[id]) result.envKeys[id] = aliases.some((a) => !!process.env[a]);
|
|
471
|
+
}
|
|
477
472
|
// Keychain-stored provider keys. Only this boolean is sent to the browser —
|
|
478
473
|
// the secret value never leaves the server, so the UI can show "Set" without
|
|
479
474
|
// exposing the key.
|
|
480
475
|
result.keyStored = {};
|
|
481
|
-
for (const
|
|
482
|
-
result.keyStored[
|
|
476
|
+
for (const id of AGENT_KEY_PROVIDER_IDS) {
|
|
477
|
+
result.keyStored[id] = hasStoredSecret(SECRET_ACCOUNTS.agentApiKey(id));
|
|
483
478
|
}
|
|
484
479
|
const ollamaUrl = config?.providers?.ollama?.baseURL || 'http://localhost:11434/v1';
|
|
485
480
|
const lmstudioUrl = config?.providers?.lmstudio?.baseURL || 'http://localhost:1234/v1';
|
|
@@ -518,7 +513,7 @@ async function detectAuth(config = {}) {
|
|
|
518
513
|
// in sync with src/agent/orchestrator/providers/registry.mjs.
|
|
519
514
|
const _RUNTIME_PROVIDER_NAMES = [
|
|
520
515
|
'anthropic', 'anthropic-oauth', 'openai', 'openai-oauth',
|
|
521
|
-
'gemini', 'deepseek', 'xai', 'grok-oauth',
|
|
516
|
+
'gemini', 'deepseek', 'xai', 'opencode-go', 'grok-oauth',
|
|
522
517
|
'ollama', 'lmstudio',
|
|
523
518
|
];
|
|
524
519
|
|
|
@@ -1471,6 +1466,27 @@ async function openAppWindowSequence() {
|
|
|
1471
1466
|
return { ...macResult, method: 'macOS open', attempts: [macAttempt] };
|
|
1472
1467
|
}
|
|
1473
1468
|
|
|
1469
|
+
// WSL: process.platform is 'linux' but xdg-open targets a (usually absent)
|
|
1470
|
+
// Linux GUI. Reach the Windows-HOST browser instead, mirroring open-url.mjs:
|
|
1471
|
+
// prefer wslview (wslu), then PowerShell Start-Process (cmd.exe is avoided —
|
|
1472
|
+
// see the note below). Each is a sync spawn through trySyncOpen so a missing
|
|
1473
|
+
// opener advances to the next.
|
|
1474
|
+
if (isWSL()) {
|
|
1475
|
+
if (trySyncOpen('wslview', 'wslview', [appUrl], attempts)) {
|
|
1476
|
+
return { ok: true, method: 'wslview', attempts };
|
|
1477
|
+
}
|
|
1478
|
+
// cmd.exe `start` is intentionally NOT used: cmd re-parses URL
|
|
1479
|
+
// metacharacters, so a query-string `&` is treated as a command separator
|
|
1480
|
+
// and the URL truncates. Start-Process takes the URL as a single argument
|
|
1481
|
+
// and does not reparse `&`; quotePowerShellString single-quotes the URL
|
|
1482
|
+
// (doubling embedded quotes) so it is injection-safe.
|
|
1483
|
+
const wslPsCommand = `Start-Process -FilePath ${quotePowerShellString(appUrl)}`;
|
|
1484
|
+
if (trySyncOpen('PowerShell Start-Process', 'powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', wslPsCommand], attempts)) {
|
|
1485
|
+
return { ok: true, method: 'PowerShell Start-Process', attempts };
|
|
1486
|
+
}
|
|
1487
|
+
return { ok: false, error: 'Failed to open Windows browser from WSL', attempts };
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1474
1490
|
const xdgResult = await new Promise(resolve => {
|
|
1475
1491
|
const child = spawn('xdg-open', [appUrl], { stdio: 'ignore' });
|
|
1476
1492
|
let timer = setTimeout(() => {
|