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.
@@ -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,6 +1,7 @@
1
1
  import { homedir } from 'os';
2
2
  import { isAbsolute, relative, resolve } from 'path';
3
3
  import { realpathSync } from 'node:fs';
4
+ import { isWSL } from '../../../../shared/wsl.mjs';
4
5
 
5
6
  // Restore the on-disk casing of a path (win32 only). rg relativizes candidate
6
7
  // paths against its process cwd with a CASE-SENSITIVE prefix strip before
@@ -28,6 +29,21 @@ export function posixPathToWindowsPath(posixPath) {
28
29
  return posixPath;
29
30
  }
30
31
 
32
+ // Reverse of posixPathToWindowsPath, for WSL: a Windows-HOST drive path that
33
+ // arrives from the host (e.g. `C:\Users\x` or `C:/Users/x`) is unreachable as
34
+ // a literal under WSL — the drive surfaces at `/mnt/<letter>/...`. Map
35
+ // `<drive>:` → `/mnt/<lowercase-drive>` and flip backslashes to slashes. Only
36
+ // drive-letter paths are touched; anything else is returned unchanged.
37
+ export function windowsPathToPosixPath(winPath) {
38
+ if (typeof winPath !== 'string') return winPath;
39
+ const m = winPath.match(/^([a-zA-Z]):[\\/](.*)$/);
40
+ if (m) return `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}`;
41
+ // Bare `C:` with no separator → drive root.
42
+ const root = winPath.match(/^([a-zA-Z]):$/);
43
+ if (root) return `/mnt/${root[1].toLowerCase()}/`;
44
+ return winPath;
45
+ }
46
+
31
47
  export function normalizeInputPath(p) {
32
48
  if (typeof p !== 'string') return p;
33
49
  // Trim leading/trailing whitespace — LLMs intermittently emit paths with
@@ -49,6 +65,13 @@ export function normalizeInputPath(p) {
49
65
  if (looksPosixDrive || looksCygdrive || looksWsl || looksUnc) {
50
66
  out = posixPathToWindowsPath(out);
51
67
  }
68
+ } else if (isWSL()) {
69
+ // Reverse direction: a Windows-style drive path from the host maps to
70
+ // the /mnt/<letter> mount. Native-Linux behavior is untouched (gated
71
+ // on isWSL()); a path already in /mnt/... POSIX form is left as-is.
72
+ if (/^[a-zA-Z]:([\\/]|$)/.test(out)) {
73
+ out = windowsPathToPosixPath(out);
74
+ }
52
75
  }
53
76
  try { out = out.normalize('NFC'); } catch {}
54
77
  return out;
@@ -712,7 +712,11 @@ export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStd
712
712
  }
713
713
 
714
714
  function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellType, clientHostPid }) {
715
- if (process.platform === 'win32' && isPowerShellShell(shell, shellType)) {
715
+ // Route ANY PowerShell shell to the PS wrapper, regardless of platform.
716
+ // Gating on win32 sent shell:'powershell' on macOS/Linux down the POSIX
717
+ // path below, which spawns `pwsh <wrapper>.sh` — pwsh then tries to run a
718
+ // bash script. isPowerShellShell already matches pwsh/powershell by stem.
719
+ if (isPowerShellShell(shell, shellType)) {
716
720
  return startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid });
717
721
  }
718
722
 
@@ -738,9 +742,11 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
738
742
  // only the setTimeout below enforced; a mixdog restart between spawn
739
743
  // and deadline would orphan the runaway. --preserve-status keeps the
740
744
  // user command's exit code on success; on timeout the wrapper exits 124.
741
- // `timeout` ships with GNU coreutils on Linux and brew coreutils on
742
- // macOS; absent platforms fall through to the inner
743
- // command (the parent setTimeout still calls refreshShellJob to clean up).
745
+ // `timeout` ships with GNU coreutils on Linux. On macOS, Homebrew
746
+ // coreutils installs it as `gtimeout` (the un-prefixed name is NOT created
747
+ // by default), so the wrapper picks `timeout` if present else `gtimeout`.
748
+ // When neither exists it falls through to the inner command (the parent
749
+ // setTimeout still calls refreshShellJob to clean up).
744
750
  const userCmdQuoted = shellQuoteSingle(command);
