mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -159,9 +159,24 @@ function _installParentExitHook() {
|
|
|
159
159
|
}
|
|
160
160
|
};
|
|
161
161
|
try { process.on('exit', sweep); } catch { /* ignore */ }
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// For terminating signals, sweep children then restore default POSIX
|
|
163
|
+
// termination. A sweep-only handler swallows the signal and leaves the
|
|
164
|
+
// process alive, so after sweeping we remove our handler and re-raise the
|
|
165
|
+
// same signal to the default disposition (exit code 128+signum).
|
|
166
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
167
|
+
const onSignal = () => {
|
|
168
|
+
sweep();
|
|
169
|
+
try { process.removeListener(sig, onSignal); } catch { /* ignore */ }
|
|
170
|
+
// Only re-raise (restoring default POSIX termination) when we are
|
|
171
|
+
// the last handler. If other listeners remain — graceful-shutdown
|
|
172
|
+
// handlers or peer sweep-only handlers — let them own termination;
|
|
173
|
+
// forcing exit here would preempt their cleanup.
|
|
174
|
+
try {
|
|
175
|
+
if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
|
|
176
|
+
} catch { /* ignore */ }
|
|
177
|
+
};
|
|
178
|
+
try { process.on(sig, onSignal); } catch { /* ignore */ }
|
|
179
|
+
}
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
function _startReaper() {
|
|
@@ -538,6 +553,11 @@ function _runCommand(entry, command, timeoutMs, abortSignal = null) {
|
|
|
538
553
|
const partialErr = entry.stderrBuf;
|
|
539
554
|
entry.stdoutBuf = '';
|
|
540
555
|
entry.stderrBuf = '';
|
|
556
|
+
// Remove the session from the pool immediately: the POSIX kill
|
|
557
|
+
// escalates SIGTERM->SIGKILL asynchronously, so a still-registered
|
|
558
|
+
// entry could be reused as a dying shell before it exits.
|
|
559
|
+
entry.dead = true;
|
|
560
|
+
for (const [sid, s] of _sessions) { if (s === entry) { _sessions.delete(sid); break; } }
|
|
541
561
|
_killProcessTree(entry.proc);
|
|
542
562
|
cleanup();
|
|
543
563
|
// Return a structured result (not a reject) so the caller
|
|
@@ -1,8 +1,41 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
|
|
2
2
|
import * as fsPromises from 'fs/promises';
|
|
3
|
-
import { join } from 'path';
|
|
3
|
+
import { basename, join } from 'path';
|
|
4
4
|
import { getPluginData } from '../../config.mjs';
|
|
5
5
|
|
|
6
|
+
// Bounded tail kept for a job's spilled stdout/stderr. A flooding job could
|
|
7
|
+
// otherwise leave a 100MB+ .stdout.log/.stderr.log sitting for up to the 24h
|
|
8
|
+
// stale TTL. Trimming to a few-MB tail preserves the most recent output
|
|
9
|
+
// (where the final error/exit context lives) while capping on-disk growth.
|
|
10
|
+
const SHELL_JOB_SPILL_MAX_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
const SHELL_JOB_SPILL_KEEP_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
// Tail-trim a single spill log in place: if it exceeds maxBytes, rewrite it
|
|
14
|
+
// keeping only the last keepBytes. Sync, best-effort, no-op on a missing file.
|
|
15
|
+
// Mirrors rotateBoundedLog in src/lib/mixdog-debug.cjs. Returns true if trimmed.
|
|
16
|
+
export function trimShellJobSpillFile(filePath, maxBytes = SHELL_JOB_SPILL_MAX_BYTES, keepBytes = SHELL_JOB_SPILL_KEEP_BYTES) {
|
|
17
|
+
try {
|
|
18
|
+
const st = statSync(filePath);
|
|
19
|
+
if (st.size <= maxBytes) return false;
|
|
20
|
+
const buf = readFileSync(filePath);
|
|
21
|
+
writeFileSync(filePath, buf.subarray(Math.max(0, buf.length - keepBytes)));
|
|
22
|
+
return true;
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Trim a completed job's stdout+stderr spill files to a bounded tail. Called on
|
|
27
|
+
// every terminal transition so a completed flooding job can never leave a
|
|
28
|
+
// 100MB spill behind for the sweep to reclaim a day later. Uses the detail's
|
|
29
|
+
// recorded paths — adopted foreground jobs spill to shell-output/, not the
|
|
30
|
+
// shell-jobs dir — and dedupes when mergeStderr collapses both onto stdoutPath.
|
|
31
|
+
export function trimShellJobSpill(detail) {
|
|
32
|
+
if (!detail) return;
|
|
33
|
+
const stdoutPath = detail.stdoutPath || shellJobStdoutPath(detail.jobId);
|
|
34
|
+
const stderrPath = detail.stderrPath || shellJobStderrPath(detail.jobId);
|
|
35
|
+
trimShellJobSpillFile(stdoutPath);
|
|
36
|
+
if (stderrPath && stderrPath !== stdoutPath) trimShellJobSpillFile(stderrPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
6
39
|
// One-shot sweep of stale shell-job artefacts. Each backgrounded `bash`
|
|
7
40
|
// emits five files (.json/.done/.exit/.stdout.log/.stderr.log); the .done
|
|
8
41
|
// flag is written when the job exits, so a .done file older than
|
|
@@ -82,6 +115,79 @@ async function sweepStaleShellJobs(dir) {
|
|
|
82
115
|
),
|
|
83
116
|
...ownerMarkers.map((n) => fsPromises.unlink(join(dir, n)).catch(() => {})),
|
|
84
117
|
]);
|
|
118
|
+
// Size enforcement for survivors: a completed flooding job whose .done is
|
|
119
|
+
// still younger than the stale cutoff isn't expired above, yet its spill
|
|
120
|
+
// can hold 100MB+ for up to a day. Proof-of-death is the .done marker (NOT
|
|
121
|
+
// mtime): mtime gating both truncated LIVE-but-quiet jobs whose writer
|
|
122
|
+
// handle is still open — sparse-regrow corruption — AND waited a full day
|
|
123
|
+
// to trim completed floods. Gating on doneSet means we only rewrite a file
|
|
124
|
+
// whose producer has provably exited, which also retries kill-path trims
|
|
125
|
+
// that failed silently while the killed child still held the redirect
|
|
126
|
+
// handle on Windows (trimShellJobSpillFile swallows the lock error).
|
|
127
|
+
await Promise.all(names.map(async (name) => {
|
|
128
|
+
const isStdout = name.endsWith('.stdout.log');
|
|
129
|
+
if (!isStdout && !name.endsWith('.stderr.log')) return;
|
|
130
|
+
const jobId = name.slice(0, -11); // both suffixes are 11 chars
|
|
131
|
+
if (expiredSet.has(jobId)) return; // already unlinked above
|
|
132
|
+
if (!doneSet.has(jobId)) return; // no completion marker → maybe live, never touch
|
|
133
|
+
const p = join(dir, name);
|
|
134
|
+
try {
|
|
135
|
+
const st = await fsPromises.stat(p);
|
|
136
|
+
if (st.size <= SHELL_JOB_SPILL_MAX_BYTES) return;
|
|
137
|
+
const buf = await fsPromises.readFile(p);
|
|
138
|
+
await fsPromises.writeFile(p, buf.subarray(Math.max(0, buf.length - SHELL_JOB_SPILL_KEEP_BYTES)));
|
|
139
|
+
} catch {}
|
|
140
|
+
}));
|
|
141
|
+
sweepStaleShellOutput(dir, names);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sibling GC for spill files under $PLUGIN_DATA/shell-output/. TaskOutput
|
|
145
|
+
// (shell-command.mjs) spills foreground stdout/stderr there as <taskId>.stdout
|
|
146
|
+
// /.stderr once past the inline cap; a KEPT foreground spill (child dead at
|
|
147
|
+
// settle) or an adopted job's leftover then sits at up to the 100MB disk cap
|
|
148
|
+
// with no sweep of its own. Proof-of-death required before touching a file
|
|
149
|
+
// (an open writer handle is the only corruption/ENOENT-to-a-reader risk): a
|
|
150
|
+
// file is skipped iff some shell-job with a live pid and no .done references
|
|
151
|
+
// it. pid-reuse false positives err toward NOT touching. Dead files past the
|
|
152
|
+
// stale TTL are removed; oversized dead files are trimmed to a bounded tail.
|
|
153
|
+
async function sweepStaleShellOutput(shellJobsDir, jobNames) {
|
|
154
|
+
const outDir = join(getPluginData(), 'shell-output');
|
|
155
|
+
let outNames;
|
|
156
|
+
try { outNames = await fsPromises.readdir(outDir); } catch { return; }
|
|
157
|
+
if (outNames.length === 0) return;
|
|
158
|
+
const doneSet = new Set(jobNames.filter(n => n.endsWith('.done')).map(n => n.slice(0, -5)));
|
|
159
|
+
const liveSpill = new Set();
|
|
160
|
+
await Promise.all(jobNames.map(async (name) => {
|
|
161
|
+
if (!name.endsWith('.json')) return;
|
|
162
|
+
const jobId = name.slice(0, -5);
|
|
163
|
+
if (doneSet.has(jobId)) return; // completed → its spill is provably dead
|
|
164
|
+
try {
|
|
165
|
+
const detail = JSON.parse(await fsPromises.readFile(join(shellJobsDir, name), 'utf-8'));
|
|
166
|
+
const pid = Number(detail?.pid);
|
|
167
|
+
let alive = true; // unknown/invalid pid → conservative: treat as live
|
|
168
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
169
|
+
try { process.kill(pid, 0); alive = true; } // running (or EPERM)
|
|
170
|
+
catch (e) { alive = e?.code !== 'ESRCH'; } // ESRCH → dead
|
|
171
|
+
}
|
|
172
|
+
if (!alive) return;
|
|
173
|
+
for (const key of ['stdoutPath', 'stderrPath']) {
|
|
174
|
+
const p = detail?.[key];
|
|
175
|
+
if (typeof p === 'string' && p) liveSpill.add(basename(p));
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
}));
|
|
179
|
+
const cutoff = Date.now() - SHELL_JOB_STALE_MS;
|
|
180
|
+
await Promise.all(outNames.map(async (name) => {
|
|
181
|
+
if (liveSpill.has(name)) return; // referenced by a live producer — never touch
|
|
182
|
+
const p = join(outDir, name);
|
|
183
|
+
try {
|
|
184
|
+
const st = await fsPromises.stat(p);
|
|
185
|
+
if (st.mtimeMs < cutoff) { await fsPromises.unlink(p).catch(() => {}); return; }
|
|
186
|
+
if (st.size <= SHELL_JOB_SPILL_MAX_BYTES) return;
|
|
187
|
+
const buf = await fsPromises.readFile(p);
|
|
188
|
+
await fsPromises.writeFile(p, buf.subarray(Math.max(0, buf.length - SHELL_JOB_SPILL_KEEP_BYTES)));
|
|
189
|
+
} catch {}
|
|
190
|
+
}));
|
|
85
191
|
}
|
|
86
192
|
|
|
87
193
|
export function getShellJobsDir() {
|
|
@@ -102,7 +102,19 @@ export function _installShellJobsExitHook() {
|
|
|
102
102
|
_shellJobsExitHookInstalled = true;
|
|
103
103
|
_ensureProcessListenerHeadroom(['exit', 'SIGTERM', 'SIGINT', 'SIGHUP'], 1);
|
|
104
104
|
try { process.on('exit', _sweepLiveJobsSync); } catch { /* ignore */ }
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// For terminating signals, sweep then restore default POSIX termination
|
|
106
|
+
// only when we are the last handler. A sweep-only handler swallows the
|
|
107
|
+
// signal and keeps the process alive; when several such handlers coexist,
|
|
108
|
+
// each removes itself and the last one to run re-raises so the default
|
|
109
|
+
// action takes effect without preempting graceful-shutdown listeners.
|
|
110
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
111
|
+
const onSignal = () => {
|
|
112
|
+
_sweepLiveJobsSync();
|
|
113
|
+
try { process.removeListener(sig, onSignal); } catch { /* ignore */ }
|
|
114
|
+
try {
|
|
115
|
+
if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
|
|
116
|
+
} catch { /* ignore */ }
|
|
117
|
+
};
|
|
118
|
+
try { process.on(sig, onSignal); } catch { /* ignore */ }
|
|
119
|
+
}
|
|
108
120
|
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
shellJobDonePath,
|
|
28
28
|
shellJobEnforcedPath,
|
|
29
29
|
resolveJobOwnerHostPid,
|
|
30
|
+
trimShellJobSpill,
|
|
30
31
|
writeShellJobDetail,
|
|
31
32
|
readShellJobDetail,
|
|
32
33
|
} from './shell-job-paths.mjs';
|
|
@@ -309,6 +310,7 @@ export function killShellJob(jobId) {
|
|
|
309
310
|
detail.exitCode = 137;
|
|
310
311
|
detail.error = 'killed by user (KillShell)';
|
|
311
312
|
detail.finishedAt = new Date().toISOString();
|
|
313
|
+
trimShellJobSpill(detail);
|
|
312
314
|
writeShellJobDetail(detail);
|
|
313
315
|
_unregisterLiveJobPid(detail.pid);
|
|
314
316
|
return { ...attachJobInsights(detail), killed: true };
|
|
@@ -340,6 +342,9 @@ function refreshShellJob(jobId) {
|
|
|
340
342
|
detail.status = exitCode === 0 ? 'completed' : 'failed';
|
|
341
343
|
detail.exitCode = exitCode;
|
|
342
344
|
detail.finishedAt = finishedAt;
|
|
345
|
+
// Job finished: cap its spill to a bounded tail so a flooding
|
|
346
|
+
// producer can't leave a 100MB stdout/stderr log behind.
|
|
347
|
+
trimShellJobSpill(detail);
|
|
343
348
|
writeShellJobDetail(detail);
|
|
344
349
|
_unregisterLiveJobPid(detail.pid);
|
|
345
350
|
return detail;
|
|
@@ -352,6 +357,7 @@ function refreshShellJob(jobId) {
|
|
|
352
357
|
detail.exitCode = 124;
|
|
353
358
|
detail.finishedAt = new Date().toISOString();
|
|
354
359
|
detail.error = `timed out after ${timeoutMs} ms`;
|
|
360
|
+
trimShellJobSpill(detail);
|
|
355
361
|
writeShellJobDetail(detail);
|
|
356
362
|
_unregisterLiveJobPid(detail.pid);
|
|
357
363
|
return detail;
|
|
@@ -365,6 +371,7 @@ function refreshShellJob(jobId) {
|
|
|
365
371
|
detail.exitCode = 137;
|
|
366
372
|
detail.finishedAt = new Date().toISOString();
|
|
367
373
|
detail.error = `output exceeded ${SHELL_JOB_OUTPUT_DISK_CAP} byte cap`;
|
|
374
|
+
trimShellJobSpill(detail);
|
|
368
375
|
writeShellJobDetail(detail);
|
|
369
376
|
_unregisterLiveJobPid(detail.pid);
|
|
370
377
|
return detail;
|
|
@@ -193,9 +193,31 @@ function firstExistingPathFromWhereExcluding(commandName, excludeRe) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// Kind-aware shell resolution. kind:
|
|
197
|
+
//
|
|
198
|
+
// Resolve a real bash on macOS/Linux. When 'bash' is explicitly requested we
|
|
199
|
+
// must NOT hand back /bin/sh, which on dash/ash distros is not bash and breaks
|
|
200
|
+
// bash-only syntax. Probe common install paths, then `bash` on PATH; only when
|
|
201
|
+
// nothing is found do we fall back to /bin/sh so a shell is still returned.
|
|
202
|
+
function resolvePosixBash() {
|
|
203
|
+
for (const p of ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash']) {
|
|
204
|
+
if (existsSync(p)) return shellSpec(p, 'posix');
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const r = spawnSync('which', ['bash'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 });
|
|
208
|
+
if (r.status === 0 && r.stdout) {
|
|
209
|
+
const p = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).find(Boolean);
|
|
210
|
+
if (p && existsSync(p)) return shellSpec(p, 'posix');
|
|
211
|
+
}
|
|
212
|
+
} catch { /* fall through to /bin/sh */ }
|
|
213
|
+
return shellSpec('/bin/sh', 'posix');
|
|
214
|
+
}
|
|
215
|
+
|
|
196
216
|
// Kind-aware shell resolution. kind:
|
|
197
217
|
// 'default' → identical to resolveShell() (PowerShell on Windows, /bin/sh elsewhere).
|
|
198
|
-
// 'bash' → on Windows, Git Bash (or null if not installed); elsewhere
|
|
218
|
+
// 'bash' → on Windows, Git Bash (or null if not installed); elsewhere a real bash
|
|
219
|
+
// binary (/bin/bash, /usr/bin/bash, or `bash` on PATH), falling back to
|
|
220
|
+
// /bin/sh only when no bash exists (dash/ash distros break on bash syntax).
|
|
199
221
|
// 'powershell' → on Windows, resolveShell(); elsewhere pwsh if present, else null.
|
|
200
222
|
// Each kind is memoized independently, but ONLY on success: a resolution miss
|
|
201
223
|
// (null) is not cached, so the caller's clear-error path is re-probed on the
|
|
@@ -206,7 +228,7 @@ export function resolveShellFor(kind = 'default') {
|
|
|
206
228
|
|
|
207
229
|
let spec = null;
|
|
208
230
|
if (kind === 'bash') {
|
|
209
|
-
spec = _isWindows() ? resolveWindowsGitBash() :
|
|
231
|
+
spec = _isWindows() ? resolveWindowsGitBash() : resolvePosixBash();
|
|
210
232
|
} else if (kind === 'powershell') {
|
|
211
233
|
if (_isWindows()) {
|
|
212
234
|
spec = resolveShell();
|
|
@@ -11,7 +11,7 @@ import { withBuiltinPathLocks } from '../builtin.mjs';
|
|
|
11
11
|
import { withAdvisoryLocks } from '../builtin/advisory-lock.mjs';
|
|
12
12
|
import { wrapMutationRouteOutput } from '../mutation-planner.mjs';
|
|
13
13
|
import { getPluginData } from '../../config.mjs';
|
|
14
|
-
import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, isCompactedPlaceholderPatch } from './parsing.mjs';
|
|
14
|
+
import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, isCompactedPlaceholderPatch, salvageV4AOpening } from './parsing.mjs';
|
|
15
15
|
import {
|
|
16
16
|
resolveBasePath,
|
|
17
17
|
resolveV4AEntryPath,
|
|
@@ -105,7 +105,7 @@ function salvageShatteredV4APatchArgs(args) {
|
|
|
105
105
|
|
|
106
106
|
async function apply_patch(args, cwd, options = {}) {
|
|
107
107
|
args = salvageShatteredV4APatchArgs(args);
|
|
108
|
-
const patchStr = (typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, '');
|
|
108
|
+
const patchStr = salvageV4AOpening((typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, ''));
|
|
109
109
|
if (!patchStr.trim()) {
|
|
110
110
|
throw new Error('apply_patch: "patch" is required (unified diff or V4A patch string)');
|
|
111
111
|
}
|
|
@@ -29,6 +29,67 @@ export function isV4APatchInput(patchStr, format) {
|
|
|
29
29
|
|| isApplyPatchEnvelope(patchStr);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Absorb malformed-but-unambiguous patch openings that models emit instead of
|
|
33
|
+
// a clean "*** Begin Patch\n*** Update File:" header. Every observed shape
|
|
34
|
+
// arrives wrapped in a (possibly decorated) "*** Begin Patch" envelope, so the
|
|
35
|
+
// junk is the first body line before any file header. Normalises them into a
|
|
36
|
+
// form the existing V4A / unified paths already handle. Returns the ORIGINAL
|
|
37
|
+
// string untouched whenever nothing is salvageable — well-formed patches and
|
|
38
|
+
// genuinely ambiguous input both pass through unchanged (the latter then keeps
|
|
39
|
+
// erroring downstream).
|
|
40
|
+
const BEGIN_PATCH_RE = /^\*\*\*\s*Begin Patch\b/i;
|
|
41
|
+
const V4A_FILE_HEADER_RE = /^\*\*\* (?:Update|Add|Delete) File:/;
|
|
42
|
+
const V4A_FILE_HEADER_ANYLINE_RE = /^\*\*\* (?:Update|Add|Delete) File:/m;
|
|
43
|
+
const UNIFIED_BODY_RE = /^(?:---\s|\+\+\+\s|diff --git |Index: )/;
|
|
44
|
+
|
|
45
|
+
function impliedV4AFilePath(line) {
|
|
46
|
+
let t = String(line || '').trim();
|
|
47
|
+
const fileMatch = t.match(/^File:\s*(.+)$/i);
|
|
48
|
+
if (fileMatch) t = fileMatch[1].trim();
|
|
49
|
+
// Conservative: a single whitespace-free token that reads like a path — has
|
|
50
|
+
// a directory separator or a dotted extension. Anything else (prose, an
|
|
51
|
+
// "@@" anchor, a bare word) is treated as ambiguous and left to error.
|
|
52
|
+
if (!t || /\s/.test(t)) return null;
|
|
53
|
+
if (!/^[\w@.~/\\-]+$/.test(t)) return null;
|
|
54
|
+
if (!(t.includes('/') || t.includes('\\') || /\.\w+$/.test(t))) return null;
|
|
55
|
+
return normaliseV4APath(t);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function salvageV4AOpening(patchStr) {
|
|
59
|
+
const raw = String(patchStr ?? '');
|
|
60
|
+
const lines = prepareInput(raw).split('\n');
|
|
61
|
+
let i = 0;
|
|
62
|
+
while (i < lines.length && lines[i].trim() === '') i++;
|
|
63
|
+
if (i >= lines.length) return raw;
|
|
64
|
+
const first = lines[i];
|
|
65
|
+
if (!BEGIN_PATCH_RE.test(first)) {
|
|
66
|
+
// No envelope: only absorb leading blank lines ahead of a real V4A header.
|
|
67
|
+
if (i > 0 && V4A_FILE_HEADER_RE.test(first)) return lines.slice(i).join('\n');
|
|
68
|
+
return raw;
|
|
69
|
+
}
|
|
70
|
+
const needBeginFix = first !== '*** Begin Patch';
|
|
71
|
+
const out = lines.slice(i);
|
|
72
|
+
out[0] = '*** Begin Patch';
|
|
73
|
+
let j = 1;
|
|
74
|
+
while (j < out.length && out[j].trim() === '') j++;
|
|
75
|
+
const body = out[j];
|
|
76
|
+
let changed = i > 0 || needBeginFix;
|
|
77
|
+
if (body !== undefined && !V4A_FILE_HEADER_RE.test(body) && !/^\*\*\* End Patch/i.test(body)) {
|
|
78
|
+
if (UNIFIED_BODY_RE.test(body) && !V4A_FILE_HEADER_ANYLINE_RE.test(prepareInput(raw))) {
|
|
79
|
+
// Unified diff wrapped in a V4A envelope: drop the envelope so it routes
|
|
80
|
+
// through the unified parser instead of the V4A path. Only when NO real
|
|
81
|
+
// V4A file headers exist anywhere (a mixed body stays ambiguous).
|
|
82
|
+
return out.filter((l, idx) => idx !== 0 && !/^\*\*\* End Patch/i.test(l)).join('\n');
|
|
83
|
+
}
|
|
84
|
+
const implied = impliedV4AFilePath(body);
|
|
85
|
+
if (implied) {
|
|
86
|
+
out.splice(j, 1, `*** Update File: ${implied}`);
|
|
87
|
+
changed = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return changed ? out.join('\n') : raw;
|
|
91
|
+
}
|
|
92
|
+
|
|
32
93
|
export function hasUnifiedBareV4AHunk(patchStr) {
|
|
33
94
|
const text = prepareInput(patchStr);
|
|
34
95
|
if (!/^--- /m.test(text) || !/^\+\+\+ /m.test(text)) return false;
|
|
@@ -122,14 +183,17 @@ function splitPatchLines(patchStr) {
|
|
|
122
183
|
// new tool args). Exported so callers (e.g. apply_patch entry) can check
|
|
123
184
|
// this before any format detection / V4A-vs-unified dispatch, so ANY
|
|
124
185
|
// compacted body gets the corrective message regardless of format.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
186
|
+
//
|
|
187
|
+
// Matches EVERY placeholder variant, not just "<key>: <N> chars, sha256:…":
|
|
188
|
+
// the marker key segment varies ("patch", "old_string", "patch v4a, sha256:…",
|
|
189
|
+
// etc.), so anchor on the "[mixdog compacted …]" bracket span alone.
|
|
190
|
+
export const COMPACTED_PLACEHOLDER_RE = /^\s*\[mixdog compacted\b[^\]\n]*\]/;
|
|
191
|
+
|
|
192
|
+
// Mid-body scan reuses the same broad bracket shape. It only runs on lines that
|
|
193
|
+
// are NOT unified diff content (+/-/space-prefixed), so a legit edit whose real
|
|
194
|
+
// content mentions the placeholder text still parses; the skip discipline below
|
|
195
|
+
// is what prevents false positives, not a narrower regex.
|
|
196
|
+
const COMPACTED_PLACEHOLDER_LINE_RE = COMPACTED_PLACEHOLDER_RE;
|
|
133
197
|
|
|
134
198
|
export function isCompactedPlaceholderPatch(patchStr) {
|
|
135
199
|
const str = String(patchStr ?? '');
|
|
@@ -10,7 +10,11 @@ export function isDangerousDeleteTarget(rawTarget) {
|
|
|
10
10
|
if (/^\$home(\b|[\\/])/.test(low)) return true;
|
|
11
11
|
if (/^\$env:(userprofile|homepath|home|homedrive|systemroot|windir|programfiles|programdata|systemdrive|allusersprofile|public|appdata|localappdata)\b/.test(low)) return true;
|
|
12
12
|
if (/^%(userprofile|homepath|home|homedrive|systemroot|windir|programfiles|programdata|systemdrive|allusersprofile|public|appdata|localappdata)%/i.test(t)) return true;
|
|
13
|
-
|
|
13
|
+
// Match ${HOME} and parameter-expansion forms: ${HOME:?}, ${HOME:-x},
|
|
14
|
+
// ${HOME%/}, ${HOME#x}, ${HOME/a/b}, ${HOME+x}, ${HOME=x}. The lookahead
|
|
15
|
+
// requires the next char to be the closing brace or an expansion operator so
|
|
16
|
+
// plain longer names (e.g. ${HOMEBREW}) don't false-match.
|
|
17
|
+
if (/^\$\{(home|userprofile|homepath|homedrive|systemroot|windir|programfiles|programdata|systemdrive|allusersprofile|public|appdata|localappdata)(?=[}:%#/+\-?=])/i.test(t)) return true;
|
|
14
18
|
if (/^\$\(home\)/i.test(t)) return true;
|
|
15
19
|
if (/^\\\\[^\\]+\\[^\\]+\\\*?$/.test(t) || /^\\\\[^\\]+\\[^\\]*$/.test(t)) return true;
|
|
16
20
|
if (t.startsWith('$') || t.includes('$(') || /^%[^%]*%/.test(t)) return false;
|