mixdog 0.9.85 → 0.9.86

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.
Files changed (74) hide show
  1. package/README.md +7 -1
  2. package/package.json +3 -2
  3. package/src/defaults/agents.json +12 -0
  4. package/src/defaults/skills/setup/SKILL.md +44 -12
  5. package/src/help.mjs +40 -9
  6. package/src/lib/keychain-cjs.cjs +11 -1
  7. package/src/rules/agent/43-title-agent.md +22 -0
  8. package/src/rules/shared/01-tool.md +3 -3
  9. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  10. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  13. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  14. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  16. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  17. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  18. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  19. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  20. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  21. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  22. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  23. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  24. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  29. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  30. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  31. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  32. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  33. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  34. package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
  35. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  36. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  37. package/src/runtime/media/renditions.mjs +1 -1
  38. package/src/runtime/media/store.mjs +1 -1
  39. package/src/runtime/shared/atomic-file.mjs +25 -14
  40. package/src/runtime/shared/automation-attachments.mjs +3 -3
  41. package/src/runtime/shared/child-guardian.mjs +15 -5
  42. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  43. package/src/runtime/shared/resource-admission.mjs +7 -3
  44. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  45. package/src/session-runtime/lifecycle-api.mjs +34 -9
  46. package/src/session-runtime/prewarm.mjs +35 -0
  47. package/src/session-runtime/remote-control.mjs +8 -47
  48. package/src/session-runtime/remote-transcript.mjs +3 -7
  49. package/src/session-runtime/route-preparation.mjs +24 -0
  50. package/src/session-runtime/runtime-core.mjs +37 -85
  51. package/src/session-runtime/session-turn-api.mjs +9 -2
  52. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  53. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  54. package/src/standalone/agent-shard/shard-pool.mjs +442 -0
  55. package/src/standalone/agent-tool.mjs +222 -0
  56. package/src/standalone/channel-daemon-transport.mjs +126 -185
  57. package/src/standalone/channel-daemon.mjs +9 -10
  58. package/src/tui/App.jsx +16 -9
  59. package/src/tui/app/channel-pickers.mjs +3 -8
  60. package/src/tui/app/route-pickers.mjs +117 -152
  61. package/src/tui/app/settings-picker.mjs +90 -89
  62. package/src/tui/app/slash-commands.mjs +7 -7
  63. package/src/tui/app/slash-dispatch.mjs +0 -45
  64. package/src/tui/components/StatusLine.jsx +1 -1
  65. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  66. package/src/tui/dist/index.mjs +243 -323
  67. package/src/tui/engine/session-api-ext.mjs +24 -9
  68. package/src/tui/engine.mjs +7 -1
  69. package/src/tui/figures.mjs +0 -1
  70. package/src/ui/statusline-format.mjs +0 -1
  71. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  72. package/src/lib/config-cjs.cjs +0 -61
  73. package/src/runtime/shared/launcher-control.mjs +0 -258
  74. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.1",
2
+ "version": "0.1.2",
3
3
  "_comment": "Synced from immutable graph-v release assets.",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.1/mixdog-graph-darwin-arm64",
7
- "sha256": "3fb76a5e3064547b68e7bcab6ce2a855ce615a732157d836cb561c5d7c8d86b6"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.2/mixdog-graph-darwin-arm64",
7
+ "sha256": "e54f09013af2ae057eae89c629237af79caeb248e59c335c64c88fc7b720d662"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.1/mixdog-graph-darwin-x64",
11
- "sha256": "64eeffc17984a76b0fbf7d85f9d84cb5e1bb949a251053feb260f70481235bb1"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.2/mixdog-graph-darwin-x64",
11
+ "sha256": "d4506109fab2a392157ee016d9dfd2524d74363925004f008795eab3fba3de23"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.1/mixdog-graph-linux-arm64",
15
- "sha256": "15d5cbb7575b3e0671c6f5fbef3460c5c9353f17968a80faba737fcc15b49c33"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.2/mixdog-graph-linux-arm64",
15
+ "sha256": "ba5f3e52f95c6e0c976115fea9e60c1a1d4534565a15c2da7284ad9c59c1eeb5"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.1/mixdog-graph-linux-x64",
19
- "sha256": "5079544c09ae2311295cef21978fda3c1c06f93beaaf666cfe1f68971d5b27ae"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.2/mixdog-graph-linux-x64",
19
+ "sha256": "3642d773317a468fe5ba5efc4544ae91a68117b78d577ee2aad37e2639fcc01a"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.1/mixdog-graph-win32-x64.exe",
23
- "sha256": "acfbcd02b96154f4fb342a44dec1a4808752944c5941bbb7983d84ed64464830"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.2/mixdog-graph-win32-x64.exe",
23
+ "sha256": "3d86870fc6d3ce1151dd97b91fc634fd2755e3f3c74f10809aaea34ae3e2aa02"
24
24
  }