745
751
  // P2 fix: invoke the resolved shell (not bash -c) so zsh / dash /
746
752
  // alternate shells run snapshot-aware commands correctly. Drop
@@ -762,7 +768,7 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
762
768
  // status for processes that actually exited 0. `rm -- "$0"` removes
763
769
  // the staged wrapper .cmd.sh after donePath is published so a host
764
770
  // crash before this point still leaves the file for the sweep to GC.
765
- const wrapped = `{ if command -v timeout >/dev/null 2>&1; then touch ${shellQuoteSingle(enforcedPath)}; timeout ${timeoutSeconds} ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; else ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; fi; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
771
+ const wrapped = `{ if command -v timeout >/dev/null 2>&1; then _to=timeout; elif command -v gtimeout >/dev/null 2>&1; then _to=gtimeout; else _to=; fi; if [ -n "$_to" ]; then touch ${shellQuoteSingle(enforcedPath)}; "$_to" ${timeoutSeconds} ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; else ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; fi; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
766
772
  // Stage the wrapped command to a .sh and let the script open its own
767
773
  // output files via `exec > … 2> …`. The parent does NOT pass file
768
774
  // descriptors via stdio inheritance (`stdio: 'ignore'` for all three).
@@ -841,9 +847,24 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
841
847
  '$code = 1',
842
848
  'try {',
843
849
  " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded)",
844
- ' $p = Start-Process -FilePath $exe -ArgumentList $argList -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -WindowStyle Hidden -PassThru',
850
+ // -WindowStyle is a Windows-only Start-Process parameter; pwsh on
851
+ // macOS/Linux throws "not supported on this platform". Add it only on win32.
852
+ ' $spArgs = @{ FilePath = $exe; ArgumentList = $argList; RedirectStandardOutput = $stdoutPath; RedirectStandardError = $stderrPath; PassThru = $true }',
853
+ ' if ($IsWindows -or $null -eq $IsWindows) { $spArgs[\'WindowStyle\'] = \'Hidden\' }',
854
+ ' $p = Start-Process @spArgs',
845
855
  ' if ($timeoutMs -gt 0 -and -not $p.WaitForExit($timeoutMs)) {',
846
- ' try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}',
856
+ // Kill the whole process TREE, not just the direct child: Start-Process
857
+ // launches an intermediate pwsh that spawns the user command, so
858
+ // Stop-Process on $p.Id alone orphans the grandchildren. On Windows use
859
+ // `taskkill /T /F` (tree-terminate); on macOS/Linux pwsh, fall back to
860
+ // Stop-Process (no taskkill there).
861
+ ' try {',
862
+ ' if ($IsWindows -or $null -eq $IsWindows) {',
863
+ ' & taskkill.exe /pid $p.Id /t /f 2>$null | Out-Null',
864
+ ' } else {',
865
+ ' try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}',
866
+ ' }',
867
+ ' } catch {}',
847
868
  ' $code = 124',
848
869
  ' } else {',
849
870
  ' try { $p.WaitForExit() } catch {}',
@@ -875,9 +896,14 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
875
896
  }
876
897
 
877
898
  const shellStem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
878
- const wrapperArgs = shellStem === 'powershell'
879
- ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', wrappedTempPath]
880
- : ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-File', wrappedTempPath];
899
+ // `-WindowStyle Hidden` is a Windows-only CLI switch; pwsh on macOS/Linux
900
+ // rejects it. `-ExecutionPolicy` likewise only applies to Windows
901
+ // PowerShell. Build args per-platform so cross-OS pwsh background jobs run.
902
+ const isWin = process.platform === 'win32';
903
+ const wrapperArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
904
+ if (isWin) wrapperArgs.push('-WindowStyle', 'Hidden');
905
+ if (isWin && shellStem === 'powershell') wrapperArgs.push('-ExecutionPolicy', 'Bypass');
906
+ wrapperArgs.push('-File', wrappedTempPath);
881
907
  // Spawn the staged wrapper directly. detached MUST be false on Windows:
