mixdog 0.9.134 → 0.9.136
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/package.json +3 -1
- package/scripts/tool-stress.mjs +8 -1
- package/src/agents/maintainer/agent.json +1 -1
- package/src/defaults/skills/setup/SKILL.md +8 -43
- package/src/headless-exec.mjs +26 -9
- package/src/headless-exec.test.mjs +55 -2
- package/src/help.mjs +0 -2
- package/src/rules/lead/01-general.md +0 -4
- package/src/rules/shared/01-tool.md +12 -4
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
- package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +15 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +25 -4
- package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +30 -7
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
- package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +60 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +138 -19
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +80 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +3 -19
- package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +25 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +165 -17
- package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +162 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +36 -8
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/channels/lib/config.mjs +14 -101
- package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
- package/src/runtime/channels/lib/scheduler.mjs +75 -18
- package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
- package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
- package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
- package/src/runtime/channels/lib/worker-main.mjs +22 -307
- package/src/runtime/media/renditions.mjs +68 -5
- package/src/runtime/media/renditions.test.mjs +45 -0
- package/src/runtime/media/store.mjs +1 -0
- package/src/runtime/memory/index.mjs +4 -3
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
- package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
- package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
- package/src/runtime/memory/lib/query-handlers.mjs +1 -0
- package/src/runtime/shared/config.mjs +8 -49
- package/src/runtime/shared/schedule-time.mjs +25 -0
- package/src/runtime/shared/schedule-time.test.mjs +29 -0
- package/src/runtime/shared/schedules-db.mjs +73 -1
- package/src/runtime/shared/tool-card-model.mjs +1 -1
- package/src/runtime/shared/transcript-writer.mjs +10 -17
- package/src/runtime/shared/update-checker.mjs +7 -2
- package/src/runtime/shared/update-checker.test.mjs +33 -0
- package/src/session-runtime/channel-config-api.mjs +3 -20
- package/src/session-runtime/config-lifecycle.mjs +1 -42
- package/src/session-runtime/lifecycle-api.mjs +10 -21
- package/src/session-runtime/model-route-api.mjs +0 -2
- package/src/session-runtime/model-settings-persist.test.mjs +0 -1
- package/src/session-runtime/prewarm.mjs +11 -7
- package/src/session-runtime/prewarm.test.mjs +0 -1
- package/src/session-runtime/runtime-core.mjs +20 -186
- package/src/session-runtime/session-lifecycle.mjs +4 -17
- package/src/session-runtime/session-transcript.mjs +54 -0
- package/src/session-runtime/session-turn-api.mjs +4 -44
- package/src/session-runtime/settings-api.mjs +0 -45
- package/src/session-runtime/tool-catalog-schema.mjs +1 -1
- package/src/session-runtime/tool-policy-surface.test.mjs +4 -0
- package/src/standalone/channel-admin.mjs +8 -150
- package/src/standalone/channel-transport.mjs +13 -0
- package/src/standalone/daemon.mjs +19 -49
- package/src/standalone/seeds.mjs +18 -1
- package/src/standalone/session-protocol.mjs +0 -8
- package/src/standalone/session-service.mjs +1 -3
- package/src/tui/App.jsx +0 -5
- package/src/tui/app/app-view.jsx +1 -2
- package/src/tui/app/create-app-pickers.mjs +0 -20
- package/src/tui/app/doctor.mjs +2 -6
- package/src/tui/app/prompt-submit.mjs +2 -49
- package/src/tui/app/settings-picker.mjs +22 -96
- package/src/tui/app/slash-commands.mjs +0 -2
- package/src/tui/app/slash-dispatch.mjs +0 -17
- package/src/tui/app/use-prompt-handlers.mjs +4 -1
- package/src/tui/components/StatusLine.jsx +4 -4
- package/src/tui/dist/index.mjs +1003 -1461
- package/src/tui/session/context-state.mjs +0 -1
- package/src/tui/session/queue-helpers.mjs +1 -1
- package/src/tui/session/queue-helpers.test.mjs +10 -1
- package/src/tui/session/session-api-ext.mjs +0 -89
- package/src/tui/session/session-api.mjs +0 -2
- package/src/tui/session/tool-card-results.mjs +1 -1
- package/src/tui/session/tool-result-status.mjs +3 -3
- package/src/tui/session/tool-result-status.test.mjs +3 -3
- package/src/tui/session-local.mjs +0 -26
- package/src/workflows/default/WORKFLOW.md +13 -15
- package/src/workflows/solo/WORKFLOW.md +6 -7
- package/src/runtime/channels/lib/config-lock.mjs +0 -13
- package/src/runtime/channels/lib/drop-trace.mjs +0 -71
- package/src/runtime/channels/lib/format.mjs +0 -190
- package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
- package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
- package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
- package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
- package/src/runtime/channels/lib/memory-client.mjs +0 -289
- package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
- package/src/runtime/channels/lib/session-control.mjs +0 -36
- package/src/runtime/channels/lib/session-discovery.mjs +0 -217
- package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
- package/src/runtime/channels/lib/telegram-format.mjs +0 -280
- package/src/runtime/channels/lib/tool-format.mjs +0 -125
- package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
- package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
- package/src/runtime/channels/providers/discord-access.mjs +0 -30
- package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
- package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
- package/src/runtime/channels/providers/discord.mjs +0 -730
- package/src/runtime/channels/providers/telegram.mjs +0 -598
- package/src/runtime/channels/providers/telegram.test.mjs +0 -75
- package/src/session-runtime/remote-control.mjs +0 -126
- package/src/session-runtime/remote-transcript.mjs +0 -134
- package/src/session-runtime/remote-transition-queue.mjs +0 -14
- package/src/tui/app/channel-pickers.mjs +0 -384
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.18",
|
|
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.18/mixdog-graph-darwin-arm64",
|
|
7
|
+
"sha256": "c89403d4a4cd08aa27427b65e9f0efe4885fb35cbe788527b81279e03edc82eb"
|
|
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.18/mixdog-graph-darwin-x64",
|
|
11
|
+
"sha256": "43765bb6598f0b7db3ba5df8348bb7221fc0e4fec5f8ad9d4d53887d4951e42f"
|
|
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.18/mixdog-graph-linux-arm64",
|
|
15
|
+
"sha256": "ada18e0d9ca3ab8fe65098b81c8856d01992eb2f33a2f2923e886280e2b635a4"
|
|
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.18/mixdog-graph-linux-x64",
|
|
19
|
+
"sha256": "724d219ec72828662f34ba7b0edf00938e8f9f00df76cb6c41675551e2c0137e"
|
|
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.18/mixdog-graph-win32-x64.exe",
|
|
23
|
+
"sha256": "c24f4ffb0beec3f355bc4e33c02adafce2ba966df34922ec0ff2ce2cde23fe65"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve as pathResolve } from 'node:path';
|
|
3
4
|
import { createInterface } from 'node:readline';
|
|
4
5
|
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import { hiddenSpawnOpts } from '../../../../shared/spawn-flags.mjs';
|
|
6
8
|
import { packageNativeToolPath } from '../../../../shared/native-tool-paths.mjs';
|
|
7
9
|
import { getPluginData } from '../../config.mjs';
|
|
8
10
|
import { ensureSpawnBinary, findCachedSpawnBinary } from '../spawn-binary-fetcher.mjs';
|
|
9
11
|
|
|
10
12
|
const RESTART_BACKOFF_MS = 30_000;
|
|
13
|
+
const SERVER_READY_TIMEOUT_MS = 5_000;
|
|
11
14
|
|
|
12
15
|
let _server = null;
|
|
13
16
|
let _binaryPath = undefined;
|
|
@@ -57,29 +60,31 @@ function acceptTask(message) {
|
|
|
57
60
|
class LineStream extends EventEmitter {
|
|
58
61
|
constructor() {
|
|
59
62
|
super();
|
|
60
|
-
this._pending =
|
|
63
|
+
this._pending = [];
|
|
61
64
|
}
|
|
62
65
|
setEncoding() { return this; }
|
|
63
66
|
emit(event, ...args) {
|
|
64
67
|
if (event === 'data' && this.listenerCount('data') === 0) {
|
|
65
|
-
|
|
68
|
+
if (args[0] != null) this._pending.push(args[0]);
|
|
66
69
|
return false;
|
|
67
70
|
}
|
|
68
71
|
return super.emit(event, ...args);
|
|
69
72
|
}
|
|
70
73
|
on(event, listener) {
|
|
71
74
|
const ret = super.on(event, listener);
|
|
72
|
-
if (event === 'data' && this._pending) {
|
|
75
|
+
if (event === 'data' && this._pending.length > 0) {
|
|
73
76
|
const pending = this._pending;
|
|
74
|
-
this._pending =
|
|
75
|
-
queueMicrotask(() =>
|
|
77
|
+
this._pending = [];
|
|
78
|
+
queueMicrotask(() => {
|
|
79
|
+
for (const chunk of pending) listener(chunk);
|
|
80
|
+
});
|
|
76
81
|
}
|
|
77
82
|
return ret;
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
export class NativeSpawnChild extends EventEmitter {
|
|
82
|
-
constructor(id, cancel) {
|
|
87
|
+
constructor(id, cancel, send = null) {
|
|
83
88
|
super();
|
|
84
89
|
this.pid = undefined;
|
|
85
90
|
this.killed = false;
|
|
@@ -91,9 +96,27 @@ export class NativeSpawnChild extends EventEmitter {
|
|
|
91
96
|
this.__nativeSpawn = true;
|
|
92
97
|
this._id = id;
|
|
93
98
|
this._cancel = cancel;
|
|
99
|
+
this._send = send;
|
|
94
100
|
this._closed = false;
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
// stdinPipe children only (warm shell standby): forward script text to the
|
|
104
|
+
// child's stdin via the spawn server. No-ops (false) when the child was
|
|
105
|
+
// spawned without stdinPipe or the server link is gone.
|
|
106
|
+
writeStdin(text, { close = false } = {}) {
|
|
107
|
+
if (this._closed || this.killed || typeof this._send !== 'function') return false;
|
|
108
|
+
return this._send({
|
|
109
|
+
stdinWrite: this._id,
|
|
110
|
+
data: String(text ?? ''),
|
|
111
|
+
...(close ? { close: true } : {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
endStdin() {
|
|
116
|
+
if (typeof this._send !== 'function') return false;
|
|
117
|
+
return this._send({ stdinClose: this._id });
|
|
118
|
+
}
|
|
119
|
+
|
|
97
120
|
kill() {
|
|
98
121
|
this.killed = true;
|
|
99
122
|
this._cancel?.(this._id);
|
|
@@ -124,6 +147,17 @@ function _resolveBinary() {
|
|
|
124
147
|
_binaryPath = explicit;
|
|
125
148
|
return _binaryPath;
|
|
126
149
|
}
|
|
150
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
151
|
+
const binName = process.platform === 'win32' ? 'mixdog-spawn.exe' : 'mixdog-spawn';
|
|
152
|
+
const localBuild = pathResolve(
|
|
153
|
+
moduleDir,
|
|
154
|
+
'../../../../../../native/mixdog-spawn/target/release',
|
|
155
|
+
binName,
|
|
156
|
+
);
|
|
157
|
+
if (existsSync(localBuild)) {
|
|
158
|
+
_binaryPath = localBuild;
|
|
159
|
+
return _binaryPath;
|
|
160
|
+
}
|
|
127
161
|
const installed = packageNativeToolPath('spawn');
|
|
128
162
|
if (existsSync(installed)) {
|
|
129
163
|
_binaryPath = installed;
|
|
@@ -140,6 +174,28 @@ function _setServerReferenced(server, referenced) {
|
|
|
140
174
|
try { server?.child?.stdout?.[method]?.(); } catch {}
|
|
141
175
|
}
|
|
142
176
|
|
|
177
|
+
// Referenced only while a NON-idle request is pending. Idle requests (parked
|
|
178
|
+
// warm shell standbys) live for minutes-to-hours; counting them would pin the
|
|
179
|
+
// host event loop — a one-shot CLI or test runner could never exit.
|
|
180
|
+
function _recomputeServerRef(server) {
|
|
181
|
+
let active = false;
|
|
182
|
+
for (const entry of server.pending.values()) {
|
|
183
|
+
if (!entry.idle) { active = true; break; }
|
|
184
|
+
}
|
|
185
|
+
_setServerReferenced(server, active);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Mark a pending spawn request idle (parked) or active. Idle requests do not
|
|
189
|
+
* keep the host process alive. Returns false when the request is gone. */
|
|
190
|
+
export function setNativeSpawnRequestIdle(child, idle = true) {
|
|
191
|
+
const server = _server;
|
|
192
|
+
const entry = server?.pending?.get?.(Number(child?._id));
|
|
193
|
+
if (!entry) return false;
|
|
194
|
+
entry.idle = idle === true;
|
|
195
|
+
_recomputeServerRef(server);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
143
199
|
function _teardown(error) {
|
|
144
200
|
const server = _server;
|
|
145
201
|
_server = null;
|
|
@@ -164,6 +220,30 @@ function _teardown(error) {
|
|
|
164
220
|
try { server.child.kill(); } catch {}
|
|
165
221
|
}
|
|
166
222
|
|
|
223
|
+
export async function shutdownNativeSpawnServer(reason = 'process-exit', timeoutMs = 1_000) {
|
|
224
|
+
const server = _server;
|
|
225
|
+
if (!server) return true;
|
|
226
|
+
const child = server.child;
|
|
227
|
+
const exited = new Promise((resolve) => {
|
|
228
|
+
child.once('exit', () => resolve(true));
|
|
229
|
+
child.once('error', () => resolve(true));
|
|
230
|
+
});
|
|
231
|
+
try { child.stdin?.end?.(); } catch {}
|
|
232
|
+
_teardown(new Error(`native spawn server shutdown (${reason})`));
|
|
233
|
+
let timer;
|
|
234
|
+
const stopped = await Promise.race([
|
|
235
|
+
exited,
|
|
236
|
+
new Promise((resolve) => {
|
|
237
|
+
timer = setTimeout(() => resolve(false), Math.max(10, Number(timeoutMs) || 1_000));
|
|
238
|
+
}),
|
|
239
|
+
]);
|
|
240
|
+
if (timer) clearTimeout(timer);
|
|
241
|
+
if (!stopped) {
|
|
242
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
243
|
+
}
|
|
244
|
+
return stopped;
|
|
245
|
+
}
|
|
246
|
+
|
|
167
247
|
function _ensureServer() {
|
|
168
248
|
if (_server) return _server;
|
|
169
249
|
if (Date.now() - _lastFailureAt < RESTART_BACKOFF_MS) return null;
|
|
@@ -179,19 +259,38 @@ function _ensureServer() {
|
|
|
179
259
|
_lastFailureAt = Date.now();
|
|
180
260
|
return null;
|
|
181
261
|
}
|
|
182
|
-
const server = { child, pending: new Map(), sequence: 0 };
|
|
262
|
+
const server = { child, pending: new Map(), sequence: 0, ready: false, caps: {} };
|
|
263
|
+
let resolveReady;
|
|
264
|
+
server.readyPromise = new Promise((resolve) => { resolveReady = resolve; });
|
|
265
|
+
const settleReady = (value) => {
|
|
266
|
+
if (!resolveReady) return;
|
|
267
|
+
const resolve = resolveReady;
|
|
268
|
+
resolveReady = null;
|
|
269
|
+
resolve(value);
|
|
270
|
+
};
|
|
183
271
|
const lines = createInterface({ input: child.stdout });
|
|
184
272
|
lines.on('line', (line) => {
|
|
185
273
|
let message;
|
|
186
274
|
try { message = JSON.parse(line); } catch { return; }
|
|
187
|
-
if (message?.ready === true)
|
|
275
|
+
if (message?.ready === true) {
|
|
276
|
+
server.caps = (message.caps && typeof message.caps === 'object') ? message.caps : {};
|
|
277
|
+
server.ready = true;
|
|
278
|
+
settleReady(true);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
188
281
|
if (String(message?.event || '').startsWith('task_')) acceptTask(message);
|
|
189
282
|
const pending = server.pending.get(Number(message?.id));
|
|
190
283
|
if (!pending) return;
|
|
191
284
|
pending.onMessage(message);
|
|
192
285
|
});
|
|
193
|
-
child.on('error', (error) => {
|
|
194
|
-
|
|
286
|
+
child.on('error', (error) => {
|
|
287
|
+
settleReady(false);
|
|
288
|
+
if (_server === server) _teardown(error);
|
|
289
|
+
});
|
|
290
|
+
child.on('exit', () => {
|
|
291
|
+
settleReady(false);
|
|
292
|
+
if (_server === server) _teardown();
|
|
293
|
+
});
|
|
195
294
|
child.stdin.on('error', (error) => { if (_server === server) _teardown(error); });
|
|
196
295
|
_setServerReferenced(server, false);
|
|
197
296
|
_server = server;
|
|
@@ -207,17 +306,40 @@ export async function warmNativeSpawnServer() {
|
|
|
207
306
|
}
|
|
208
307
|
|
|
209
308
|
export async function ensureNativeSpawnServer() {
|
|
210
|
-
if (_server) return true;
|
|
309
|
+
if (_server?.ready) return true;
|
|
211
310
|
let binary = _resolveBinary();
|
|
212
311
|
if (!binary) {
|
|
213
312
|
binary = await ensureSpawnBinary(getPluginData());
|
|
214
313
|
_binaryPath = binary;
|
|
215
314
|
}
|
|
216
|
-
|
|
315
|
+
const server = _ensureServer();
|
|
316
|
+
if (!server) {
|
|
217
317
|
throw Object.assign(new Error('verified native spawn server could not be started'), {
|
|
218
318
|
code: 'NATIVE_SPAWN_UNAVAILABLE',
|
|
219
319
|
});
|
|
220
320
|
}
|
|
321
|
+
if (!server.ready) {
|
|
322
|
+
let timer;
|
|
323
|
+
let ready;
|
|
324
|
+
_setServerReferenced(server, true);
|
|
325
|
+
try {
|
|
326
|
+
ready = await Promise.race([
|
|
327
|
+
server.readyPromise,
|
|
328
|
+
new Promise((resolve) => {
|
|
329
|
+
timer = setTimeout(() => resolve(false), SERVER_READY_TIMEOUT_MS);
|
|
330
|
+
}),
|
|
331
|
+
]);
|
|
332
|
+
} finally {
|
|
333
|
+
if (timer) clearTimeout(timer);
|
|
334
|
+
if (_server === server) _recomputeServerRef(server);
|
|
335
|
+
}
|
|
336
|
+
if (!ready) {
|
|
337
|
+
if (_server === server) _teardown(new Error('native spawn server ready timeout'));
|
|
338
|
+
throw Object.assign(new Error('verified native spawn server did not become ready'), {
|
|
339
|
+
code: 'NATIVE_SPAWN_UNAVAILABLE',
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
221
343
|
return true;
|
|
222
344
|
}
|
|
223
345
|
|
|
@@ -227,6 +349,11 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
227
349
|
const id = ++server.sequence;
|
|
228
350
|
const fake = new NativeSpawnChild(id, (cancelId) => {
|
|
229
351
|
try { server.child.stdin.write(`${JSON.stringify({ cancel: cancelId })}\n`); } catch {}
|
|
352
|
+
}, (payload) => {
|
|
353
|
+
try {
|
|
354
|
+
server.child.stdin.write(`${JSON.stringify({ id: ++server.sequence, ...payload })}\n`);
|
|
355
|
+
return true;
|
|
356
|
+
} catch { return false; }
|
|
230
357
|
});
|
|
231
358
|
const request = {
|
|
232
359
|
id,
|
|
@@ -239,6 +366,8 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
239
366
|
timeoutMs: Math.max(0, Number(spawnOptions.timeoutMs) || 0),
|
|
240
367
|
outputLimit: Math.max(0, Number(spawnOptions.outputLimit) || 0),
|
|
241
368
|
mergeStderr: spawnOptions.mergeStderr === true,
|
|
369
|
+
rawOutput: spawnOptions.rawOutput === true,
|
|
370
|
+
...(spawnOptions.stdinPipe === true ? { stdinPipe: true } : {}),
|
|
242
371
|
command: spawnOptions.command || undefined,
|
|
243
372
|
shellType: spawnOptions.shellType || undefined,
|
|
244
373
|
ownerSessionId: spawnOptions.ownerSessionId || undefined,
|
|
@@ -255,7 +384,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
255
384
|
fake.emit('error', err);
|
|
256
385
|
fake.emit('close', 1, null);
|
|
257
386
|
}
|
|
258
|
-
|
|
387
|
+
_recomputeServerRef(server);
|
|
259
388
|
},
|
|
260
389
|
onMessage(message) {
|
|
261
390
|
const event = String(message?.event || '');
|
|
@@ -265,8 +394,18 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
265
394
|
fake.emit('spawn');
|
|
266
395
|
return;
|
|
267
396
|
}
|
|
268
|
-
if (event === 'stdout' && message.
|
|
269
|
-
|
|
397
|
+
if (event === 'stdout' && (message.dataBase64 || message.text)) {
|
|
398
|
+
const chunk = message.dataBase64
|
|
399
|
+
? Buffer.from(String(message.dataBase64), 'base64')
|
|
400
|
+
: String(message.text);
|
|
401
|
+
fake.stdout.emit('data', chunk);
|
|
402
|
+
}
|
|
403
|
+
else if (event === 'stderr' && (message.dataBase64 || message.text)) {
|
|
404
|
+
const chunk = message.dataBase64
|
|
405
|
+
? Buffer.from(String(message.dataBase64), 'base64')
|
|
406
|
+
: String(message.text);
|
|
407
|
+
fake.stderr.emit('data', chunk);
|
|
408
|
+
}
|
|
270
409
|
else if (event === 'root_exit') {
|
|
271
410
|
fake.exitCode = message.code == null ? null : Number(message.code);
|
|
272
411
|
fake.signalCode = message.signal || null;
|
|
@@ -279,7 +418,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
279
418
|
fake.signalCode = message.signal || null;
|
|
280
419
|
fake._closed = true;
|
|
281
420
|
fake.emit('close', fake.exitCode, fake.signalCode);
|
|
282
|
-
|
|
421
|
+
_recomputeServerRef(server);
|
|
283
422
|
} else if (event === 'error') {
|
|
284
423
|
const detail = String(message.message || 'native spawn failed');
|
|
285
424
|
const err = new Error(message.code ? `${message.code}: ${detail}` : detail);
|
|
@@ -425,7 +564,7 @@ export function adoptNativeTaskByPid({
|
|
|
425
564
|
const finish = (task, error = null) => {
|
|
426
565
|
if (timer) clearTimeout(timer);
|
|
427
566
|
server.pending.delete(id);
|
|
428
|
-
|
|
567
|
+
_recomputeServerRef(server);
|
|
429
568
|
if (error) reject(error);
|
|
430
569
|
else resolve(task);
|
|
431
570
|
};
|
|
@@ -468,6 +607,15 @@ export function adoptNativeTaskByPid({
|
|
|
468
607
|
});
|
|
469
608
|
}
|
|
470
609
|
|
|
610
|
+
// Capability handshake: true only after the connected spawn server announced
|
|
611
|
+
// stdinPipe support in its ready line. An older binary silently ignores the
|
|
612
|
+
// unknown stdinPipe field and gives the child a null stdin — a standby taken
|
|
613
|
+
// in that state would run an EMPTY script and report exit 0 without ever
|
|
614
|
+
// executing the command. Gate the warm-standby feature on this.
|
|
615
|
+
export function nativeSpawnSupportsStdinPipe() {
|
|
616
|
+
return _server?.caps?.stdinPipe === true;
|
|
617
|
+
}
|
|
618
|
+
|
|
471
619
|
export function _resetNativeSpawnClientForTest() {
|
|
472
620
|
_teardown(new Error('test reset'));
|
|
473
621
|
_binaryPath = undefined;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Warm pwsh standby: one pre-spawned PowerShell per (shell path, env
|
|
2
|
+
// signature) waits with a stdin-reading bootstrap. Taking it skips
|
|
3
|
+
// CreateProcess + Defender scan (~300ms warm, ~1.8s cold) for the next shell
|
|
4
|
+
// call; the script arrives via the spawn server's stdinWrite message and runs
|
|
5
|
+
// with identical exit-code/stdout/stderr semantics. Any miss falls back to
|
|
6
|
+
// the regular gated spawn. MIXDOG_SHELL_WARM_STANDBY=0 disables.
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { nativeSpawnSupportsStdinPipe, setNativeSpawnRequestIdle, tryNativeSpawn } from './native-spawn-client.mjs';
|
|
9
|
+
import { SHELL_OUTPUT_DISK_CAP } from '../shell-exec-output.mjs';
|
|
10
|
+
|
|
11
|
+
// Bootstrap: pre-warm scriptblock compilation while parked, re-decode stdin
|
|
12
|
+
// as UTF-8 (console input encoding would mangle non-ASCII), then dot-source
|
|
13
|
+
// the fed script so exit codes, $LASTEXITCODE and terminating errors behave
|
|
14
|
+
// exactly like `-Command <script>`.
|
|
15
|
+
const STANDBY_BOOTSTRAP = "$null = . ([scriptblock]::Create('$null')); [Console]::InputEncoding=[System.Text.UTF8Encoding]::new($false); . ([scriptblock]::Create([Console]::In.ReadToEnd()))";
|
|
16
|
+
const STANDBY_ARGS = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', STANDBY_BOOTSTRAP];
|
|
17
|
+
const _configuredIdleMs = Number(process.env.MIXDOG_SHELL_WARM_STANDBY_IDLE_MS);
|
|
18
|
+
const STANDBY_TTL_MS = Number.isFinite(_configuredIdleMs) && _configuredIdleMs >= 100
|
|
19
|
+
? Math.floor(_configuredIdleMs)
|
|
20
|
+
: 2 * 60_000;
|
|
21
|
+
|
|
22
|
+
let _slot = null; // { native, shell, envSig, createdAt }
|
|
23
|
+
let _slotIdleTimer = null;
|
|
24
|
+
|
|
25
|
+
function _clearSlotIdleTimer() {
|
|
26
|
+
if (!_slotIdleTimer) return;
|
|
27
|
+
clearTimeout(_slotIdleTimer);
|
|
28
|
+
_slotIdleTimer = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _armSlotIdleTimer(slot) {
|
|
32
|
+
_clearSlotIdleTimer();
|
|
33
|
+
_slotIdleTimer = setTimeout(() => {
|
|
34
|
+
_slotIdleTimer = null;
|
|
35
|
+
if (_slot !== slot) return;
|
|
36
|
+
_slot = null;
|
|
37
|
+
try { slot.native.child.kill(); } catch { /* best-effort */ }
|
|
38
|
+
}, STANDBY_TTL_MS);
|
|
39
|
+
_slotIdleTimer.unref?.();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _disabled() {
|
|
43
|
+
return process.env.MIXDOG_SHELL_WARM_STANDBY === '0';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function envSignature(env) {
|
|
47
|
+
const entries = Object.entries(env || {})
|
|
48
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
49
|
+
.sort()
|
|
50
|
+
.join('\n');
|
|
51
|
+
return createHash('sha256').update(entries).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _slotAlive(slot) {
|
|
55
|
+
return Boolean(slot
|
|
56
|
+
&& slot.native?.child
|
|
57
|
+
&& slot.native.child.exitCode == null
|
|
58
|
+
&& !slot.native.child.killed);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function psQuote(value) {
|
|
62
|
+
return String(value).replace(/'/g, "''");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pre-spawn the next standby (fire-and-forget; a live one is kept). */
|
|
66
|
+
export function ensureWarmShellStandby({ shell, env }) {
|
|
67
|
+
if (_disabled() || !shell) return;
|
|
68
|
+
// Old spawn binaries ignore stdinPipe (null stdin → instant-EOF standby
|
|
69
|
+
// that would ack commands without running them); require the handshake.
|
|
70
|
+
if (!nativeSpawnSupportsStdinPipe()) return;
|
|
71
|
+
if (_slotAlive(_slot)) return;
|
|
72
|
+
let native = null;
|
|
73
|
+
try {
|
|
74
|
+
native = tryNativeSpawn({
|
|
75
|
+
shell: String(shell),
|
|
76
|
+
argv: STANDBY_ARGS,
|
|
77
|
+
spawnOptions: {
|
|
78
|
+
env,
|
|
79
|
+
cwd: process.cwd(),
|
|
80
|
+
outputLimit: SHELL_OUTPUT_DISK_CAP,
|
|
81
|
+
rawOutput: true,
|
|
82
|
+
stdinPipe: true,
|
|
83
|
+
shellType: 'powershell',
|
|
84
|
+
command: '(warm shell standby)',
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
} catch { native = null; }
|
|
88
|
+
if (!native?.child) return;
|
|
89
|
+
// Parked standby must not pin the host event loop (a one-shot CLI or test
|
|
90
|
+
// runner would otherwise never exit); reactivated at take time.
|
|
91
|
+
setNativeSpawnRequestIdle(native.child, true);
|
|
92
|
+
const slot = {
|
|
93
|
+
native,
|
|
94
|
+
shell: String(shell),
|
|
95
|
+
envSig: envSignature(env),
|
|
96
|
+
createdAt: Date.now(),
|
|
97
|
+
};
|
|
98
|
+
const release = () => {
|
|
99
|
+
if (_slot !== slot) return;
|
|
100
|
+
_slot = null;
|
|
101
|
+
_clearSlotIdleTimer();
|
|
102
|
+
};
|
|
103
|
+
native.child.once('close', release);
|
|
104
|
+
native.child.once('error', release);
|
|
105
|
+
_slot = slot;
|
|
106
|
+
_armSlotIdleTimer(slot);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Take the parked standby for immediate use, or null on any mismatch.
|
|
110
|
+
* Always refills so the NEXT call finds a warm one. */
|
|
111
|
+
export function takeWarmShellStandby({ shell, env, cwd }) {
|
|
112
|
+
if (_disabled() || !shell) return null;
|
|
113
|
+
const slot = _slot;
|
|
114
|
+
const refill = () => { try { ensureWarmShellStandby({ shell, env }); } catch { /* best-effort */ } };
|
|
115
|
+
const usable = _slotAlive(slot)
|
|
116
|
+
&& slot.shell === String(shell)
|
|
117
|
+
&& typeof slot.native.child.writeStdin === 'function'
|
|
118
|
+
&& Number.isFinite(slot.native.child.pid) && slot.native.child.pid > 0
|
|
119
|
+
&& Date.now() - slot.createdAt <= STANDBY_TTL_MS
|
|
120
|
+
&& slot.envSig === envSignature(env);
|
|
121
|
+
if (!usable) {
|
|
122
|
+
// Stale (TTL/env/shell drift): kill so it cannot linger; a still-
|
|
123
|
+
// warming slot (no pid yet) is left in place for a later call.
|
|
124
|
+
if (_slotAlive(slot) && Number.isFinite(slot.native.child.pid)
|
|
125
|
+
&& (slot.shell !== String(shell)
|
|
126
|
+
|| Date.now() - slot.createdAt > STANDBY_TTL_MS
|
|
127
|
+
|| slot.envSig !== envSignature(env))) {
|
|
128
|
+
_slot = null;
|
|
129
|
+
_clearSlotIdleTimer();
|
|
130
|
+
try { slot.native.child.kill(); } catch { /* best-effort */ }
|
|
131
|
+
}
|
|
132
|
+
refill();
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
_slot = null;
|
|
136
|
+
_clearSlotIdleTimer();
|
|
137
|
+
// Back to active: the caller is about to run a real command on it and the
|
|
138
|
+
// host must stay alive until that command settles.
|
|
139
|
+
setNativeSpawnRequestIdle(slot.native.child, false);
|
|
140
|
+
refill();
|
|
141
|
+
const feed = (commandText, workDir) => {
|
|
142
|
+
// cwd prelude: the standby was spawned in the daemon cwd. Set-Location
|
|
143
|
+
// fixes $PWD and native-child working dirs; Environment.CurrentDirectory
|
|
144
|
+
// fixes .NET relative-path APIs.
|
|
145
|
+
const prelude = workDir
|
|
146
|
+
? `Set-Location -LiteralPath '${psQuote(workDir)}'; [System.Environment]::CurrentDirectory = '${psQuote(workDir)}'; `
|
|
147
|
+
: '';
|
|
148
|
+
// Single atomic write+EOF: a separate close message can race ahead
|
|
149
|
+
// of the server's async write thread and feed an empty script.
|
|
150
|
+
slot.native.child.writeStdin(prelude + String(commandText ?? ''), { close: true });
|
|
151
|
+
};
|
|
152
|
+
return { spawned: slot.native, feed };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function _resetWarmShellStandbyForTest() {
|
|
156
|
+
const slot = _slot;
|
|
157
|
+
_slot = null;
|
|
158
|
+
_clearSlotIdleTimer();
|
|
159
|
+
if (slot?.native?.child) {
|
|
160
|
+
try { slot.native.child.kill(); } catch { /* best-effort */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
extractPowerShellCommandInner,
|
|
25
25
|
} from './shell-powershell.mjs';
|
|
26
26
|
import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
|
|
27
|
+
import { takeWarmShellStandby } from './lib/shell-warm-standby.mjs';
|
|
27
28
|
|
|
28
29
|
export {
|
|
29
30
|
_maybeEncodePowerShellCommand,
|
|
@@ -34,7 +35,7 @@ export {
|
|
|
34
35
|
// renders a path marker instead of pasting the tail. Matches the
|
|
35
36
|
// SHELL_OUTPUT_MAX_CHARS used by the smart-truncate renderer in
|
|
36
37
|
// builtin.mjs so spilled output and inline output share the same boundary.
|
|
37
|
-
import { SHELL_OUTPUT_INLINE_CAP, SHELL_OUTPUT_DISK_CAP, stripAnsi, treeKill, TaskOutput, ExecResult } from './shell-exec-output.mjs';
|
|
38
|
+
import { SHELL_OUTPUT_INLINE_CAP, SHELL_OUTPUT_DISK_CAP, stripAnsi, treeKill, TaskOutput, ExecResult, ShellTextDecoder } from './shell-exec-output.mjs';
|
|
38
39
|
export { stripAnsi, ExecResult } from './shell-exec-output.mjs';
|
|
39
40
|
|
|
40
41
|
async function _execPolicyBlockMessage(command) {
|
|
@@ -199,6 +200,7 @@ export function execShellCommand({
|
|
|
199
200
|
timeoutMs,
|
|
200
201
|
abortSignal,
|
|
201
202
|
autoBackgroundMs,
|
|
203
|
+
startInBackground = false,
|
|
202
204
|
onProgress,
|
|
203
205
|
onOutputTail,
|
|
204
206
|
clientHostPid,
|
|
@@ -219,6 +221,8 @@ export function execShellCommand({
|
|
|
219
221
|
};
|
|
220
222
|
const taskId = `shell_${randomUUID().slice(0, 8)}`;
|
|
221
223
|
const taskOutput = new TaskOutput(taskId);
|
|
224
|
+
const stdoutDecoder = new ShellTextDecoder();
|
|
225
|
+
const stderrDecoder = new ShellTextDecoder();
|
|
222
226
|
let timedOut = false;
|
|
223
227
|
let killed = false;
|
|
224
228
|
let killCause = null;
|
|
@@ -340,11 +344,23 @@ export function execShellCommand({
|
|
|
340
344
|
else pendingChildError = pendingChildError || err;
|
|
341
345
|
};
|
|
342
346
|
_onChildErrorRef = _onChildError;
|
|
347
|
+
// Warm-standby fast path (pwsh only): a pre-spawned bootstrap pwsh
|
|
348
|
+
// reads the script from stdin, skipping CreateProcess + Defender scan
|
|
349
|
+
// for this call. Any miss (env drift, TTL, dead/warming standby,
|
|
350
|
+
// MIXDOG_SHELL_WARM_STANDBY=0) falls through to the gated spawn below.
|
|
351
|
+
let _standby = null;
|
|
352
|
+
if (!_useDirectArgv && shellArg === '-Command') {
|
|
353
|
+
try { _standby = takeWarmShellStandby({ shell, env, cwd }); } catch { _standby = null; }
|
|
354
|
+
}
|
|
343
355
|
// Spawn-burst gate: hold a 'process-spawn' slot only across process
|
|
344
356
|
// creation (CreateProcess + AV scan + EPERM retries), released the
|
|
345
357
|
// moment the child exists. Bounds the Defender convoy a shell burst
|
|
346
358
|
// creates without limiting how many commands RUN concurrently — the
|
|
347
359
|
// full-lifetime gating concern in the note below stays true.
|
|
360
|
+
let spawned = null;
|
|
361
|
+
if (_standby) {
|
|
362
|
+
spawned = _standby.spawned;
|
|
363
|
+
} else {
|
|
348
364
|
const _releaseSpawnSlot = await acquireChildSpawnSlot(abortSignal || null, 'process-spawn', {
|
|
349
365
|
ownerKey: ownerSessionId,
|
|
350
366
|
});
|
|
@@ -355,7 +371,6 @@ export function execShellCommand({
|
|
|
355
371
|
try { _releaseSpawnSlot(); } catch { /* idempotent */ }
|
|
356
372
|
throw abortSignal.reason || new Error('aborted');
|
|
357
373
|
}
|
|
358
|
-
let spawned;
|
|
359
374
|
try {
|
|
360
375
|
spawned = await _spawnShellWithRetry({
|
|
361
376
|
shell,
|
|
@@ -366,6 +381,7 @@ export function execShellCommand({
|
|
|
366
381
|
env,
|
|
367
382
|
cwd,
|
|
368
383
|
outputLimit: SHELL_OUTPUT_DISK_CAP,
|
|
384
|
+
rawOutput: true,
|
|
369
385
|
// NOTE (child-spawn-gate): the full command lifetime is intentionally
|
|
370
386
|
// NOT gated — bash/pwsh commands can run for minutes and would starve
|
|
371
387
|
// rg/code_graph. Only the spawn window above holds a slot.
|
|
@@ -378,8 +394,12 @@ export function execShellCommand({
|
|
|
378
394
|
} finally {
|
|
379
395
|
try { _releaseSpawnSlot(); } catch { /* idempotent */ }
|
|
380
396
|
}
|
|
397
|
+
}
|
|
381
398
|
child = spawned.child;
|
|
382
399
|
spawned.adoptErrorHandler(_onChildError);
|
|
400
|
+
// Feed the standby only after the error handler is attached; server
|
|
401
|
+
// messages cannot be processed before this synchronous block yields.
|
|
402
|
+
if (_standby) _standby.feed(_spawnCommand, cwd);
|
|
383
403
|
} catch (err) {
|
|
384
404
|
const cleanupError = await releaseResourceLease();
|
|
385
405
|
const spawnText = String((err && err.message) || err);
|
|
@@ -412,21 +432,21 @@ export function execShellCommand({
|
|
|
412
432
|
}
|
|
413
433
|
|
|
414
434
|
const _stdoutData = (chunk) => {
|
|
415
|
-
|
|
435
|
+
const text = stdoutDecoder.write(chunk);
|
|
436
|
+
if (text) taskOutput.writeStdout(text);
|
|
416
437
|
if (taskOutput.binaryOutput && !settled && !autoBackgrounded) _treeKillForceSettle('binary-output');
|
|
417
438
|
if (taskOutput.writeError && !settled && !autoBackgrounded) _treeKillForceSettle('output-capture-error');
|
|
418
439
|
};
|
|
419
440
|
const _stderrData = (chunk) => {
|
|
420
|
-
|
|
441
|
+
const text = stderrDecoder.write(chunk);
|
|
442
|
+
if (text) taskOutput.writeStderr(text);
|
|
421
443
|
if (taskOutput.binaryOutput && !settled && !autoBackgrounded) _treeKillForceSettle('binary-output');
|
|
422
444
|
if (taskOutput.writeError && !settled && !autoBackgrounded) _treeKillForceSettle('output-capture-error');
|
|
423
445
|
};
|
|
424
446
|
if (child.stdout) {
|
|
425
|
-
child.stdout.setEncoding('utf-8');
|
|
426
447
|
child.stdout.on('data', _stdoutData);
|
|
427
448
|
}
|
|
428
449
|
if (child.stderr) {
|
|
429
|
-
child.stderr.setEncoding('utf-8');
|
|
430
450
|
child.stderr.on('data', _stderrData);
|
|
431
451
|
}
|
|
432
452
|
|
|
@@ -443,6 +463,10 @@ export function execShellCommand({
|
|
|
443
463
|
autoBgTimer = null;
|
|
444
464
|
}
|
|
445
465
|
detachAbortHandler();
|
|
466
|
+
const stdoutTail = stdoutDecoder.end();
|
|
467
|
+
const stderrTail = stderrDecoder.end();
|
|
468
|
+
if (stdoutTail) taskOutput.writeStdout(stdoutTail);
|
|
469
|
+
if (stderrTail) taskOutput.writeStderr(stderrTail);
|
|
446
470
|
// getStdout/getStderr can throw on a spilled-file read failure (EBADF
|
|
447
471
|
// after unlink race, EACCES). Without this catch the rejection bubbles
|
|
448
472
|
// up and leaves the outer settle promise unresolved, hanging the call.
|
|
@@ -684,7 +708,9 @@ export function execShellCommand({
|
|
|
684
708
|
const secs = Math.max(0, Math.round((Date.now() - _startMs) / 1000));
|
|
685
709
|
const _verb = reason === 'timeout'
|
|
686
710
|
? `moved to background at timeout after ${secs}s`
|
|
687
|
-
:
|
|
711
|
+
: (reason === 'explicit'
|
|
712
|
+
? 'started in background'
|
|
713
|
+
: `auto-backgrounded after ${secs}s`);
|
|
688
714
|
resolveResult(
|
|
689
715
|
new ExecResult({
|
|
690
716
|
stdout,
|
|
@@ -795,7 +821,9 @@ export function execShellCommand({
|
|
|
795
821
|
// Arm the auto-background timer only for the genuine foreground one-shot
|
|
796
822
|
// path: a positive threshold strictly below the hard timeout, and not a
|
|
797
823
|
// trailing-`&` background command (those already detach + settle on exit).
|
|
798
|
-
if (
|
|
824
|
+
if (startInBackground && !_isBackground) {
|
|
825
|
+
setImmediate(() => { fireAutoBackground({ reason: 'explicit' }); });
|
|
826
|
+
} else if (
|
|
799
827
|
typeof autoBackgroundMs === 'number' &&
|
|
800
828
|
autoBackgroundMs > 0 &&
|
|
801
829
|
!_isBackground &&
|