25
25
  }
26
26
  }
@@ -0,0 +1,286 @@
1
+ 'use strict';
2
+ // Warm standby pwsh pool (Windows only). Every one-shot shell call pays the
3
+ // pwsh.exe startup cost (~300ms measured). This pool keeps a few pre-spawned
4
+ // pwsh processes blocked on stdin; execShellCommand adopts one and feeds the
5
+ // command via stdin, cutting call latency roughly in half (measured
6
+ // ~330ms → ~155ms end-to-end).
7
+ //
8
+ // Semantics parity (validated empirically against `pwsh -Command <script>`
9
+ // on exit codes, streams, unicode, multi-line scripts, explicit `exit N`,
10
+ // native failures, Write-Error, parse errors):
11
+ // - the bootstrap reads RAW stdin bytes and decodes UTF-8 explicitly
12
+ // ([Console]::In uses the ANSI codepage for redirected stdin — Korean/emoji
13
+ // would mojibake otherwise);
14
+ // - the 0/1 exit mapping (`if ($?) { exit 0 } else { exit 1 }`) is APPENDED
15
+ // INTO the script text so `$?` reflects the user script's LAST statement,
16
+ // exactly what pwsh -Command does implicitly; explicit `exit N` and
17
+ // terminating errors bypass it with identical codes;
18
+ // - cwd is applied in-script (Set-Location + [Environment]::CurrentDirectory)
19
+ // since the standby was spawned before the call's cwd was known.
20
+ //
21
+ // Adoption rules keep this pool NARROW: win32 + pwsh (never Windows
22
+ // PowerShell 5.1, whose `$?` native semantics differ) + the standard
23
+ // -NoLogo/-NoProfile/-NonInteractive/-Command argv + an env whose signature
24
+ // matches the standby's spawn env. Everything else falls back to the classic
25
+ // cold spawn path unchanged. Kill-switch: MIXDOG_PWSH_STANDBY_POOL=0.
26
+ import { spawn } from 'node:child_process';
27
+ import { basename } from 'node:path';
28
+ import { createHash, randomBytes } from 'node:crypto';
29
+ import { killProcessTree, windowsPidHasDescendants } from '../builtin/shell-job-process.mjs';
30
+
31
+ function _poolTarget() {
32
+ const n = Math.floor(Number(process.env.MIXDOG_PWSH_STANDBY_POOL));
33
+ if (Number.isFinite(n) && n >= 0) return Math.min(8, n);
34
+ return 2;
35
+ }
36
+ const POOL_TARGET = _poolTarget();
37
+ // Only standbys older than this are handed out: a younger one is still paying
38
+ // pwsh startup, so adopting it would just relocate the cold cost. Callers get
39
+ // null (classic spawn) while the pool warms.
40
+ const STANDBY_READY_AGE_MS = 400;
41
+ const EXPECTED_ARGS = Object.freeze(['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']);
42
+
43
+ // Marker-loop bootstrap: the standby serves MANY commands over its lifetime.
44
+ // Protocol per command (all lines UTF-8 over stdin):
45
+ // <script lines...>
46
+ // <<<MIXDOG_RUN_<nonce>>>>
47
+ // The loop resets env + location to the spawn baseline, executes the script
48
+ // with the same `$?`→0/1 exit mapping pwsh -Command applies implicitly, then
49
+ // emits `<<<MIXDOG_DONE_<nonce>:<code>>>>` on stdout and
50
+ // `<<<MIXDOG_DONE_<nonce>>>>` on stderr (both streams provably drained).
51
+ // stdin EOF exits the process with the LAST command's code — that is exactly
52
+ // what background-promotion relies on for its exit-file. A user script that
53
+ // calls `exit N` terminates the whole process with N (identical code to the
54
+ // one-shot path); the caller settles via 'close' and the entry is discarded.
55
+ function _standbyBootScript(nonce) {
56
+ const runSentinel = `<<<MIXDOG_RUN_${nonce}>>>`;
57
+ const donePrefix = `<<<MIXDOG_DONE_${nonce}`;
58
+ return [
59
+ '$null = & {0}',
60
+ "[Console]::Out.Write('')",
61
+ "[Console]::Error.Write('')",
62
+ '$__sr = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))',
63
+ '$__baseEnv = @{}',
64
+ 'Get-ChildItem env: | ForEach-Object { $__baseEnv[$_.Name] = $_.Value }',
65
+ '$__baseLoc = (Get-Location).ProviderPath',
66
+ '$__code = 0',
67
+ 'while ($true) {',
68
+ ' $__buf = New-Object System.Text.StringBuilder',
69
+ ' while ($true) {',
70
+ ' $__line = $__sr.ReadLine()',
71
+ ' if ($null -eq $__line) { exit $__code }',
72
+ ` if ($__line -eq '${runSentinel}') { break }`,
73
+ ' [void]$__buf.AppendLine($__line)',
74
+ ' }',
75
+ ' foreach ($__n in @(Get-ChildItem env: | ForEach-Object Name)) { if (-not $__baseEnv.ContainsKey($__n)) { Remove-Item ("env:$__n") -ErrorAction SilentlyContinue } }',
76
+ ' foreach ($__k in $__baseEnv.Keys) { Set-Item ("env:$__k") $__baseEnv[$__k] }',
77
+ ' Set-Location -LiteralPath $__baseLoc',
78
+ ' $global:LASTEXITCODE = 0',
79
+ ' $global:__MIXDOG_RC = 1',
80
+ ' try {',
81
+ " $__sc = [ScriptBlock]::Create($__buf.ToString() + [Environment]::NewLine + 'if ($?) { $global:__MIXDOG_RC = 0 } else { $global:__MIXDOG_RC = 1 }')",
82
+ ' & $__sc',
83
+ ' } catch {',
84
+ ' $global:__MIXDOG_RC = 1',
85
+ " [Console]::Error.WriteLine('Exception: ' + $_.Exception.Message)",
86
+ ' }',
87
+ ' $__code = $global:__MIXDOG_RC',
88
+ ' [Console]::Out.Flush()',
89
+ ' [Console]::Error.Flush()',
90
+ ` [Console]::Out.WriteLine('${donePrefix}:' + $__code + '>>>')`,
91
+ ` [Console]::Error.WriteLine('${donePrefix}>>>')`,
92
+ '}',
93
+ ].join('\n');
94
+ }
95
+
96
+ const _idle = [];
97
+ let _envSig = null;
98
+ let _exitHookInstalled = false;
99
+
100
+ function _envSignature(env) {
101
+ const h = createHash('sha1');
102
+ for (const k of Object.keys(env).sort()) {
103
+ h.update(k); h.update('='); h.update(String(env[k] ?? '')); h.update('\0');
104
+ }
105
+ return h.digest('base64');
106
+ }
107
+
108
+ function _killEntry(entry) {
109
+ try { entry.child.kill(); } catch { /* already down */ }
110
+ }
111
+
112
+ function _discardIdle(entry) {
113
+ const i = _idle.indexOf(entry);
114
+ if (i !== -1) _idle.splice(i, 1);
115
+ }
116
+
117
+ function _installExitHook() {
118
+ if (_exitHookInstalled) return;
119
+ _exitHookInstalled = true;
120
+ // Idle standbys block on stdin forever; never leave them behind.
121
+ process.once('exit', () => {
122
+ for (const entry of _idle.splice(0)) _killEntry(entry);
123
+ });
124
+ }
125
+
126
+ function _spawnStandby(shell, env, sig) {
127
+ let child;
128
+ const nonce = randomBytes(8).toString('hex');
129
+ try {
130
+ child = spawn(shell, [...EXPECTED_ARGS, _standbyBootScript(nonce)], {
131
+ env,
132
+ cwd: process.env.SystemRoot || 'C:\\Windows',
133
+ windowsHide: true,
134
+ stdio: ['pipe', 'pipe', 'pipe'],
135
+ detached: false,
136
+ });
137
+ } catch { return; }
138
+ const entry = { child, sig, nonce, spawnedAt: Date.now(), taken: false, consumed: false };
139
+ child.on('error', () => { if (!entry.taken) { _discardIdle(entry); _killEntry(entry); } });
140
+ child.on('exit', () => { if (!entry.taken) _discardIdle(entry); });
141
+ // A dead standby's pending stdin write must never crash the daemon.
142
+ child.stdin?.on?.('error', () => { /* swallow — entry is discarded via exit */ });
143
+ // Idle standbys must not hold the event loop open (process handle AND
144
+ // pipe handles each ref it). Re-ref'd on take.
145
+ try {
146
+ child.unref();
147
+ child.stdin?.unref?.();
148
+ child.stdout?.unref?.();
149
+ child.stderr?.unref?.();
150
+ } catch { /* best-effort */ }
151
+ _idle.push(entry);
152
+ }
153
+
154
+ function _topUp(shell, env, sig) {
155
+ while (_idle.length < POOL_TARGET) _spawnStandby(shell, env, sig);
156
+ }
157
+
158
+ /**
159
+ * Adopt a warm standby for this call, or return null for the classic spawn
160
+ * path. On success returns { child, feed(spawnCommand, cwd) }; the caller
161
+ * wires its stdio/exit handlers FIRST and then calls feed() exactly once.
162
+ */
163
+ export function takePwshStandby({ shell, shellArgs, env }) {
164
+ if (process.platform !== 'win32' || POOL_TARGET === 0) return null;
165
+ const stem = basename(String(shell || '')).toLowerCase();
166
+ if (stem !== 'pwsh' && stem !== 'pwsh.exe') return null;
167
+ if (!Array.isArray(shellArgs)
168
+ || shellArgs.length !== EXPECTED_ARGS.length
169
+ || shellArgs.some((a, i) => a !== EXPECTED_ARGS[i])) return null;
170
+ if (!env || typeof env !== 'object') return null;
171
+ _installExitHook();
172
+ const sig = _envSignature(env);
173
+ if (sig !== _envSig) {
174
+ // Env changed (secrets scrub set, locale, config reload): stale
175
+ // standbys would run with the old env — drain and respawn.
176
+ for (const entry of _idle.splice(0)) _killEntry(entry);
177
+ _envSig = sig;
178
+ }
179
+ _topUp(shell, env, sig);
180
+ const now = Date.now();
181
+ let entry = null;
182
+ for (let i = 0; i < _idle.length; i += 1) {
183
+ const cand = _idle[i];
184
+ if (now - cand.spawnedAt < STANDBY_READY_AGE_MS) continue;
185
+ if (cand.child.exitCode !== null || cand.child.signalCode !== null) continue;
186
+ if (!cand.child.stdin || cand.child.stdin.destroyed) continue;
187
+ _idle.splice(i, 1);
188
+ entry = cand;
189
+ break;
190
+ }
191
+ if (!entry) return null;
192
+ entry.taken = true;
193
+ entry.consumed = false;
194
+ try {
195
+ entry.child.ref();
196
+ entry.child.stdin?.ref?.();
197
+ entry.child.stdout?.ref?.();
198
+ entry.child.stderr?.ref?.();
199
+ } catch { /* best-effort */ }
200
+ _topUp(shell, env, sig);
201
+ return {
202
+ child: entry.child,
203
+ nonce: entry.nonce,
204
+ runSentinel: `<<<MIXDOG_RUN_${entry.nonce}>>>`,
205
+ doneMarkerPrefix: `<<<MIXDOG_DONE_${entry.nonce}`,
206
+ run(spawnCommand, cwdForRun) {
207
+ const esc = String(cwdForRun || '').replace(/'/g, "''");
208
+ const prelude = esc
209
+ ? `Set-Location -LiteralPath '${esc}' -ErrorAction Stop\n[Environment]::CurrentDirectory = '${esc}'\n`
210
+ : '';
211
+ try {
212
+ const body = prelude + String(spawnCommand ?? '');
213
+ entry.child.stdin.write(body.endsWith('\n') ? body : `${body}\n`, 'utf8');
214
+ entry.child.stdin.write(`<<<MIXDOG_RUN_${entry.nonce}>>>\n`, 'utf8');
215
+ return true;
216
+ } catch {
217
+ // Feed failed (child died in the take→run window): kill so the
218
+ // caller's close handler settles with an error result.
219
+ _killEntry(entry);
220
+ return false;
221
+ }
222
+ },
223
+ // Background-promotion handoff: after the in-flight command completes,
224
+ // the loop reads EOF and exits with that command's code — the adopted
225
+ // job's close-wired exit file then records the REAL command exit.
226
+ endStdin() {
227
+ entry.consumed = true;
228
+ try { entry.child.stdin.end(); } catch { /* already gone */ }
229
+ },
230
+ // Post-command recycle: return the standby to the pool ONLY when the
231
+ // command left no descendant processes attached to its stdio (a live
232
+ // grandchild would pollute the NEXT command's capture). Dirty, dead,
233
+ // env-stale, or surplus standbys are tree-killed instead. Runs off the
234
+ // result path; the caller releases its admission lease afterwards.
235
+ async recycle() {
236
+ if (entry.consumed) return false;
237
+ entry.consumed = true;
238
+ const c = entry.child;
239
+ const alive = () => c.exitCode === null && c.signalCode === null;
240
+ let dirty = true;
241
+ if (alive() && c.stdin && !c.stdin.destroyed) {
242
+ try { dirty = await windowsPidHasDescendants(c.pid); } catch { dirty = true; }
243
+ }
244
+ if (dirty || !alive() || !c.stdin || c.stdin.destroyed
245
+ || _idle.length >= POOL_TARGET || _envSig !== entry.sig) {
246
+ try { killProcessTree(c.pid, 'SIGKILL'); } catch { /* already down */ }
247
+ _killEntry(entry);
248
+ return false;
249
+ }
250
+ entry.taken = false;
251
+ entry.consumed = false;
252
+ entry.spawnedAt = Date.now() - STANDBY_READY_AGE_MS;
253
+ try {
254
+ c.unref();
255
+ c.stdin?.unref?.();
256
+ c.stdout?.unref?.();
257
+ c.stderr?.unref?.();
258
+ } catch { /* best-effort */ }
259
+ _idle.push(entry);
260
+ return true;
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Boot-time warmup: spawn the standby processes NOW (same gates as take) so a
267
+ * session's first shell call finds a warm pool instead of paying the pwsh
268
+ * startup cost. Best-effort; returns true when standbys were ensured.
269
+ */
270
+ export function prewarmPwshStandbyPool({ shell, shellArgs, env }) {
271
+ if (process.platform !== 'win32' || POOL_TARGET === 0) return false;
272
+ const stem = basename(String(shell || '')).toLowerCase();
273
+ if (stem !== 'pwsh' && stem !== 'pwsh.exe') return false;
274
+ if (!Array.isArray(shellArgs)
275
+ || shellArgs.length !== EXPECTED_ARGS.length
276
+ || shellArgs.some((a, i) => a !== EXPECTED_ARGS[i])) return false;
277
+ if (!env || typeof env !== 'object') return false;
278
+ _installExitHook();
279
+ const sig = _envSignature(env);
280
+ if (sig !== _envSig) {
281
+ for (const entry of _idle.splice(0)) _killEntry(entry);
282
+ _envSig = sig;
283
+ }
284
+ _topUp(shell, env, sig);
285
+ return true;
286
+ }
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.20",
2
+ "version": "0.7.21",
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/tribgames/mixdog/releases/download/patch-v0.7.20/mixdog-patch-darwin-arm64",
7
- "sha256": "12b9b3ad3365fcde21c83c1b42236f6bd70dbe411d79e5c0f7c3c5743f1a3185"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.21/mixdog-patch-darwin-arm64",
7
+ "sha256": "f52db5c65ffdc28ace93a6dd54d7d1c8e76f3daba45a74e63676513fcee4b51b"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.20/mixdog-patch-darwin-x64",
11
- "sha256": "ff5de6da5fec3abbf617c0c3b1ead8e724d4cb0e9948277253889c83bc27a570"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.21/mixdog-patch-darwin-x64",
11
+ "sha256": "024bacaaead16a368fa4a2a148f66f3892ee03be78803c40dda9acd59f3dd2c1"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.20/mixdog-patch-linux-arm64",
15
- "sha256": "76d7cf54df7face32fb20862425ce25ad44e9800440132dea3f0ad387c92f07e"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.21/mixdog-patch-linux-arm64",
15
+ "sha256": "c197931e005fb89211b60b66008bc0c3b42200393483e0ae4a251e2111a06d44"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.20/mixdog-patch-linux-x64",
19
- "sha256": "43f6610beadf25eca752a09775fcb8858818ad2d9f7b8cf6a00deaa47571f3f0"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.21/mixdog-patch-linux-x64",
19
+ "sha256": "8d958f9315ec82a17fa2e21560630f66a4e55a8a56ad92d69ba9f3f5ff61c15c"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.20/mixdog-patch-win32-x64.exe",
23
- "sha256": "4d5d2242f9662aeb968c24df571a498008d9c504fd31214fe9dac31aac669fdd"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.21/mixdog-patch-win32-x64.exe",
23
+ "sha256": "cc927c7bc6f09ecae591c058d0053cd5bc65ee17c56c9bce6871487be1d2baa2"
24
24
  }
25
25
  }
26
26
  }
@@ -46,6 +46,7 @@ import {
46
46
  extractPowerShellCommandInner,
47
47
  } from './shell-powershell.mjs';
48
48
  import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
49
+ import { takePwshStandby } from './lib/pwsh-standby-pool.mjs';
49
50
 
50
51
  export {
51
52
  _maybeEncodePowerShellCommand,
@@ -312,6 +313,11 @@ export function execShellCommand({
312
313
  const _isBackground = /(^|[^&|])&$/.test(_trimmed);
313
314
 
314
315
  let child;
316
+ let _standby = null;
317
+ let _viaMarker = false;
318
+ let _standbyDetach = null;
319
+ let _markerFilters = null;
320
+ let _onChildErrorRef = null;
315
321
  try {
316
322
  resourceLease = await _acquireShellLeaseBounded(admission, {
317
323
  abortSignal,
@@ -339,6 +345,25 @@ export function execShellCommand({
339
345
  const argv = Array.isArray(shellArgs) && shellArgs.length > 0
340
346
  ? [...shellArgs, _spawnCommand]
341
347
  : [shellArg, _spawnCommand];
348
+ const _onChildError = (err) => {
349
+ spawnError = spawnError || err;
350
+ failurePhase = 'tool';
351
+ failureReason = 'spawn failed';
352
+ if (settle) settle(1, null);
353
+ else pendingChildError = pendingChildError || err;
354
+ };
355
+ _onChildErrorRef = _onChildError;
356
+ // Warm standby adoption (Windows/pwsh only, see pwsh-standby-pool.mjs):
357
+ // a pre-spawned pwsh blocked on stdin replaces the cold spawn. The
358
+ // command is fed via stdin AFTER the stdio/exit wiring below, so no
359
+ // output can be produced before the capture handlers are attached.
360
+ // Standby children always use pipe capture — identical to the win32
361
+ // default (SHELL_DIRECT_CAPTURE is false on win32).
362
+ _standby = takePwshStandby({ shell, shellArgs, env });
363
+ if (_standby) {
364
+ child = _standby.child;
365
+ child.on('error', _onChildError);
366
+ } else {
342
367
  // Direct capture (CC file-mode parity): the child writes stdout/stderr
343
368
  // into the spill files via inherited fds. No parent pipes exist, so
344
369
  // 'close' fires with 'exit' and grandchildren cannot hold the capture
@@ -371,13 +396,8 @@ export function execShellCommand({
371
396
  },
372
397
  });
373
398
  child = spawned.child;
374
- spawned.adoptErrorHandler((err) => {
375
- spawnError = spawnError || err;
376
- failurePhase = 'tool';
377
- failureReason = 'spawn failed';
378
- if (settle) settle(1, null);
379
- else pendingChildError = pendingChildError || err;
380
- });
399
+ spawned.adoptErrorHandler(_onChildError);
400
+ }
381
401
  startChildGuardian({
382
402
  childPid: child.pid,
383
403
  childGroupPid: child.pid,
@@ -385,7 +405,12 @@ export function execShellCommand({
385
405
  });
386
406
  // Windows has no process-group handle. Begin observing while the owned
387
407
  // root still exists so descendants can be proven before root exit.
388
- releaseResourceLeaseWhenTreeQuiescent(child.pid, { waitForRootExit: true });
408
+ // Standby children skip the eager tracker: their root stays ALIVE across
409
+ // commands (waitForRootExit could never confirm and its CIM polling
410
+ // would run for the standby's whole lifetime). Marker settle releases
411
+ // the lease through the recycle path instead; kill/exit fallbacks below
412
+ // create the tracker lazily once the root is really gone.
413
+ if (!_standby) releaseResourceLeaseWhenTreeQuiescent(child.pid, { waitForRootExit: true });
389
414
  } catch (err) {
390
415
  const cleanupError = await releaseResourceLease();
391
416
  const spawnText = String((err && err.message) || err);
@@ -418,15 +443,77 @@ export function execShellCommand({
418
443
  }
419
444
 
420
445
  // Pipe-capture wiring only; direct mode has no parent-side streams.
446
+ // Standby children speak the marker protocol: per-command completion
447
+ // markers on BOTH streams are stripped from the capture and drive settle()
448
+ // (the process stays alive for the next command), while kill/exit paths
449
+ // still settle through 'close' as before.
450
+ let _stdoutData = (chunk) => { taskOutput.writeStdout(chunk); };
451
+ let _stderrData = (chunk) => taskOutput.writeStderr(chunk);
452
+ if (_standby) {
453
+ const donePrefix = _standby.doneMarkerPrefix;
454
+ const _mk = { outDone: false, errDone: false, code: 1 };
455
+ const tryComplete = () => {
456
+ if (!_mk.outDone || !_mk.errDone) return;
457
+ if (settled || autoBackgrounded || killed) return;
458
+ // Both streams delivered their marker — everything the command wrote
459
+ // is already in the capture. Detach BEFORE settle so late output from
460
+ // an undiscovered descendant can never pollute this task, then settle
461
+ // through the marker path (recycle + direct lease release).
462
+ _standbyDetach?.();
463
+ _viaMarker = true;
464
+ settle(_mk.code, null);
465
+ };
466
+ const makeFilter = (write, onMarkerLine) => {
467
+ let tail = '';
468
+ return {
469
+ data: (chunk) => {
470
+ tail += chunk;
471
+ let idx;
472
+ let out = '';
473
+ while ((idx = tail.indexOf('\n')) !== -1) {
474
+ const line = tail.slice(0, idx + 1);
475
+ tail = tail.slice(idx + 1);
476
+ if (line.startsWith(donePrefix)) { onMarkerLine(line); continue; }
477
+ out += line;
478
+ }
479
+ if (out) write(out);
480
+ // A marker always ends with a newline, so only a pathological
481
+ // marker-free run can grow the tail; spill it but keep enough to
482
+ // recognize a marker split across chunks.
483
+ if (tail.length > 65536) { write(tail.slice(0, -256)); tail = tail.slice(-256); }
484
+ },
485
+ flush: () => { if (tail) { write(tail); tail = ''; } },
486
+ };
487
+ };
488
+ const outFilter = makeFilter((s) => taskOutput.writeStdout(s), (line) => {
489
+ const m = /^:(-?\d+)>>>/.exec(line.slice(donePrefix.length));
490
+ _mk.code = m ? Number(m[1]) : 1;
491
+ _mk.outDone = true;
492
+ tryComplete();
493
+ });
494
+ const errFilter = makeFilter((s) => taskOutput.writeStderr(s), () => {
495
+ _mk.errDone = true;
496
+ tryComplete();
497
+ });
498
+ _markerFilters = [outFilter, errFilter];
499
+ _stdoutData = outFilter.data;
500
+ _stderrData = errFilter.data;
501
+ }
421
502
  if (child.stdout) {
422
503
  child.stdout.setEncoding('utf-8');
423
- child.stdout.on('data', (chunk) => {
424
- taskOutput.writeStdout(chunk);
425
- });
504
+ child.stdout.on('data', _stdoutData);
426
505
  }
427
506
  if (child.stderr) {
428
507
  child.stderr.setEncoding('utf-8');
429
- child.stderr.on('data', (chunk) => taskOutput.writeStderr(chunk));
508
+ child.stderr.on('data', _stderrData);
509
+ }
510
+
511
+ // Standby path: the capture handlers are attached — release the command
512
+ // into the waiting pwsh now. Skipped when a pre-fired abort already
513
+ // tree-killed the child (its close still settles the call).
514
+ if (_standby && !killed) {
515
+ const _spawnCommandForStandby = _maybeEncodePowerShellCommand(command);
516
+ _standby.run(_spawnCommandForStandby, cwd);
430
517
  }
431
518
 
432
519
  // If the spill writer hits an I/O failure (full disk, EBADF after
@@ -456,6 +543,11 @@ export function execShellCommand({
456
543
  autoBgTimer = null;
457
544
  }
458
545
  detachAbortHandler();
546
+ // Marker-free paths (kill/exit) may hold a partial last line in the
547
+ // standby stream filters — flush it so the capture keeps the tail.
548
+ if (_markerFilters) {
549
+ for (const f of _markerFilters) { try { f.flush(); } catch { /* best-effort */ } }
550
+ }
459
551
  // getStdout/getStderr can throw on a spilled-file read failure (EBADF
460
552
  // after unlink race, EACCES). Without this catch the rejection bubbles
461
553
  // up and leaves the outer settle promise unresolved, hanging the call.
@@ -480,12 +572,34 @@ export function execShellCommand({
480
572
  } else {
481
573
  taskOutput.closeFds();
482
574
  }
575
+ if (_viaMarker && _standby) {
576
+ // Marker-settled standby: the root pwsh is ALIVE by design, so the
577
+ // waitForRootExit tracker could never confirm. recycle() probes for
578
+ // leftover descendants off the result path (returns the standby to the
579
+ // pool when clean, tree-kills it otherwise) and the lease frees after.
580
+ const lease = resourceLease;
581
+ resourceLease = null;
582
+ void _standby.recycle()
583
+ .catch(() => false)
584
+ .then(() => { try { return Promise.resolve(lease?.release()).catch(() => {}); } catch { return null; } });
585
+ } else {
483
586
  const leaseSettlement = resourceLeaseSettlement
484
587
  || releaseResourceLeaseWhenTreeQuiescent(child.pid);
485
588
  leaseSettlement.tracker?.rootExited?.();
486
- await leaseSettlement.tracker?.afterRootExitCheck?.();
487
- if (!leaseSettlement.pending) await leaseSettlement.promise;
488
- else _armLeaseLingerCap(() => leaseSettlement.lease, taskId);
589
+ // Decoupled lease settlement: the tool RESULT resolves immediately —
590
+ // the output is fully captured above and nothing in ExecResult depends
591
+ // on admission accounting. The quiescence proof (a Windows CIM process
592
+ // snapshot costing ~200ms per call) and the lease release continue in
593
+ // the background: exactly-once release is owned by the tracker/lease,
594
+ // and the linger cap still bounds a stuck descendant tree.
595
+ void (async () => {
596
+ try {
597
+ await leaseSettlement.tracker?.afterRootExitCheck?.();
598
+ if (!leaseSettlement.pending) await leaseSettlement.promise;
599
+ else _armLeaseLingerCap(() => leaseSettlement.lease, taskId);
600
+ } catch { /* background release must never throw */ }
601
+ })();
602
+ }
489
603
  resolveResult(
490
604
  new ExecResult({
491
605
  stdout,
@@ -513,14 +627,15 @@ export function execShellCommand({
513
627
  // bytes; settling there can lose the tail of the output. 'close' fires
514
628
  // after stdio is fully drained, so getStdout()/getStderr() see the
515
629
  // complete capture.
516
- child.once('close', (code, signal) => settle(code, signal));
630
+ const _onChildClose = (code, signal) => settle(code, signal);
631
+ child.once('close', _onChildClose);
517
632
  if (pendingChildError) settle(1, null);
518
633
  // 'close' only fires after stdio drains; a forked grandchild that
519
634
  // inherited stdout/stderr fds can hold them open past direct-child
520
635
  // exit and stall settle() until timeoutMs. 'exit' fires on direct
521
636
  // child termination regardless — give 'close' a 2 s grace then
522
637
  // settle anyway.
523
- child.once('exit', (code, signal) => {
638
+ const _onChildExit = (code, signal) => {
524
639
  if (_isBackground) {
525
640
  setImmediate(() => settle(code == null ? 1 : code, signal));
526
641
  return;
@@ -531,7 +646,21 @@ export function execShellCommand({
531
646
  settle(code == null ? 1 : code, signal);
532
647
  }, 2000);
533
648
  if (grace.unref) grace.unref();
534
- });
649
+ };
650
+ child.once('exit', _onChildExit);
651
+ // Standby recycle prerequisite: THIS command's listeners must come off the
652
+ // long-lived child before it can serve another command (otherwise every
653
+ // reuse would stack another close/exit/data listener and stale handlers
654
+ // would keep writing into this task's finished capture).
655
+ _standbyDetach = () => {
656
+ try { child.stdout?.removeListener('data', _stdoutData); } catch { /* best-effort */ }
657
+ try { child.stderr?.removeListener('data', _stderrData); } catch { /* best-effort */ }
658
+ try { child.removeListener('close', _onChildClose); } catch { /* best-effort */ }
659
+ try { child.removeListener('exit', _onChildExit); } catch { /* best-effort */ }
660
+ if (_onChildErrorRef) {
661
+ try { child.removeListener('error', _onChildErrorRef); } catch { /* best-effort */ }
662
+ }
663
+ };
535
664
 
536
665
  // Auto-background transition (CC startBackgrounding analogue). Two triggers
537
666
  // resolve the call immediately with a 'backgrounded' result while the
@@ -552,6 +681,11 @@ export function execShellCommand({
552
681
  if (settled || autoBackgrounded || killed || timedOut) return;
553
682
  if (child.exitCode != null || child.signalCode != null) return;
554
683
  autoBackgrounded = true;
684
+ // Standby handoff: stop feeding this process. Once the in-flight
685
+ // command completes, the marker loop reads stdin EOF and exits with
686
+ // that command's code — the adopted job's close-wired exit file then
687
+ // records the real command exit, and the standby is never reused.
688
+ if (_standby) { try { _standby.endStdin(); } catch { /* best-effort */ } }
555
689
  // The foreground capture is over; stop the local watchdogs/timers so
556
690
  // they cannot treeKill the now-adopted child.
557
691
  if (timer) { clearTimeout(timer); timer = null; }