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.
- package/README.md +7 -1
- package/package.json +3 -2
- package/src/defaults/agents.json +12 -0
- package/src/defaults/skills/setup/SKILL.md +44 -12
- package/src/help.mjs +40 -9
- package/src/lib/keychain-cjs.cjs +11 -1
- package/src/rules/agent/43-title-agent.md +22 -0
- package/src/rules/shared/01-tool.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
- package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
- package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
- package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
- package/src/runtime/media/adapters/gemini-video.mjs +1 -1
- package/src/runtime/media/renditions.mjs +1 -1
- package/src/runtime/media/store.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +25 -14
- package/src/runtime/shared/automation-attachments.mjs +3 -3
- package/src/runtime/shared/child-guardian.mjs +15 -5
- package/src/runtime/shared/child-spawn-gate.mjs +6 -12
- package/src/runtime/shared/resource-admission.mjs +7 -3
- package/src/runtime/shared/turn-snapshot.mjs +0 -3
- package/src/session-runtime/lifecycle-api.mjs +34 -9
- package/src/session-runtime/prewarm.mjs +35 -0
- package/src/session-runtime/remote-control.mjs +8 -47
- package/src/session-runtime/remote-transcript.mjs +3 -7
- package/src/session-runtime/route-preparation.mjs +24 -0
- package/src/session-runtime/runtime-core.mjs +37 -85
- package/src/session-runtime/session-turn-api.mjs +9 -2
- package/src/session-runtime/workflow-agents-api.mjs +3 -0
- package/src/standalone/agent-shard/shard-child.mjs +300 -0
- package/src/standalone/agent-shard/shard-pool.mjs +442 -0
- package/src/standalone/agent-tool.mjs +222 -0
- package/src/standalone/channel-daemon-transport.mjs +126 -185
- package/src/standalone/channel-daemon.mjs +9 -10
- package/src/tui/App.jsx +16 -9
- package/src/tui/app/channel-pickers.mjs +3 -8
- package/src/tui/app/route-pickers.mjs +117 -152
- package/src/tui/app/settings-picker.mjs +90 -89
- package/src/tui/app/slash-commands.mjs +7 -7
- package/src/tui/app/slash-dispatch.mjs +0 -45
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
- package/src/tui/dist/index.mjs +243 -323
- package/src/tui/engine/session-api-ext.mjs +24 -9
- package/src/tui/engine.mjs +7 -1
- package/src/tui/figures.mjs +0 -1
- package/src/ui/statusline-format.mjs +0 -1
- package/src/hooks/lib/permission-evaluator.cjs +0 -24
- package/src/lib/config-cjs.cjs +0 -61
- package/src/runtime/shared/launcher-control.mjs +0 -258
- package/src/runtime/shared/workspace-router.mjs +0 -259
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.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.
|
|
7
|
-
"sha256": "
|
|
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.
|
|
11
|
-
"sha256": "
|
|
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.
|
|
15
|
-
"sha256": "
|
|
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.
|
|
19
|
-
"sha256": "
|
|
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.
|
|
23
|
-
"sha256": "
|
|
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.
|
|
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.
|
|
7
|
-
"sha256": "
|
|
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.
|
|
11
|
-
"sha256": "
|
|
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.
|
|
15
|
-
"sha256": "
|
|
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.
|
|
19
|
-
"sha256": "
|
|
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.
|
|
23
|
-
"sha256": "
|
|
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(
|
|
375
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; }
|