mixdog 0.7.14 → 0.7.15

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.15"
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.15",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.14",
3
+ "version": "0.7.15",
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,110 @@ 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
+ // Reclaim PORT from a version-mismatched setup-server: resolve the LISTENER
161
+ // pid, kill it (tree on win32, process-group on unix), wait for the port to
162
+ // free, then spawn the current-version server window-free. Throws LaunchError
163
+ // if the port cannot be reclaimed. Caller MUST hold the launch lock so two
164
+ // launchers never race the kill+respawn.
165
+ async function reclaimPort(pluginRoot, pluginData, remoteRoot, trace) {
166
+ let stalePid = null;
167
+ let pidResolveError = null;
168
+ let killError = null;
169
+ try {
170
+ if (process.platform === 'win32') {
171
+ // -State Listen is REQUIRED: Get-NetTCPConnection -LocalPort returns the
172
+ // LISTENING socket AND every ESTABLISHED/TIME_WAIT connection to it, so a
173
+ // bare `-First 1` can resolve a client/proxy PID (or 0) instead of the
174
+ // server — taskkill then misses the real owner and the port never frees.
175
+ const out = execSync(
176
+ `powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess"`,
177
+ { encoding: 'utf8', timeout: 3000, windowsHide: true },
178
+ ).trim();
179
+ const n = parseInt(out, 10);
180
+ if (Number.isFinite(n) && n > 0) stalePid = n;
181
+ } else {
182
+ const out = execSync(`lsof -ti :${PORT} -sTCP:LISTEN`, { encoding: 'utf8', timeout: 3000 }).trim();
183
+ const n = parseInt(out.split('\n')[0], 10);
184
+ if (Number.isFinite(n) && n > 0) stalePid = n;
185
+ }
186
+ } catch (e) { pidResolveError = e; }
187
+ if (stalePid !== null) {
188
+ try {
189
+ if (process.platform === 'win32') {
190
+ // /T kills the whole process tree — the setup-server spawns child
191
+ // browser/server descendants that survive a PID-only Stop-Process.
192
+ execSync(`taskkill /T /F /PID ${stalePid}`, { timeout: 3000, windowsHide: true });
193
+ } else {
194
+ // Detached setup-server is its own process-group leader; the negative
195
+ // PID signals the group, reaping descendants a bare kill would orphan.
196
+ try { execSync(`kill -KILL -${stalePid}`, { timeout: 3000 }); }
197
+ catch { execSync(`kill -KILL ${stalePid}`, { timeout: 3000 }); }
198
+ }
199
+ } catch (e) { killError = e; }
200
+ }
201
+ // Poll until the port stops answering (250ms ticks, 5000ms deadline — a hair
202
+ // longer than the kill's own 3000ms timeout so a slow OS port release is not
203
+ // misread as a kill failure).
204
+ const killDeadline = Date.now() + 5000;
205
+ let portFree = false;
206
+ while (Date.now() < killDeadline) {
207
+ await sleep(250);
208
+ portFree = !(await ping(300));
209
+ if (portFree) break;
210
+ }
211
+ if (!portFree) {
212
+ const errorDetails = [
213
+ pidResolveError && ` PID resolution failure: ${pidResolveError?.message || String(pidResolveError)}`,
214
+ killError && ` kill failure: ${killError?.message || String(killError)}`,
215
+ ].filter(Boolean).join('\n');
216
+ throw new LaunchError(
217
+ `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` +
218
+ ` expected plugin root: ${pluginRoot}\n` +
219
+ ` actual plugin root: ${remoteRoot}\n` +
220
+ (errorDetails ? errorDetails + '\n' : '') +
221
+ `Stop the other setup-server (or change PORT) and retry.\n`,
222
+ );
223
+ }
224
+ trace(`reclaimPort: killed stalePid=${stalePid}, port freed, spawning current server`);
225
+ await spawnServerWithLog(pluginRoot, pluginData, { openOnStart: false });
226
+ trace(`reclaimPort: spawnServerWithLog completed`);
227
+ }
228
+
125
229
  async function waitForServer(timeoutMs = 4000, shouldStop = () => false) {
126
230
  const deadline = Date.now() + timeoutMs;
127
231
  let ready = false;
@@ -386,22 +490,13 @@ export async function launchConfigUi(options = {}) {
386
490
  const alive = await aliveProbe();
387
491
  __trace(`aliveProbe result alive=${alive}`);
388
492
 
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
-
493
+ // Singleton / current-version invariant is enforced below for BOTH prewarm
494
+ // and interactive launches: prewarm no longer early-returns on a live
495
+ // server, so a stale old-version setup-server left running across a plugin
496
+ // update is taken over and replaced window-free at session start — instead
497
+ // of lingering until a manual /mixdog:config (the recurrence we hit). PORT
498
+ // is a fixed constant, so exactly one setup-server can own it; reclaiming a
499
+ // version-mismatched owner is the correct singleton behaviour even here.
405
500
  if (!alive) {
406
501
  // Boot window-free; the unconditional requestOpen below owns opening the
407
502
  // window. Spawning with openOnStart:1 here would double-open and races a
@@ -425,68 +520,40 @@ export async function launchConfigUi(options = {}) {
425
520
  const actual = normalize(remoteRoot);
426
521
  __trace(`compare expected=${expected} actual=${actual} mismatch=${expected !== actual}`);
427
522
  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;
523
+ // Serialize the reclaim across processes: prewarm now takes over every
524
+ // session, so two concurrent launchers can both observe this stale
525
+ // server. Under the lock the recheck is authoritative whoever acquires
526
+ // first reclaims, and the next acquirer re-reads a now-current server and
527
+ // skips, so a freshly-spawned correct server is never killed.
528
+ const locked = await acquireLaunchLock(__trace);
434
529
  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;
530
+ const recheckRoot = await fetchPluginPath();
531
+ if (recheckRoot && normalize(recheckRoot) !== expected) {
532
+ await reclaimPort(pluginRoot, pluginData, recheckRoot, __trace);
442
533
  } 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;
534
+ __trace(`takeover: server current under lock (recheckRoot=${recheckRoot}) skip`);
446
535
  }
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; }
536
+ } finally {
537
+ if (locked) releaseLaunchLock();
463
538
  }
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;
471
- }
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
539
  }
