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.
@@ -13,7 +13,7 @@
13
13
  "source": {
14
14
  "source": "url",
15
15
  "url": "https://github.com/trib-plugin/mixdog.git",
16
- "ref": "v0.7.14"
16
+ "ref": "v0.7.16"
17
17
  },
18
18
  "strict": false,
19
19
  "mcpServers": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.14",
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": {
@@ -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
- }).unref();
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 {
@@ -48,4 +48,18 @@ const child = spawnSync(shimPath, process.argv.slice(2), {
48
48
  windowsHide: true,
49
49
  });
50
50
 
51
- process.exit(child.status ?? 0);
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);
@@ -6,7 +6,19 @@ const fs = require('fs');
6
6
  const { resolvePluginData } = require('./plugin-paths.cjs');
7
7
 
8
8
  const SERVICE = 'mixdog';
9
- const POWERSHELL_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_TIMEOUT_MS || 15000);
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 = run('security', ['find-generic-password', '-a', account, '-s', SERVICE, '-w']);
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 = run('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', value, '-U']);
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 = run('security', ['delete-generic-password', '-a', account, '-s', SERVICE]);
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;
@@ -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(os.homedir(), '.claude', 'plugins', 'data', `${pluginName}-${marketplace}`);
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(os.homedir(), '.claude', 'plugins', 'data', `${pluginName}-${marketplace}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.14",
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",
@@ -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;
@@ -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
- // Pre-warm mode: ensure the server is booted window-free, then return.
390
- // Already alive cheap no-op (no requestOpen, no window). Not alive →
391
- // spawn with openOnStart:false so setup-server boots without opening a
392
- // browser window. No stale-server takeover: pre-warm is best-effort and
393
- // must never kill another install's server.
394
- if (prewarm) {
395
- if (alive) {
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
- // Invariant: single live plugin install owns PORT. The occupying server
429
- // belongs to a different install resolve its PID, terminate it, poll
430
- // until the port is free, then take ownership via spawnServerWithLog().
431
- let stalePid = null;
432
- let pidResolveError = null;
433
- let killError = null;
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
- if (process.platform === 'win32') {
436
- const out = execSync(
437
- `powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${PORT} -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess"`,
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
- const out = execSync(`lsof -ti :${PORT}`, { encoding: 'utf8', timeout: 3000 }).trim();
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
- } catch (e) { pidResolveError = e; }
448
- if (stalePid !== null) {
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
@@ -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 && existsSync(first)) return 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 (existsSync(candidate)) return candidate;
51
+ if (isUsable(candidate)) return candidate;
34
52
  }
35
53
  }
36
54
  }
37
55
  return null;
38
- }
56
+ }
@@ -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
- const AGENT_KEY_PROVIDER_IDS = ['openai', 'anthropic', 'gemini', 'deepseek', 'xai'];
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 [name, envKey] of [
469
- ['openai', 'OPENAI_API_KEY'], ['anthropic', 'ANTHROPIC_API_KEY'],
470
- ['gemini', 'GEMINI_API_KEY'], ['deepseek', 'DEEPSEEK_API_KEY'],
471
- ['xai', 'XAI_API_KEY'],
472
- ]) { result.envKeys[name] = !!process.env[envKey]; }
473
- // GROK_API_KEY is the last-resort xAI env alias honored by getAgentApiKey('xai')
474
- // (shared/config.mjs is the SSOT); mirror it here so a GROK_API_KEY-only env
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 name of ['openai', 'anthropic', 'gemini', 'deepseek', 'xai']) {
482
- result.keyStored[name] = hasStoredSecret(SECRET_ACCOUNTS.agentApiKey(name));
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(() => {