882
908
  // a native pwsh launched with detached:true + stdio:'ignore' exits
883
909
  // immediately without running -File (verified — the detached child dies
@@ -19,10 +19,15 @@ function shellTypeFor(shell) {
19
19
 
20
20
  function shellSpec(shell, shellType = shellTypeFor(shell)) {
21
21
  if (shellType === 'powershell') {
22
+ // `-WindowStyle Hidden` is a Windows-only switch. PowerShell Core
23
+ // (pwsh) on macOS/Linux rejects it, so only append it on win32.
24
+ const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
25
+ if (process.platform === 'win32') psArgs.push('-WindowStyle', 'Hidden');
26
+ psArgs.push('-Command');
22
27
  return {
23
28
  shell,
24
29
  shellArg: '-Command',
25
- shellArgs: ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command'],
30
+ shellArgs: psArgs,
26
31
  shellType,
27
32
  };
28
33
  }
@@ -60,8 +65,10 @@ function resolveWindowsPowerShell() {
60
65
 
61
66
  export function resolveShell() {
62
67
  if (_resolvedShell) return _resolvedShell;
63
- const isWindows = process.platform === 'win32'
64
- || !!(process.env.WINDIR || process.env.SystemRoot);
68
+ // Gate on the actual platform, NOT WINDIR/SystemRoot env presence: under
69
+ // WSL those vars can be inherited via interop while process.platform is
70
+ // 'linux', and WSL must resolve /bin/sh — not Windows PowerShell.
71
+ const isWindows = process.platform === 'win32';
65
72
  if (!isWindows) {
66
73
  _resolvedShell = shellSpec('/bin/sh', 'posix');
67
74
  return _resolvedShell;
@@ -76,7 +83,9 @@ export function resolveShell() {
76
83
  }
77
84
 
78
85
  function _isWindows() {
79
- return process.platform === 'win32' || !!(process.env.WINDIR || process.env.SystemRoot);
86
+ // Real-platform check only (see resolveShell): env presence would make
87
+ // WSL (process.platform 'linux') mis-resolve to Windows Git Bash.
88
+ return process.platform === 'win32';
80
89
  }
81
90
 
82
91
  // Resolve Git Bash on Windows. Strategy (invariant-based, no silent fallback):
@@ -118,7 +118,17 @@ export function ensureGraphBinary(dataDir) {
118
118
  const pkey = platformKey();
119
119
  const asset = manifest.assets?.[pkey];
120
120
  if (!asset || !asset.url || !asset.sha256) {
121
- throw new Error(`[graph-fetcher] no prebuilt mixdog-graph asset for platform ${pkey}`);
121
+ // Unsupported platform/arch (e.g. win32-arm64): the manifest has no
122
+ // downloadable asset for this {os}-{arch}. The code graph has NO JS
123
+ // parsing fallback, so this is terminal — surface a single clear,
124
+ // actionable message instead of a cryptic crash downstream.
125
+ const supported = Object.keys(manifest.assets || {}).join(', ') || '(none)';
126
+ throw new Error(
127
+ `[graph-fetcher] no prebuilt mixdog-graph binary for platform ${pkey} `
128
+ + `(unsupported platform/arch — there is no JS parsing fallback). `
129
+ + `Supported platforms: ${supported}. `
130
+ + `Build it locally: cargo build --release in native/mixdog-graph.`,
131
+ );
122
132
  }
123
133
  const version = String(manifest.version || '0');
124
134
  const dir = graphBinDir(dataDir);
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.14",
2
+ "version": "0.7.16",
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.16/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.16/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.16/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.16/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.16/mixdog-graph-win32-x64.exe",
23
+ "sha256": "6e53cdf523871f818942678d95e2235105b939ceeafc56a3b83b8190eeeba026"
24
24
  }
25
25
  }
26
26
  }
@@ -107,7 +107,17 @@ export function ensurePatchBinary(dataDir) {
107
107
  const pkey = platformKey();
108
108
  const asset = manifest.assets?.[pkey];
109
109
  if (!asset || !asset.url || !asset.sha256) {
110
- throw new Error(`[patch-fetcher] no prebuilt mixdog-patch asset for platform ${pkey}`);
110
+ // Unsupported platform/arch (e.g. win32-arm64): the manifest has no
111
+ // downloadable asset for this {os}-{arch}. apply_patch is native-only
112
+ // (no JS apply fallback), so this is terminal — surface a single clear,
113
+ // actionable message instead of a cryptic crash downstream.
114
+ const supported = Object.keys(manifest.assets || {}).join(', ') || '(none)';
115
+ throw new Error(
116
+ `[patch-fetcher] no prebuilt mixdog-patch binary for platform ${pkey} `
117
+ + `(unsupported platform/arch — apply_patch is native-only, no JS apply fallback). `
118
+ + `Supported platforms: ${supported}. `
119
+ + `Build it locally: cargo build --release in native/mixdog-patch.`,
120
+ );
111
121
  }
112
122
  const version = String(manifest.version || '0');
113
123
  const dir = patchBinDir(dataDir);
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.14",
2
+ "version": "0.7.16",
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.16/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.16/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.16/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.16/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.16/mixdog-patch-win32-x64.exe",
23
+ "sha256": "81fbabab9aa53f5fa57b1691b5ab24cbc1cf6ac1c2a36482c336411abe452d14"
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
  });
@@ -130,30 +130,65 @@ function runScript(name, scriptName, onResult) {
130
130
  return;
131
131
  }
132
132
  const ext = extname(scriptName).toLowerCase();
133
- const cmd = ext === ".py" ? "python3" : "node";
134
- const proc = spawn(cmd, [scriptPath], {
135
- timeout: 3e4,
136
- env: { ...process.env },
137
- windowsHide: true
138
- });
139
- let stdout = "";
140
- let stderr = "";
141
- if (proc.stdout) proc.stdout.on("data", (d) => {
142
- stdout += d;
143
- });
144
- if (proc.stderr) proc.stderr.on("data", (d) => {
145
- stderr += d;
146
- });
147
- proc.on("close", (code) => {
148
- if (code !== 0) {
149
- logEvent(`${name}: script exited ${code}: ${stderr.substring(0, 500)}`);
150
- }
151
- onResult(stdout.substring(0, 2e3), code);
152
- });
153
- proc.on("error", (err) => {
154
- logEvent(`${name}: script spawn error: ${err.message}`);
155
- onResult("", null);
156
- });
133
+ // Pick interpreter candidates. `python3` does not exist on a default
134
+ // Windows install — the Python launcher `py` (and often `python`) does —
135
+ // so on win32 try py → python → python3, falling through on ENOENT.
136
+ // POSIX keeps python3 → python.
137
+ let candidates;
138
+ if (ext === ".py") {
139
+ candidates = process.platform === "win32"
140
+ ? ["py", "python", "python3"]
141
+ : ["python3", "python"];
142
+ } else {
143
+ candidates = ["node"];
144
+ }
145
+ // onResult MUST fire exactly once across the whole candidate chain. A failed
146
+ // ENOENT spawn emits BOTH 'error' (→ we advance) AND 'close', so guard the
147
+ // final callback at the chain level and mark each attempt that advanced so
148
+ // its own 'close' is ignored.
149
+ let resultSent = false;
150
+ const finish = (out, code) => {
151
+ if (resultSent) return;
152
+ resultSent = true;
153
+ onResult(out, code);
154
+ };
155
+ const trySpawn = (idx) => {
156
+ const cmd = candidates[idx];
157
+ let advanced = false;
158
+ const proc = spawn(cmd, [scriptPath], {
159
+ timeout: 3e4,
160
+ env: { ...process.env },
161
+ windowsHide: true
162
+ });
163
+ let stdout = "";
164
+ let stderr = "";
165
+ if (proc.stdout) proc.stdout.on("data", (d) => {
166
+ stdout += d;
167
+ });
168
+ if (proc.stderr) proc.stderr.on("data", (d) => {
169
+ stderr += d;
170
+ });
171
+ proc.on("close", (code) => {
172
+ // This attempt ENOENT'd and handed off to the next candidate — its
173
+ // 'close' is spurious and must not report a result.
174
+ if (advanced) return;
175
+ if (code !== 0) {
176
+ logEvent(`${name}: script exited ${code}: ${stderr.substring(0, 500)}`);
177
+ }
178
+ finish(stdout.substring(0, 2e3), code);
179
+ });
180
+ proc.on("error", (err) => {
181
+ // Interpreter not found → try the next candidate before giving up.
182
+ if (err.code === "ENOENT" && idx + 1 < candidates.length) {
183
+ advanced = true;
184
+ trySpawn(idx + 1);
185
+ return;
186
+ }
187
+ logEvent(`${name}: script spawn error: ${err.message}`);
188
+ finish("", null);
189
+ });
190
+ };
191
+ trySpawn(0);
157
192
  }
158
193
  export {
159
194
  applyParser,
@@ -35,7 +35,14 @@ const {
35
35
  // net.createServer().listen() accepts both transparently.
36
36
  const PIPE_PATH = moduleRequire('../../../lib/hook-pipe-path.cjs')()
37
37
 
38
- const RUNTIME_ROOT = join(tmpdir(), 'mixdog')
38
+ // Honor MIXDOG_RUNTIME_ROOT consistently with runtime-paths.mjs (the consumer
39
+ // of these tool-exec signals): when the override is set, the signal PRODUCER
40
+ // here must write into the same root the channels worker watches, or signals
41
+ // are silently dropped. Default stays tmpdir()/mixdog so non-override installs
42
+ // are unchanged.
43
+ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
44
+ ? pathResolve(process.env.MIXDOG_RUNTIME_ROOT)
45
+ : join(tmpdir(), 'mixdog')
39
46
  const SIGNAL_CONSUMER_MARKER = join(RUNTIME_ROOT, '.tool-exec-consumer')
40
47
  const SUBAGENT_SIGNAL_CONSUMER_MARKER = join(RUNTIME_ROOT, '.tool-exec-subagent-consumer')
41
48
  const SIGNAL_RE_GENERIC = /^tool-exec-\d+-[0-9a-f]+\.signal$/
@@ -341,13 +341,29 @@ export async function ensureRuntime(dataDir) {
341
341
  const pkey = platformKey()
342
342
  const asset = manifest.assets?.[pkey]
343
343
  if (!asset) {
344
+ // Platform/arch absent from the manifest entirely (e.g. win32-arm64).
345
+ // The memory PG runtime cannot start here; fail with a single clear,
346
+ // actionable message. The memory worker's init().catch reports this as
347
+ // degraded and the rest of mixdog (agent, tools) keeps working without
348
+ // memory.
349
+ const supported = Object.keys(manifest.assets || {})
350
+ .filter((k) => isUsableAsset(manifest.assets[k]))
351
+ .join(', ') || '(none)'
344
352
  throw new Error(
345
- `[runtime-fetcher] no asset for platform ${pkey} in manifest. ` +
346
- `Available: ${Object.keys(manifest.assets || {}).join(', ')}`
353
+ `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
354
+ `no runtime asset for this platform/arch in the manifest. ` +
355
+ `Supported: ${supported}. ` +
356
+ `Memory is disabled on this platform; the rest of mixdog continues to work.`
347
357
  )
348
358
  }
349
359
  if (!isUsableAsset(asset)) {
350
- throw new Error(`unsupported platform/arch: no validated runtime asset for ${pkey}`)
360
+ // Platform/arch present but explicitly marked unsupported or carrying a
361
+ // placeholder/TBD payload (e.g. linux-arm64). Same graceful-degrade path.
362
+ throw new Error(
363
+ `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
364
+ `this platform/arch is marked unsupported (no validated runtime asset). ` +
365
+ `Memory is disabled on this platform; the rest of mixdog continues to work.`
366
+ )
351
367
  }
352
368
 
353
369
  const { url, sha256, size } = asset
@@ -6,6 +6,7 @@ import { Agent, fetch as undiciFetch } from 'undici'
6
6
  import { JSDOM } from 'jsdom'
7
7
  import puppeteer from 'puppeteer-core'
8
8
  import { Readability } from '@mozilla/readability'
9
+ import { isWSL } from '../../shared/wsl.mjs'
9
10
 
10
11
 
11
12
  const PKG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return '0.0.1' } })()
@@ -38,13 +39,24 @@ const COMMON_BROWSER_PATHS = (() => {
38
39
  ].filter(Boolean)
39
40
  }
40
41
  if (platform === 'linux') {
41
- return [
42
+ // Native-Linux Chromium/Chrome binaries first. The /mnt/c Windows .exe
43
+ // entries are reachable from WSL's filesystem but puppeteer-core CANNOT
44
+ // drive a Windows GUI browser as a Linux child process (CDP over a pipe to
45
+ // a Win32 binary launched from the Linux ABI does not work), so advertising
46
+ // puppeteer-available off a Windows .exe yields launch failures at runtime.
47
+ // Only offer the Windows .exe fallbacks on plain Linux (Wine/dual-mount
48
+ // edge cases), never under WSL.
49
+ const linuxNative = [
42
50
  '/usr/bin/google-chrome',
43
51
  '/usr/bin/google-chrome-stable',
44
52
  '/usr/bin/chromium',
45
53
  '/usr/bin/chromium-browser',
46
54
  '/snap/bin/chromium',
47
55
  '/usr/bin/microsoft-edge',
56
+ ]
57
+ if (isWSL()) return linuxNative
58
+ return [
59
+ ...linuxNative,
48
60
  '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
49
61
  '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe',
50
62
  '/mnt/c/Program Files/Microsoft/Edge/Application/msedge.exe',
@@ -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
 
@@ -331,7 +334,26 @@ export function getAgentApiKey(provider) {
331
334
  * Never writes to mixdog-config.json.
332
335
  */
333
336
  export function saveSecret(account, value) {
334
- _setSecret(account, value)
337
+ try {
338
+ _setSecret(account, value)
339
+ } catch (err) {
340
+ // On WSL/headless Linux (and any host missing a usable keychain backend,
341
+ // e.g. keytar/libsecret not installed or no running Secret Service) the OS
342
+ // write throws a cryptic backend error. Surface an actionable message that
343
+ // points at the env-var read path the getters already honor, instead of
344
+ // silently writing the plaintext secret to mixdog-config.json.
345
+ const envKey = _envKey(account)
346
+ const agentMatch = String(account || '').match(/^agent\.([^.]+)\.apiKey$/)
347
+ const stdEnv = agentMatch ? AGENT_PROVIDER_ENV[agentMatch[1]] : null
348
+ const envHint = stdEnv ? `${stdEnv} (or ${envKey})` : envKey
349
+ const e = new Error(
350
+ `[config] could not save secret to the OS keychain: ${err && err.message ? err.message : err}\n` +
351
+ ` No usable keychain backend on this host (common on WSL / headless Linux without libsecret).\n` +
352
+ ` Set the ${envHint} environment variable instead — the runtime reads it directly.`
353
+ )
354
+ e.cause = err
355
+ throw e
356
+ }
335
357
  }
336
358
 
337
359
  export function deleteSecret(account) {