489
540
  }
541
+
542
+ // Ensure the server (just spawned, taken over, or concurrently swapped by
543
+ // another launcher) is actually answering before we report success or open a
544
+ // window — absorbs the brief port-down window during a concurrent reclaim so
545
+ // requestOpen never hits a refused port and prewarm never returns early.
546
+ if (!await waitForServer(15000)) {
547
+ throw new LaunchError(`setup-server did not become ready at ${CONFIG_UI_URL}/ before launch.\n`);
548
+ }
549
+
550
+ // Prewarm stops here: the current-version server is up (spawned or taken
551
+ // over above) and must NOT open a browser window.
552
+ if (prewarm) {
553
+ __trace(`prewarm: current-version server ensured → return`);
554
+ return CONFIG_UI_URL;
555
+ }
556
+
490
557
  // Invariant: a non-prewarm launch ALWAYS opens the window through the
491
558
  // idempotent /open endpoint once the server is ready — regardless of which
492
559
  // process (this launcher, a concurrent every-session prewarm, or a
@@ -15,7 +15,7 @@ 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';
@@ -142,15 +142,11 @@ const SUPPORTED_PROVIDERS = [
142
142
  'anthropic-oauth', 'openai-oauth', 'openai-api', 'gemini-api', 'xai-api', 'grok-oauth',
143
143
  'tavily', 'firecrawl', 'exa',
144
144
  ];
145
- const AGENT_KEY_PROVIDER_IDS = ['openai', 'anthropic', 'gemini', 'deepseek', 'xai'];
145
+ // AGENT_PROVIDER_ENV / AGENT_PROVIDER_ENV_ALIASES are the SSOT (src/shared/config.mjs);
146
+ // derive the id list so a provider added there flows through every detection path
147
+ // (keyStored, envKeys, secret presence) here automatically.
148
+ const AGENT_KEY_PROVIDER_IDS = Object.keys(AGENT_PROVIDER_ENV);
146
149
  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
150
 
155
151
  function envSecretPresent(account) {
156
152
  const key = 'MIXDOG_' + String(account || '').replace(/[.\s]+/g, '_').toUpperCase();
@@ -465,21 +461,19 @@ async function detectAuth(config = {}) {
465
461
  result.copilot = existsSync(join(configDir, 'github-copilot', 'hosts.json'))
466
462
  || existsSync(join(configDir, 'github-copilot', 'apps.json'));
467
463
  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;
464
+ for (const [id, env] of Object.entries(AGENT_PROVIDER_ENV)) result.envKeys[id] = !!process.env[env];
465
+ // Honor the shared last-resort env aliases (e.g. GROK_API_KEY for xai) so a
466
+ // GROK_API_KEY-only env still shows xAI as available, matching getAgentApiKey.
467
+ // keyStored semantics (keychain-only) stay unchanged.
468
+ for (const [id, aliases] of Object.entries(AGENT_PROVIDER_ENV_ALIASES)) {
469
+ if (!result.envKeys[id]) result.envKeys[id] = aliases.some((a) => !!process.env[a]);
470
+ }
477
471
  // Keychain-stored provider keys. Only this boolean is sent to the browser —
478
472
  // the secret value never leaves the server, so the UI can show "Set" without
479
473
  // exposing the key.
480
474
  result.keyStored = {};
481
- for (const name of ['openai', 'anthropic', 'gemini', 'deepseek', 'xai']) {
482
- result.keyStored[name] = hasStoredSecret(SECRET_ACCOUNTS.agentApiKey(name));
475
+ for (const id of AGENT_KEY_PROVIDER_IDS) {
476
+ result.keyStored[id] = hasStoredSecret(SECRET_ACCOUNTS.agentApiKey(id));
483
477
  }
484
478
  const ollamaUrl = config?.providers?.ollama?.baseURL || 'http://localhost:11434/v1';
485
479
  const lmstudioUrl = config?.providers?.lmstudio?.baseURL || 'http://localhost:1234/v1';
@@ -518,7 +512,7 @@ async function detectAuth(config = {}) {
518
512
  // in sync with src/agent/orchestrator/providers/registry.mjs.
519
513
  const _RUNTIME_PROVIDER_NAMES = [
520
514
  'anthropic', 'anthropic-oauth', 'openai', 'openai-oauth',
521
- 'gemini', 'deepseek', 'xai', 'grok-oauth',
515
+ 'gemini', 'deepseek', 'xai', 'opencode-go', 'grok-oauth',
522
516
  'ollama', 'lmstudio',
523
517
  ];
524
518
 
@@ -1,5 +1,5 @@
1
1
  import { resolvePluginData } from '../../shared/plugin-paths.mjs';
2
- import { readSection, updateSection, getAgentApiKey } from '../../shared/config.mjs';
2
+ import { readSection, updateSection, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
3
3
  import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat.mjs';
4
4
  import { hasAnthropicOAuthCredentials } from './providers/anthropic-oauth.mjs';
5
5
  import { hasOpenAIOAuthCredentials } from './providers/openai-oauth.mjs';
@@ -10,13 +10,9 @@ import { hasGrokOAuthCredentials } from './providers/grok-oauth.mjs';
10
10
  export function getPluginData() {
11
11
  return resolvePluginData();
12
12
  }
13
- const ENV_KEY_MAP = {
14
- openai: 'OPENAI_API_KEY',
15
- anthropic: 'ANTHROPIC_API_KEY',
16
- gemini: 'GEMINI_API_KEY',
17
- deepseek: 'DEEPSEEK_API_KEY',
18
- xai: 'XAI_API_KEY',
19
- };
13
+ // First-class agent API-key providers: imported from the shared SSOT
14
+ // (src/shared/config.mjs) so default-config and overlay paths cannot drift
15
+ // from the env names the runtime key loader (getAgentApiKey) actually uses.
20
16
  // Canonical maintenance defaults. Single source of truth — imported by
21
17
  // llm/index.mjs and setup-server.mjs so UI/runtime cannot drift from config.
22
18
  //
@@ -73,7 +69,7 @@ export const DEFAULT_PRESETS = Object.freeze([
73
69
  function buildDefaultConfig() {
74
70
  const providers = {};
75
71
  // API providers — enabled if env key exists
76
- for (const [name, envKey] of Object.entries(ENV_KEY_MAP)) {
72
+ for (const [name, envKey] of Object.entries(AGENT_PROVIDER_ENV)) {
77
73
  const apiKey = process.env[envKey];
78
74
  providers[name] = {
79
75
  enabled: !!apiKey,
@@ -138,11 +134,11 @@ export function loadConfig() {
138
134
  // Provider API keys live in the OS keychain (std env / MIXDOG_AGENT_*
139
135
  // -> keychain), never plaintext in config. Overlay them so the
140
136
  // provider clients see config.apiKey populated.
141
- // ENV_KEY_MAP covers first-class key providers; OPENAI_COMPAT_PRESETS
137
+ // AGENT_PROVIDER_ENV covers first-class key providers; OPENAI_COMPAT_PRESETS
142
138
  // covers compat providers (opencode-go, …) whose key also lives in
143
139
  // the keychain. Without the union, a compat provider with a valid
144
140
  // stored key still ships 'no-key' → 401.
145
- for (const name of new Set([...Object.keys(ENV_KEY_MAP), ...Object.keys(OPENAI_COMPAT_PRESETS)])) {
141
+ for (const name of new Set([...Object.keys(AGENT_PROVIDER_ENV), ...Object.keys(OPENAI_COMPAT_PRESETS)])) {
146
142
  const kc = getAgentApiKey(name);
147
143
  if (kc) mergedProviders[name] = { ...(mergedProviders[name] || {}), apiKey: kc, enabled: true };
148
144
  }
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.14",
2
+ "version": "0.7.15",
3
3
  "_comment": "Rewritten by .github/workflows/graph-release.yml on each tagged release. assets maps platformKey (process.platform-process.arch, e.g. win32-x64, linux-x64, darwin-arm64) to { url, sha256 } of the mixdog-graph binary on the GitHub release. A local cargo build under native/mixdog-graph/target/release always takes precedence at runtime. (v0.5.236 entries were filled manually after CI's commit step hit detached HEAD; the workflow now checks out ref: main so future releases self-update.)",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-graph-darwin-arm64",
6
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-darwin-arm64",
7
7
  "sha256": "53f45f8373000fb55ccb49f9b03af0275ca2a663f924b68ee8ded4535e14445a"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-graph-darwin-x64",
10
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-darwin-x64",
11
11
  "sha256": "347dc5e1b39dfc33ae07e01319b085c1f1ee775a70be1ac09f7b0d2954b36591"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-graph-linux-arm64",
14
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-linux-arm64",
15
15
  "sha256": "7648be2c4bc89bef837b4843639a9ab38c382ba103518ad52d4439587521542f"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-graph-linux-x64",
18
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-linux-x64",
19
19
  "sha256": "6e5a1b548c69c3a51170eb11c769985be7ca7029ede2597b19304c8dc106eded"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-graph-win32-x64.exe",
23
- "sha256": "2ca840a008a26a6ebdeab2803a994c6b2a462b178269cc3e2ab5a005c51d3663"
22
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-win32-x64.exe",
23
+ "sha256": "e2933fcf6ad0d7ab517c0beda4f8c7258638602bca73170dbda2cd8311c8637f"
24
24
  }
25
25
  }
26
26
  }
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.14",
2
+ "version": "0.7.15",
3
3
  "_comment": "Rewritten by .github/workflows/patch-release.yml on each tagged release. assets maps platformKey (process.platform-process.arch, e.g. win32-x64, linux-x64, darwin-arm64) to { url, sha256 } of the mixdog-patch binary on the GitHub release. A local cargo build under native/mixdog-patch/target/release always takes precedence; otherwise the binary is fetched per this manifest into the data dir (apply is native-only — no JS apply engine).",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-patch-darwin-arm64",
6
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-darwin-arm64",
7
7
  "sha256": "836a0b60a443b0a6a8c1bbe24d15a79ed70ee92c2f0fbc05374c4e9ed2536415"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-patch-darwin-x64",
10
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-darwin-x64",
11
11
  "sha256": "cab10c4e1e8b72d3958241dffdff764712ed74f4861d105bafa8258961215c98"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-patch-linux-arm64",
14
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-linux-arm64",
15
15
  "sha256": "a90c32ce3417a7d853f2723f82f3613cf2cd030fe885cf710cfc9f8e4b193264"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-patch-linux-x64",
18
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-linux-x64",
19
19
  "sha256": "0fea40ab98acd35bfb47515756024d1882a2abbaddce8a0b51642d20ac405577"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.14/mixdog-patch-win32-x64.exe",
23
- "sha256": "d6e7d19bf48c55f3a54bf5183546b657f2a34ebb235f25304d2d37b74017c873"
22
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-win32-x64.exe",
23
+ "sha256": "bc57bc029667bd62ce3798b4bfe23a42e4278148a6bbb778236769b3713397d8"
24
24
  }
25
25
  }
26
26
  }
@@ -1826,12 +1826,21 @@ function wireWebhookHandlers() {
1826
1826
  // partial `silent_to_agent` semantics that still audit to Discord.
1827
1827
  const raw = String(text);
1828
1828
  if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
1829
+ // Deterministic findings-count drop. Code-review handlers emit a
1830
+ // structured `[[findings:N]]` token (N = number of issues). The RELAY —
1831
+ // not the worker's prose — decides: N==0 => clean review, drop entirely
1832
+ // (no Lead inject, no Discord forward). Token absent => fail-safe forward
1833
+ // so a real finding is never silently dropped if the worker omits it.
1834
+ const fc = raw.match(/\[\[findings:(\d+)\]\]/i);
1835
+ if (fc && Number(fc[1]) === 0) return;
1829
1836
  // Lifecycle pings (started / iter echoes, marked silent_to_agent) are
1830
1837
  // channel noise for an automated webhook review — drop them entirely so
1831
1838
  // a skip stays fully silent and only the final answer reaches the
1832
1839
  // channel. The final [meta:silent] skip result is already dropped above.
1833
1840
  if (meta?.silent_to_agent === true) return;
1834
- injectAndRecord(channelId, label, raw, {
1841
+ // Strip the verdict token before surfacing (findings present, N>0).
1842
+ const surfaced = raw.replace(/\[\[findings:\d+\]\]/gi, "").replace(/[^\S\n]{2,}/g, " ").trim();
1843
+ injectAndRecord(channelId, label, surfaced || raw, {
1835
1844
  type: "webhook",
1836
1845
  instruction,
1837
1846
  });
@@ -297,16 +297,19 @@ export function getSearchApiKey(provider) {
297
297
 
298
298
  // Standard provider env names take precedence so existing OPENAI_API_KEY-style
299
299
  // exports keep working, then MIXDOG_AGENT_<P>_APIKEY, then the OS keychain.
300
- const AGENT_PROVIDER_ENV = Object.freeze({
300
+ // SSOT for agent API-key providers: setup-server.mjs and config-merge.mjs import
301
+ // this so UI key-presence detection uses the exact same predicate the runtime
302
+ // loads from. Add a provider here and every path picks it up.
303
+ export const AGENT_PROVIDER_ENV = Object.freeze({
301
304
  openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY',
302
- deepseek: 'DEEPSEEK_API_KEY', xai: 'XAI_API_KEY',
305
+ deepseek: 'DEEPSEEK_API_KEY', xai: 'XAI_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',
303
306
  })
304
307
 
305
308
  // Last-resort env aliases honored AFTER the standard env / MIXDOG_AGENT_* /
306
309
  // keychain sources. GROK_API_KEY is the established xAI alias elsewhere in the
307
310
  // repo (search discovery, xai-api/grok-oauth backends), so honoring it here
308
311
  // keeps provider discovery and dispatch resolving the same credential.
309
- const AGENT_PROVIDER_ENV_ALIASES = Object.freeze({
312
+ export const AGENT_PROVIDER_ENV_ALIASES = Object.freeze({
310
313
  xai: ['GROK_API_KEY'],
311
314
  })
312
315