mixdog 0.9.88 → 0.9.89
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 +1 -1
- package/src/rules/shared/01-tool.md +24 -34
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +15 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +16 -2
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +24 -2
- package/src/runtime/shared/child-guardian.mjs +21 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/dist/index.mjs +10 -102
- package/src/workflows/solo/WORKFLOW.md +4 -2
package/package.json
CHANGED
|
@@ -8,43 +8,33 @@
|
|
|
8
8
|
identifier/relation→`code_graph` before grep; known file/span→`read`
|
|
9
9
|
directly without `grep`; verified directory→`list`; known edit→
|
|
10
10
|
`apply_patch` directly, with no preparatory `read`;
|
|
11
|
-
program/state change→`shell`; web/current external info→`search`.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- Project root, session cwd, user-provided and tool-returned paths are
|
|
26
|
-
verified. Use `find` first for every genuinely guessed path/name fragment, in
|
|
27
|
-
the same turn as independent probes. Never find verified roots or use
|
|
28
|
-
`path:"."` with guessed `src/**`. On ENOENT, find the basename; never retry
|
|
29
|
-
the guess. Unscoped root grep/glob/list requires no find.
|
|
11
|
+
program/state change→`shell`; web/current external info→`search`.
|
|
12
|
+
- Shortest total calls, maximum batching — every turn. Combine variants,
|
|
13
|
+
symbols, scopes, paths, and queries into one call; put all independent
|
|
14
|
+
calls (probes, reads, hypotheses, commands) in one message — concurrent
|
|
15
|
+
regardless of tool, shell included. Sequential singles only for a
|
|
16
|
+
genuinely dependent next step. Distinct facets, not alternative routes.
|
|
17
|
+
Only apply_patch executes in order.
|
|
18
|
+
- Batch compatible reads — same-file regions as real `{path,offset,limit}`
|
|
19
|
+
arrays covering the whole logical unit — in one `path[]` call, and graph
|
|
20
|
+
targets in arrays. Don't reread returned spans. Put all new edits in one
|
|
21
|
+
patch.
|
|
22
|
+
- Verified paths: project root, session cwd, user-provided, tool-returned.
|
|
23
|
+
`find` first for guessed path/name fragments (same turn as other probes);
|
|
24
|
+
on ENOENT, find the basename.
|
|
30
25
|
- At task start, batch all `explore` facets in one `query[]` call, maximum 8,
|
|
31
26
|
without rephrased duplicates. Retry `EXPLORATION_FAILED` once with changed
|
|
32
27
|
tokens.
|
|
33
|
-
- Stop when evidence covers the deliverable;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
grep means no overlapping `read`. After `files_with_matches`, `count`,
|
|
39
|
-
capped, or insufficient context, inspect only missing content. A nonzero
|
|
40
|
-
`content_with_context` result resolves the concept; act directly without
|
|
41
|
-
re-search; only zero/error results permit token or scope changes.
|
|
28
|
+
- Stop when evidence covers the deliverable; don't re-locate or re-verify a
|
|
29
|
+
sufficient anchor. A returned `path:line` freezes the location; inspecting
|
|
30
|
+
its content with read/code_graph is valid.
|
|
31
|
+
- A nonzero `content_with_context` result resolves that concept — act on it;
|
|
32
|
+
only zero/error results justify changed tokens or scope.
|
|
42
33
|
- `apply_patch` is the primary edit tool: send the patch as soon as the target
|
|
43
34
|
path and new content are known. `read` is for discovery or for recovery
|
|
44
35
|
after a patch failed on insufficient context.
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
- A
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
explicitly.
|
|
36
|
+
- A shell placed after `apply_patch` in the same turn runs after the patch
|
|
37
|
+
lands — batch edits and their verification freely.
|
|
38
|
+
- A command promoted to background is a decision point: continue only if
|
|
39
|
+
observed progress fits the budget, otherwise switch routes. Waiting is an
|
|
40
|
+
explicit choice.
|
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
} from './loop/tool-helpers.mjs';
|
|
82
82
|
import {
|
|
83
83
|
compactToolCallsForHistory,
|
|
84
|
+
compactSettledToolCallBodies,
|
|
84
85
|
restoreToolCallBodyForId,
|
|
85
86
|
} from './loop/stored-tool-args.mjs';
|
|
86
87
|
import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
|
|
@@ -216,6 +217,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
216
217
|
// Reasoning/thinking deltas, tool calls, and the final answer are kept.
|
|
217
218
|
const suppressMidTurnText = isAgentOwner(sessionRef);
|
|
218
219
|
if (suppressMidTurnText) opts.onTextDelta = undefined;
|
|
220
|
+
// Deferred mutation-body compaction: bodies left verbatim by a previous
|
|
221
|
+
// turn's push (deferBodies below) collapse to markers now — the model has
|
|
222
|
+
// already seen them on that turn's follow-up send. Failed bodies stay
|
|
223
|
+
// verbatim for retry.
|
|
224
|
+
compactSettledToolCallBodies(messages);
|
|
219
225
|
// ---- Codex turn stop hook (refs/codex core/src/session/turn.rs:372-404) --
|
|
220
226
|
// A no-tool assistant message is TERMINAL. Only a structured provider
|
|
221
227
|
// follow-up signal (end_turn=false / pause_turn), pending input, tool
|
|
@@ -938,7 +944,12 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
938
944
|
// response.content (the real result rides the later final-answer
|
|
939
945
|
// turn). Blank it so it never accumulates as input tokens.
|
|
940
946
|
content: suppressMidTurnText ? '' : (response.content || ''),
|
|
941
|
-
|
|
947
|
+
// deferBodies: mutation bodies (patch / old_string / ...) stay
|
|
948
|
+
// verbatim through the send that answers this batch, then the
|
|
949
|
+
// compactSettledToolCallBodies sweep below collapses them. This
|
|
950
|
+
// keeps a model that patches twice in a row from ever seeing (and
|
|
951
|
+
// copying) a compacted marker where its own last patch should be.
|
|
952
|
+
toolCalls: compactToolCallsForHistory(calls, { deferBodies: true }),
|
|
942
953
|
// MIXED Anthropic turn (native server tools + client tool_use):
|
|
943
954
|
// the ordered `server_tool_use` / `*_tool_result` blocks exist ONLY
|
|
944
955
|
// in this verbatim list and are order-bound (a result block is
|
|
@@ -972,6 +983,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
972
983
|
? { providerMetadata: response.providerMetadata }
|
|
973
984
|
: {}),
|
|
974
985
|
}, opts);
|
|
986
|
+
// Settle earlier deferred bodies before this turn's message lands:
|
|
987
|
+
// every previous call already has its result row, so successful bodies
|
|
988
|
+
// compact to markers while failed ones keep their full retry text.
|
|
989
|
+
compactSettledToolCallBodies(messages);
|
|
975
990
|
messages.push(_assistantTurnMsg);
|
|
976
991
|
try { opts.onAssistantMessageCommitted?.(_assistantTurnMsg); } catch {}
|
|
977
992
|
const _callsToExecute = calls;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// serial batch loop.
|
|
8
8
|
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
9
9
|
import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
|
|
10
|
-
import { _intraTurnSig, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
|
|
10
|
+
import { _intraTurnSig, _isMutationTool, _isReadTool, _isScopedCacheableTool, _isShellTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
|
|
11
11
|
import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
|
|
12
12
|
import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
|
|
13
13
|
import { executeTool } from './loop/tool-exec.mjs';
|
|
@@ -40,6 +40,18 @@ export function createEagerDispatcher({
|
|
|
40
40
|
// resets at the turn boundary without leaking across getIterations().
|
|
41
41
|
const _eagerInFlightSigs = new Map();
|
|
42
42
|
const epoch = { mutation: 0 };
|
|
43
|
+
// Patch→shell ordering insurance: a shell call that appears AFTER an
|
|
44
|
+
// apply_patch in the same assistant turn must not eager-start before
|
|
45
|
+
// that patch has executed (serial body runs both in call order).
|
|
46
|
+
// Reads are already safe via the mutationEpoch re-execution gate;
|
|
47
|
+
// only shell's side effects would consume pre-patch file state.
|
|
48
|
+
let _streamSawMutation = false;
|
|
49
|
+
const _hasEarlierMutation = (calls, index) => {
|
|
50
|
+
for (let k = 0; k < index; k += 1) {
|
|
51
|
+
if (_isMutationTool(calls[k]?.name)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
};
|
|
43
55
|
const startEagerTool = (call) => {
|
|
44
56
|
if (!call?.id || pending.has(call.id) || !isParallelDispatchable(call.name)) return null;
|
|
45
57
|
// Never eager-execute a call whose arguments failed to parse
|
|
@@ -163,6 +175,9 @@ export function createEagerDispatcher({
|
|
|
163
175
|
// later calls keep starting in parallel past it.
|
|
164
176
|
if (!call?.id || !isParallelDispatchable(call.name)) continue;
|
|
165
177
|
if (dupSet && dupSet.has(call.id)) continue;
|
|
178
|
+
// Patch→shell insurance: leave a shell that follows an
|
|
179
|
+
// apply_patch to the serial body so it runs after the patch.
|
|
180
|
+
if (_isShellTool(call.name) && _hasEarlierMutation(calls, j)) continue;
|
|
166
181
|
// A null return here is NOT a state barrier. It means a
|
|
167
182
|
// non-barrier stub — intra-turn in-flight dup, repeat-failure /
|
|
168
183
|
// cross-turn dedup, pre-dispatch-deny, invalid-args, or a cache
|
|
@@ -173,7 +188,9 @@ export function createEagerDispatcher({
|
|
|
173
188
|
}
|
|
174
189
|
};
|
|
175
190
|
const onToolCall = (call) => {
|
|
191
|
+
if (_isMutationTool(call?.name)) { _streamSawMutation = true; return; }
|
|
176
192
|
if (!isParallelDispatchable(call?.name)) return;
|
|
193
|
+
if (_streamSawMutation && _isShellTool(call.name)) return;
|
|
177
194
|
startEagerTool(call);
|
|
178
195
|
};
|
|
179
196
|
return { pending, epoch, startEagerTool, startEagerRun, onToolCall };
|
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
// patch text is the model's own draft, and leaving only the marker made models
|
|
6
6
|
// copy `[mixdog compacted …]` back as literal patch input. Bodies of calls that
|
|
7
7
|
// did NOT fail stay compacted so an already-applied patch cannot be replayed.
|
|
8
|
+
//
|
|
9
|
+
// Mutation bodies additionally get ONE turn of grace: the push that commits a
|
|
10
|
+
// tool-call turn defers body compaction (deferBodies), so the send that
|
|
11
|
+
// immediately follows a successful patch still shows the model its own patch
|
|
12
|
+
// verbatim. compactSettledToolCallBodies then collapses settled successful
|
|
13
|
+
// bodies to markers on the next push / loop entry. This closes the observed
|
|
14
|
+
// failure where a model writing a follow-up patch right after a success copies
|
|
15
|
+
// the marker it was just shown as literal patch input.
|
|
8
16
|
import { createHash } from 'crypto';
|
|
9
17
|
|
|
10
18
|
const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
|
|
@@ -20,14 +28,20 @@ const STORED_TOOL_ARG_LIMIT = 10_000;
|
|
|
20
28
|
const STORED_TOOL_ARG_PREVIEW_HEAD = 360;
|
|
21
29
|
const STORED_TOOL_ARG_PREVIEW_TAIL = 160;
|
|
22
30
|
|
|
23
|
-
function compactStoredToolArgString(value, key = '') {
|
|
31
|
+
function compactStoredToolArgString(value, key = '', opts = {}) {
|
|
24
32
|
if (typeof value !== 'string') return value;
|
|
25
33
|
const isBody = STORED_TOOL_ARG_BODY_KEY_RE.test(key);
|
|
34
|
+
if (isBody && opts.deferBodies === true) return value;
|
|
26
35
|
const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
|
|
27
36
|
const limit = isLong ? STORED_TOOL_ARG_LIMIT : Infinity;
|
|
28
37
|
if (value.length <= limit) return value;
|
|
29
38
|
const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
30
|
-
|
|
39
|
+
// Body markers carry the recovery instruction inline: the compaction
|
|
40
|
+
// detectors only require the `[mixdog compacted ...]` shape (no ']' or
|
|
41
|
+
// newline inside), so the longer text stays fully compatible.
|
|
42
|
+
const marker = isBody
|
|
43
|
+
? `[mixdog compacted ${key}: ${value.length} chars, sha256:${hash}; already applied - do not copy; re-read the file and write a fresh patch]`
|
|
44
|
+
: `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}; do not copy]`;
|
|
31
45
|
// Body args (patch / old_string / new_string / content / rewrite) are
|
|
32
46
|
// apply_patch / edit inputs. Keeping a head/tail preview leaves real patch
|
|
33
47
|
// fragments (a "*** Begin Patch" opening, diff lines) inside a SUCCESSFUL
|
|
@@ -40,32 +54,60 @@ function compactStoredToolArgString(value, key = '') {
|
|
|
40
54
|
return `${marker}\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
|
|
41
55
|
}
|
|
42
56
|
|
|
43
|
-
function compactStoredToolArgValue(value, key = '', depth = 0) {
|
|
57
|
+
function compactStoredToolArgValue(value, key = '', depth = 0, opts = {}) {
|
|
44
58
|
if (value === null || value === undefined) return value;
|
|
45
|
-
if (typeof value === 'string') return compactStoredToolArgString(value, key);
|
|
59
|
+
if (typeof value === 'string') return compactStoredToolArgString(value, key, opts);
|
|
46
60
|
if (typeof value !== 'object') return value;
|
|
47
61
|
if (depth >= 6) return Array.isArray(value) ? `[${value.length} items]` : '{...}';
|
|
48
62
|
if (Array.isArray(value)) {
|
|
49
|
-
return value.map((item) => compactStoredToolArgValue(item, key, depth + 1));
|
|
63
|
+
return value.map((item) => compactStoredToolArgValue(item, key, depth + 1, opts));
|
|
50
64
|
}
|
|
51
65
|
const out = {};
|
|
52
66
|
for (const [k, v] of Object.entries(value)) {
|
|
53
|
-
out[k] = compactStoredToolArgValue(v, k, depth + 1);
|
|
67
|
+
out[k] = compactStoredToolArgValue(v, k, depth + 1, opts);
|
|
54
68
|
}
|
|
55
69
|
return out;
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
export function compactToolCallsForHistory(calls) {
|
|
72
|
+
export function compactToolCallsForHistory(calls, opts = {}) {
|
|
59
73
|
if (!Array.isArray(calls)) return calls;
|
|
60
74
|
return calls.map((call) => {
|
|
61
75
|
if (!call || typeof call !== 'object') return call;
|
|
62
76
|
return {
|
|
63
77
|
...call,
|
|
64
|
-
arguments: compactStoredToolArgValue(call.arguments),
|
|
78
|
+
arguments: compactStoredToolArgValue(call.arguments, '', 0, opts),
|
|
65
79
|
};
|
|
66
80
|
});
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
// Collapse the body args of every SETTLED, non-failed tool call in history to
|
|
84
|
+
// their compacted markers. Runs at loop entry and before each new assistant
|
|
85
|
+
// push, so a deferred verbatim body survives exactly the send(s) that follow
|
|
86
|
+
// its own turn and no longer. Contract guards:
|
|
87
|
+
// - a call whose result is an error keeps its full body (retry-safe, same
|
|
88
|
+
// contract as restoreToolCallBodyForId);
|
|
89
|
+
// - a call with no result row yet (current batch / interrupted turn) is
|
|
90
|
+
// left untouched.
|
|
91
|
+
export function compactSettledToolCallBodies(messages) {
|
|
92
|
+
if (!Array.isArray(messages)) return;
|
|
93
|
+
const resultKinds = new Map();
|
|
94
|
+
for (const message of messages) {
|
|
95
|
+
if (message?.role === 'tool' && message.toolCallId) {
|
|
96
|
+
resultKinds.set(message.toolCallId, message.toolKind || 'normal');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const message of messages) {
|
|
100
|
+
if (message?.role !== 'assistant' || !Array.isArray(message.toolCalls)) continue;
|
|
101
|
+
for (const call of message.toolCalls) {
|
|
102
|
+
if (!call || typeof call !== 'object') continue;
|
|
103
|
+
if (!call.arguments || typeof call.arguments !== 'object') continue;
|
|
104
|
+
const kind = call.id ? resultKinds.get(call.id) : undefined;
|
|
105
|
+
if (kind === undefined || kind === 'error') continue;
|
|
106
|
+
call.arguments = compactStoredToolArgValue(call.arguments);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
69
111
|
// Restore retry-safe long command/script text for ONE failed tool call inside a
|
|
70
112
|
// history assistant message whose toolCalls were compacted at push time.
|
|
71
113
|
// Mutation bodies (patch, old_string, new_string, content, rewrite) are restored
|
|
@@ -36,7 +36,7 @@ export const BUILTIN_TOOLS = [
|
|
|
36
36
|
name: 'read',
|
|
37
37
|
title: 'Mixdog Read',
|
|
38
38
|
annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
|
|
39
|
-
description: 'Read file contents
|
|
39
|
+
description: 'Read file contents. Batch paths/regions as real arrays: path[] or {path,offset,limit}[] regions in one call. Not for directories.',
|
|
40
40
|
inputSchema: {
|
|
41
41
|
type: 'object',
|
|
42
42
|
properties: {
|
|
@@ -74,8 +74,8 @@ export const BUILTIN_TOOLS = [
|
|
|
74
74
|
name: 'shell',
|
|
75
75
|
title: 'Mixdog Shell',
|
|
76
76
|
annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
|
|
77
|
-
description: 'Run programs/change state; not file inspection.
|
|
78
|
-
+ '
|
|
77
|
+
description: 'Run programs/change state; not file inspection. '
|
|
78
|
+
+ 'Combine order-dependent commands into one command. Use async for sleep/watch/dev loops.'
|
|
79
79
|
+ `${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
|
|
80
80
|
inputSchema: {
|
|
81
81
|
type: 'object',
|
|
@@ -123,7 +123,7 @@ export const BUILTIN_TOOLS = [
|
|
|
123
123
|
{ type: 'string' },
|
|
124
124
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
125
125
|
],
|
|
126
|
-
description: 'Text/regex; pattern[] batches variants in one call.
|
|
126
|
+
description: 'Text/regex; pattern[] batches variants in one call.',
|
|
127
127
|
},
|
|
128
128
|
path: {
|
|
129
129
|
anyOf: [
|
|
@@ -154,7 +154,7 @@ export const BUILTIN_TOOLS = [
|
|
|
154
154
|
name: 'glob',
|
|
155
155
|
title: 'Mixdog Glob',
|
|
156
156
|
annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
157
|
-
description: 'Match exact glob patterns from verified base directories (project root is verified)
|
|
157
|
+
description: 'Match exact glob patterns from verified base directories (project root is verified). Batch pattern[]/path[].',
|
|
158
158
|
inputSchema: {
|
|
159
159
|
type: 'object',
|
|
160
160
|
properties: {
|
|
@@ -113,7 +113,7 @@ async function sweepStaleShellJobs(dir) {
|
|
|
113
113
|
});
|
|
114
114
|
await Promise.all([
|
|
115
115
|
...expired.flatMap((jobId) =>
|
|
116
|
-
['.json', '.done', '.exit', '.enforced', '.exit.cmd.sh', '.exit.cmd.ps1', '.exit.user.ps1', '.stdout.log', '.stderr.log'].map((ext) =>
|
|
116
|
+
['.json', '.done', '.exit', '.enforced', '.guardian', '.exit.cmd.sh', '.exit.cmd.ps1', '.exit.user.ps1', '.stdout.log', '.stderr.log'].map((ext) =>
|
|
117
117
|
fsPromises.unlink(join(dir, jobId + ext)).catch(() => {}),
|
|
118
118
|
),
|
|
119
119
|
),
|
|
@@ -212,6 +212,11 @@ export function shellJobDonePath(jobId) { return join(getShellJobsDir(), `${jobI
|
|
|
212
212
|
// wrapper's own env/cwd resolution). PS jobs don't need it — their wrapper
|
|
213
213
|
// enforces unconditionally and records detail.timeoutEnforced:true.
|
|
214
214
|
export function shellJobEnforcedPath(jobId) { return join(getShellJobsDir(), `${jobId}.enforced`); }
|
|
215
|
+
// Guardian kill receipt: written by the detached child-guardian immediately
|
|
216
|
+
// before it force-kills the job tree (host memory floor / orphaned parent).
|
|
217
|
+
// Its existence lets refreshShellJob attribute a marker-less death to the
|
|
218
|
+
// guardian instead of reporting an unexplained unknown exit.
|
|
219
|
+
export function shellJobGuardianReceiptPath(jobId) { return join(getShellJobsDir(), `${jobId}.guardian`); }
|
|
215
220
|
// Owner sidecar marker: a zero-byte file whose NAME encodes the owning CC host
|
|
216
221
|
// (claude.exe) pid — `<jobId>.owner-<pid>`. It lets the statusline owner-filter
|
|
217
222
|
// jobs from a SINGLE directory listing (no per-job JSON read) so the filter can
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
shellJobExitPath,
|
|
28
28
|
shellJobDonePath,
|
|
29
29
|
shellJobEnforcedPath,
|
|
30
|
+
shellJobGuardianReceiptPath,
|
|
30
31
|
resolveJobOwnerHostPid,
|
|
31
32
|
trimShellJobSpill,
|
|
32
33
|
writeShellJobDetail,
|
|
@@ -71,7 +72,13 @@ globalThis.__mixdogShellJobsRuntimeLoaded = true;
|
|
|
71
72
|
// Poll cadence for the adopted-job output-cap self-tick (mirrors the
|
|
72
73
|
// foreground sizeWatchdog in shell-command.mjs).
|
|
73
74
|
|
|
74
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
refreshShellJob,
|
|
77
|
+
trackChildUntilConfirmedExit,
|
|
78
|
+
releaseShellJobOwnershipWhenQuiescent,
|
|
79
|
+
reconcileShellJobAfterQuiescence,
|
|
80
|
+
TIMER_MAX_MS,
|
|
81
|
+
} from './shell-jobs.mjs';
|
|
75
82
|
|
|
76
83
|
export async function _startBackgroundShellJobImpl({
|
|
77
84
|
command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellArgs,
|
|
@@ -262,6 +269,7 @@ export async function _startBackgroundShellJobImpl({
|
|
|
262
269
|
childGroupPid: child.pid,
|
|
263
270
|
label: 'shell-job',
|
|
264
271
|
protectHostMemory: true,
|
|
272
|
+
receiptPath: shellJobGuardianReceiptPath(jobId),
|
|
265
273
|
});
|
|
266
274
|
_installShellJobsExitHook();
|
|
267
275
|
_registerLiveJobPid(child.pid, jobId);
|
|
@@ -269,6 +277,10 @@ export async function _startBackgroundShellJobImpl({
|
|
|
269
277
|
releaseShellJobOwnershipWhenQuiescent(jobId, child.pid, {
|
|
270
278
|
allowLateLease: true,
|
|
271
279
|
deferUntilRootExit: true,
|
|
280
|
+
// Exit-event ownership (CC-aligned): when the whole tree is gone but
|
|
281
|
+
// the wrapper never published markers, settle the job honestly instead
|
|
282
|
+
// of leaving a permanently-'running' or falsely-'failed' record.
|
|
283
|
+
onConfirmed: () => { try { reconcileShellJobAfterQuiescence(jobId); } catch { /* best-effort */ } },
|
|
272
284
|
});
|
|
273
285
|
// Deadline cleanup poke only when a timeout is enforced; an unlimited
|
|
274
286
|
// (timeoutMs<=0) job has no deadline — completion is observed via the
|
|
@@ -505,6 +517,7 @@ async function startBackgroundPowerShellJob({
|
|
|
505
517
|
childGroupPid: childPid,
|
|
506
518
|
label: 'shell-job-powershell',
|
|
507
519
|
protectHostMemory: true,
|
|
520
|
+
receiptPath: shellJobGuardianReceiptPath(jobId),
|
|
508
521
|
});
|
|
509
522
|
_installShellJobsExitHook();
|
|
510
523
|
_registerLiveJobPid(childPid, jobId);
|
|
@@ -512,6 +525,7 @@ async function startBackgroundPowerShellJob({
|
|
|
512
525
|
releaseShellJobOwnershipWhenQuiescent(jobId, childPid, {
|
|
513
526
|
allowLateLease: true,
|
|
514
527
|
deferUntilRootExit: true,
|
|
528
|
+
onConfirmed: () => { try { reconcileShellJobAfterQuiescence(jobId); } catch { /* best-effort */ } },
|
|
515
529
|
});
|
|
516
530
|
if (Number(timeoutMs) > 0) {
|
|
517
531
|
const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
shellJobExitPath,
|
|
28
28
|
shellJobDonePath,
|
|
29
29
|
shellJobEnforcedPath,
|
|
30
|
+
shellJobGuardianReceiptPath,
|
|
30
31
|
resolveJobOwnerHostPid,
|
|
31
32
|
trimShellJobSpill,
|
|
32
33
|
writeShellJobDetail,
|
|
@@ -134,6 +135,65 @@ export function killShellJob(jobId) {
|
|
|
134
135
|
return { ...attachJobInsights(detail), killed: true };
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// CC-aligned verdict for a job whose tracked root process died WITHOUT the
|
|
139
|
+
// wrapper publishing the done marker. Evidence order:
|
|
140
|
+
// 1. exit file — the wrapper writes exit BEFORE done, so a kill in that
|
|
141
|
+
// window still left the real exit code; recover it instead of guessing.
|
|
142
|
+
// 2. guardian receipt — the child-guardian records why it force-killed the
|
|
143
|
+
// tree (host memory floor / orphaned parent) before killing.
|
|
144
|
+
// 3. otherwise — honest unknown: terminal 'failed' with exitCode null and
|
|
145
|
+
// an explicit "exit status unknown" error; spilled output is preserved
|
|
146
|
+
// and the completion notification carries its tail.
|
|
147
|
+
function finalizeMarkerlessShellJob(detail) {
|
|
148
|
+
const jobId = detail.jobId;
|
|
149
|
+
detail.finishedAt = new Date().toISOString();
|
|
150
|
+
let recoveredExit = null;
|
|
151
|
+
try {
|
|
152
|
+
const raw = readFileSync(shellJobExitPath(jobId), 'utf-8').trim();
|
|
153
|
+
const parsed = parseInt(raw, 10);
|
|
154
|
+
if (Number.isFinite(parsed)) recoveredExit = parsed;
|
|
155
|
+
} catch { /* no exit file */ }
|
|
156
|
+
if (recoveredExit !== null) {
|
|
157
|
+
detail.status = recoveredExit === 0 ? 'completed' : 'failed';
|
|
158
|
+
detail.exitCode = recoveredExit;
|
|
159
|
+
detail.note = 'done marker missing; exit code recovered from exit file';
|
|
160
|
+
} else {
|
|
161
|
+
let receipt = null;
|
|
162
|
+
try {
|
|
163
|
+
receipt = JSON.parse(readFileSync(shellJobGuardianReceiptPath(jobId), 'utf-8'));
|
|
164
|
+
} catch { /* no guardian receipt */ }
|
|
165
|
+
detail.status = 'failed';
|
|
166
|
+
detail.exitCode = 137;
|
|
167
|
+
if (receipt?.reason === 'host-memory-floor') {
|
|
168
|
+
const freeMb = Math.round(Number(receipt.freeBytes || 0) / (1024 * 1024));
|
|
169
|
+
const floorMb = Math.round(Number(receipt.minFreeMemoryBytes || 0) / (1024 * 1024));
|
|
170
|
+
detail.killedByGuardian = true;
|
|
171
|
+
detail.error = `killed by child guardian: host free memory ${freeMb} MB fell below the ${floorMb} MB floor — job tree force-killed to protect the host; retry with lower concurrency or after memory recovers`;
|
|
172
|
+
} else if (receipt?.reason === 'parent-exit') {
|
|
173
|
+
detail.killedByGuardian = true;
|
|
174
|
+
detail.error = 'killed by child guardian: owning runtime process exited (session cleanup)';
|
|
175
|
+
} else {
|
|
176
|
+
detail.exitCode = null;
|
|
177
|
+
detail.error = 'exit status unknown — tracked process exited without reporting an exit code; spilled output preserved (see stdout tail)';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
trimShellJobSpill(detail);
|
|
181
|
+
writeShellJobDetail(detail);
|
|
182
|
+
releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid);
|
|
183
|
+
return detail;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Quiescence-deferred reconcile for the owning runtime: called when the
|
|
187
|
+
// spawn-time process-tree tracker confirms the whole tree is gone. If the
|
|
188
|
+
// wrapper never published markers (e.g. it was force-killed while detached
|
|
189
|
+
// descendants kept running), settle the job honestly now.
|
|
190
|
+
export function reconcileShellJobAfterQuiescence(jobId) {
|
|
191
|
+
const detail = readShellJobDetail(jobId);
|
|
192
|
+
if (!detail || detail.status !== 'running') return detail;
|
|
193
|
+
if (existsSync(shellJobDonePath(jobId))) return refreshShellJob(jobId);
|
|
194
|
+
return finalizeMarkerlessShellJob(detail);
|
|
195
|
+
}
|
|
196
|
+
|
|
137
197
|
export function refreshShellJob(jobId) {
|
|
138
198
|
const detail = readShellJobDetail(jobId);
|
|
139
199
|
if (!detail) return null;
|
|
@@ -207,11 +267,31 @@ export function refreshShellJob(jobId) {
|
|
|
207
267
|
return detail;
|
|
208
268
|
}
|
|
209
269
|
if (detail.pid && !isPidAlive(detail.pid)) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
270
|
+
// Root process died without markers. If this runtime owns a live
|
|
271
|
+
// process-tree tracker that still sees descendants (a killed wrapper
|
|
272
|
+
// whose real workload survived — observed with detached grandchildren),
|
|
273
|
+
// the JOB is still running: keep status 'running', flag the root exit,
|
|
274
|
+
// and let tree quiescence settle it via reconcileShellJobAfterQuiescence.
|
|
275
|
+
const trackerEntry = shellJobQuiescenceTrackers.get(jobId);
|
|
276
|
+
if (trackerEntry?.tracker?.pending) {
|
|
277
|
+
if (!detail.rootExitedAt) {
|
|
278
|
+
detail.rootExitedAt = new Date().toISOString();
|
|
279
|
+
detail.note = 'root process exited without an exit report; descendants still running — tracking tree to completion';
|
|
280
|
+
writeShellJobDetail(detail);
|
|
281
|
+
// Belt-and-braces for jobs without a spawn-time onConfirmed
|
|
282
|
+
// (adopted/legacy): settle once the tree is confirmed gone.
|
|
283
|
+
// Guarded by the rootExitedAt stamp so poll ticks cannot grow
|
|
284
|
+
// the callback set unboundedly.
|
|
285
|
+
trackerEntry.callbacks.add(() => reconcileShellJobAfterQuiescence(jobId));
|
|
286
|
+
}
|
|
287
|
+
// Root death is now observed: let the deferred tracker start
|
|
288
|
+
// confirming tree quiescence (idempotent past the first call).
|
|
289
|
+
trackerEntry.tracker.rootExited?.();
|
|
290
|
+
return detail;
|
|
291
|
+
}
|
|
292
|
+
// No live tracker (cross-process reader or tree already quiescent):
|
|
293
|
+
// settle now with the strongest available evidence.
|
|
294
|
+
return finalizeMarkerlessShellJob(detail);
|
|
215
295
|
}
|
|
216
296
|
return detail;
|
|
217
297
|
}
|
|
@@ -439,6 +439,25 @@ export function findLineSequence(lines, needle, fromLine, preferredLine = 0, opt
|
|
|
439
439
|
return starts[0];
|
|
440
440
|
}
|
|
441
441
|
}
|
|
442
|
+
// Whole-file unique-verbatim fallback: an @@ anchor that matched a LATER
|
|
443
|
+
// occurrence, or a previous hunk that advanced the cursor, leaves the
|
|
444
|
+
// forward-only search blind to a block that exists verbatim EARLIER in the
|
|
445
|
+
// file (measured: the majority of real context-miss failures). Accept that
|
|
446
|
+
// block ONLY when it matches exactly once in the entire file — zero
|
|
447
|
+
// ambiguity about where it belongs — and never for `*** End of File` hunks.
|
|
448
|
+
// assertSafeReplacementPlan still rejects any overlap with another hunk's
|
|
449
|
+
// replacement, so a rewound match can not silently collide.
|
|
450
|
+
if (fuzzy && !eof && minStart > 0) {
|
|
451
|
+
const starts = [];
|
|
452
|
+
for (let i = 0; i + needle.length <= lines.length && starts.length < 2; i++) {
|
|
453
|
+
let ok = true;
|
|
454
|
+
for (let k = 0; k < needle.length; k++) {
|
|
455
|
+
if (lines[i + k] !== needle[k]) { ok = false; break; }
|
|
456
|
+
}
|
|
457
|
+
if (ok) starts.push(i);
|
|
458
|
+
}
|
|
459
|
+
if (starts.length === 1) return starts[0];
|
|
460
|
+
}
|
|
442
461
|
if (fuzzy && needle.length === 1) {
|
|
443
462
|
const want = String(needle[0] ?? '').replace(/\s+/g, ' ').trim();
|
|
444
463
|
if (want.length >= 40) {
|
|
@@ -298,6 +298,13 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
|
|
|
298
298
|
for (const entry of parsed || []) {
|
|
299
299
|
const kind = classifyEntry(entry);
|
|
300
300
|
const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
|
|
301
|
+
if (!headerName) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
'apply_patch: a file section header could not be parsed (no target path) — the patch body is not a valid diff. '
|
|
304
|
+
+ 'Each section must start with `*** Update File: <path>` / `*** Add File: <path>` / `*** Delete File: <path>` '
|
|
305
|
+
+ '(V4A, wrapped in `*** Begin Patch` / `*** End Patch`), or a `--- a/<path>` + `+++ b/<path>` pair (unified).',
|
|
306
|
+
);
|
|
307
|
+
}
|
|
301
308
|
units.push({
|
|
302
309
|
displayPath: normalizeOutputPath(stripDiffPrefix(headerName || '')),
|
|
303
310
|
fullPath: parsedEntryResolvedPath(entry, basePath),
|
|
@@ -24,6 +24,16 @@ export function stripDiffPrefix(name) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function resolveEntryPath(basePath, rawName) {
|
|
27
|
+
// A parsed entry without a header path (jsdiff parses arbitrary non-diff
|
|
28
|
+
// text into a headerless entry) must fail as a patch-format error, not as a
|
|
29
|
+
// raw `The "path" argument must be of type string` TypeError from node:path.
|
|
30
|
+
if (rawName == null || rawName === '') {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'apply_patch: a file section header could not be parsed (no target path) — the patch body is not a valid diff. '
|
|
33
|
+
+ 'Each section must start with `*** Update File: <path>` / `*** Add File: <path>` / `*** Delete File: <path>` '
|
|
34
|
+
+ '(V4A, wrapped in `*** Begin Patch` / `*** End Patch`), or a `--- a/<path>` + `+++ b/<path>` pair (unified).',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
27
37
|
const stripped = stripDiffPrefix(rawName);
|
|
28
38
|
const norm = normalizeInputPath(stripped);
|
|
29
39
|
return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, basePath);
|
|
@@ -661,6 +661,12 @@ export async function convertV4ASectionsToUnifiedPatch(sections, basePath, optio
|
|
|
661
661
|
throw new Error(`V4A update target unreadable: ${section.path} (${err?.code || err?.message || String(err)}).`);
|
|
662
662
|
}
|
|
663
663
|
const sectionHunks = [];
|
|
664
|
+
// Resolved-position ordering for the emitted unified hunks: the unique-
|
|
665
|
+
// verbatim fallback in findLineSequence can resolve a later-listed hunk
|
|
666
|
+
// BEFORE an earlier one, and the native engine applies unified hunks with
|
|
667
|
+
// a forward-only cursor — emission must therefore be sorted by resolved
|
|
668
|
+
// start (stable on ties) instead of resolution order.
|
|
669
|
+
const sectionHunkEntries = [];
|
|
664
670
|
const orderedHunks = orderV4AHunksByFilePosition(sourceLines, section.hunks, fuzzy);
|
|
665
671
|
let nextSearchLine = 0;
|
|
666
672
|
for (const hunk of orderedHunks) {
|
|
@@ -730,10 +736,18 @@ export async function convertV4ASectionsToUnifiedPatch(sections, basePath, optio
|
|
|
730
736
|
// insertion index (`-N,0` inserts after source line N), while a real
|
|
731
737
|
// replacement starts at the 1-based first replaced line.
|
|
732
738
|
const oldStart = emittedOld === 0 ? loc.oldStartIdx : loc.oldStartIdx + 1;
|
|
733
|
-
|
|
734
|
-
|
|
739
|
+
sectionHunkEntries.push({
|
|
740
|
+
start: loc.oldStartIdx,
|
|
741
|
+
order: sectionHunkEntries.length,
|
|
742
|
+
lines: [
|
|
743
|
+
`@@ -${oldStart},${emittedOld} +${oldStart},${emittedNew} @@${tail ? ` ${tail}` : ''}`,
|
|
744
|
+
...bodyLines,
|
|
745
|
+
],
|
|
746
|
+
});
|
|
735
747
|
nextSearchLine = loc.nextSearchLine;
|
|
736
748
|
}
|
|
749
|
+
sectionHunkEntries.sort((a, b) => (a.start - b.start) || (a.order - b.order));
|
|
750
|
+
for (const entry of sectionHunkEntries) sectionHunks.push(...entry.lines);
|
|
737
751
|
if (sectionHunks.length > 0) {
|
|
738
752
|
out.push(`--- a/${displayPath}`);
|
|
739
753
|
out.push(`+++ b/${displayPath}`);
|
|
@@ -30,12 +30,34 @@ eof_line: "*** End of File" LF
|
|
|
30
30
|
const APPLY_PATCH_FREEFORM_DESCRIPTION =
|
|
31
31
|
'Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.';
|
|
32
32
|
|
|
33
|
+
// JSON-schema fallback providers (Anthropic and other non-grammar surfaces)
|
|
34
|
+
// get the full Codex V4A instructions inline: without a grammar the model has
|
|
35
|
+
// no format signal beyond this description, and the dominant one-shot failure
|
|
36
|
+
// modes (missing section headers, retyped context, marker resubmission) are
|
|
37
|
+
// exactly what these rules preempt. Mirrors
|
|
38
|
+
// refs/codex/codex-rs/prompts/templates/apply_patch_tool_instructions.md,
|
|
39
|
+
// adapted to the JSON `patch` argument.
|
|
40
|
+
const APPLY_PATCH_JSON_DESCRIPTION = [
|
|
41
|
+
'Primary file-editing tool. Send `patch` as soon as the target and change are known.',
|
|
42
|
+
'Use this V4A envelope:',
|
|
43
|
+
'*** Begin Patch',
|
|
44
|
+
'[file sections]',
|
|
45
|
+
'*** End Patch',
|
|
46
|
+
'Every section starts with exactly one header:',
|
|
47
|
+
'- *** Add File: <path> then one or more +content lines.',
|
|
48
|
+
'- *** Delete File: <path> with nothing after it.',
|
|
49
|
+
'- *** Update File: <path>, optionally followed by *** Move to: <new path>.',
|
|
50
|
+
'Updates contain hunks introduced by @@ or @@ <enclosing class/function>. Prefix every hunk line with space (context), - (remove), or + (add); a hunk at end of file may close with *** End of File.',
|
|
51
|
+
'Copy 3 context lines above and below verbatim from the file. Do not duplicate overlapping context. If context is not unique, add one or more enclosing @@ headers.',
|
|
52
|
+
'Use project-relative paths. Every added file line needs +. Never submit a compacted-history marker; re-read and create a fresh patch.',
|
|
53
|
+
].join('\n');
|
|
54
|
+
|
|
33
55
|
export const PATCH_TOOL_DEFS = [
|
|
34
56
|
{
|
|
35
57
|
name: 'apply_patch',
|
|
36
58
|
title: 'Mixdog Apply Patch',
|
|
37
59
|
annotations: { title: 'Mixdog Apply Patch', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, compressible: false, compressibleLossless: true },
|
|
38
|
-
description:
|
|
60
|
+
description: APPLY_PATCH_JSON_DESCRIPTION,
|
|
39
61
|
freeformDescription: APPLY_PATCH_FREEFORM_DESCRIPTION,
|
|
40
62
|
freeform: {
|
|
41
63
|
type: 'grammar',
|
|
@@ -45,7 +67,7 @@ export const PATCH_TOOL_DEFS = [
|
|
|
45
67
|
inputSchema: {
|
|
46
68
|
type: 'object',
|
|
47
69
|
properties: {
|
|
48
|
-
patch: { type: 'string', description: 'The V4A patch text to apply.' },
|
|
70
|
+
patch: { type: 'string', description: 'The V4A patch text to apply (format and context rules in the tool description).' },
|
|
49
71
|
},
|
|
50
72
|
required: ['patch'],
|
|
51
73
|
},
|
|
@@ -38,6 +38,7 @@ function guardianScript({
|
|
|
38
38
|
orphanGraceMs,
|
|
39
39
|
forceGraceMs,
|
|
40
40
|
minFreeMemoryMb,
|
|
41
|
+
receiptPath,
|
|
41
42
|
}) {
|
|
42
43
|
return `
|
|
43
44
|
const { spawnSync } = require('node:child_process');
|
|
@@ -50,6 +51,22 @@ const pollMs = ${JSON.stringify(pollMs)};
|
|
|
50
51
|
const orphanGraceMs = ${JSON.stringify(orphanGraceMs)};
|
|
51
52
|
const forceGraceMs = ${JSON.stringify(forceGraceMs)};
|
|
52
53
|
const minFreeMemoryBytes = ${JSON.stringify(minFreeMemoryMb * 1024 * 1024)};
|
|
54
|
+
const receiptPath = ${JSON.stringify(receiptPath || null)};
|
|
55
|
+
// Kill receipt: guardian kills are otherwise indistinguishable from a crashed
|
|
56
|
+
// wrapper ("process exited without reporting an exit code"). Written BEFORE
|
|
57
|
+
// the kill so the owner's next status refresh can attribute the death.
|
|
58
|
+
function writeReceipt(reason, extra) {
|
|
59
|
+
if (!receiptPath) return;
|
|
60
|
+
try {
|
|
61
|
+
require('node:fs').writeFileSync(receiptPath, JSON.stringify({
|
|
62
|
+
reason,
|
|
63
|
+
at: new Date().toISOString(),
|
|
64
|
+
guardianPid: process.pid,
|
|
65
|
+
childPid,
|
|
66
|
+
...extra,
|
|
67
|
+
}));
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
53
70
|
function alive(pid) {
|
|
54
71
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
55
72
|
try { process.kill(pid, 0); return true; }
|
|
@@ -84,6 +101,7 @@ const timer = setInterval(() => {
|
|
|
84
101
|
try { freeBytes = Number(freemem()); } catch {}
|
|
85
102
|
if (Number.isFinite(freeBytes) && freeBytes < minFreeMemoryBytes) {
|
|
86
103
|
killing = true;
|
|
104
|
+
writeReceipt('host-memory-floor', { freeBytes, minFreeMemoryBytes });
|
|
87
105
|
killTarget(true);
|
|
88
106
|
process.exit(0);
|
|
89
107
|
}
|
|
@@ -98,6 +116,7 @@ const timer = setInterval(() => {
|
|
|
98
116
|
if (!orphanedAt) orphanedAt = Date.now();
|
|
99
117
|
if (killing || Date.now() - orphanedAt < orphanGraceMs) return;
|
|
100
118
|
killing = true;
|
|
119
|
+
writeReceipt('parent-exit', {});
|
|
101
120
|
killTarget(false);
|
|
102
121
|
setTimeout(() => { if (alive(childPid)) killTarget(true); process.exit(0); }, forceGraceMs).unref?.();
|
|
103
122
|
}, pollMs);
|
|
@@ -115,6 +134,7 @@ export function startChildGuardian({
|
|
|
115
134
|
forceGraceMs = graceMs,
|
|
116
135
|
protectHostMemory = false,
|
|
117
136
|
minFreeMemoryMb = protectHostMemory ? childGuardianMemoryFloorMb() : 0,
|
|
137
|
+
receiptPath = null,
|
|
118
138
|
} = {}) {
|
|
119
139
|
const parent = positiveInt(parentPid);
|
|
120
140
|
const child = positiveInt(childPid);
|
|
@@ -134,6 +154,7 @@ export function startChildGuardian({
|
|
|
134
154
|
orphanGraceMs: Math.max(100, Math.floor(Number(orphanGraceMs) || Number(graceMs) || 3000)),
|
|
135
155
|
forceGraceMs: Math.max(100, Math.floor(Number(forceGraceMs) || Number(graceMs) || 3000)),
|
|
136
156
|
minFreeMemoryMb: memoryFloorMb,
|
|
157
|
+
receiptPath: typeof receiptPath === 'string' && receiptPath ? receiptPath : null,
|
|
137
158
|
}),
|
|
138
159
|
], {
|
|
139
160
|
stdio: 'ignore',
|
|
@@ -18,7 +18,7 @@ export const EXPLORE_TOOL = {
|
|
|
18
18
|
name: 'explore',
|
|
19
19
|
title: 'Explore',
|
|
20
20
|
annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
21
|
-
description: 'Locator for broad/uncertain targets with no known path, repo or machine-wide (dot dirs included). Array = independent targets: query[] fans out facets (max 8), never rephrasings.
|
|
21
|
+
description: 'Locator for broad/uncertain targets with no known path, repo or machine-wide (dot dirs included). Array = independent targets: query[] fans out facets (max 8), never rephrasings.',
|
|
22
22
|
inputSchema: {
|
|
23
23
|
type: 'object',
|
|
24
24
|
properties: {
|
|
@@ -157,134 +157,28 @@ export function createRoutePickers({
|
|
|
157
157
|
closeUsagePanel();
|
|
158
158
|
setPicker({
|
|
159
159
|
title: 'Workflow',
|
|
160
|
-
description: '
|
|
161
|
-
help: returnTo ? '↑/↓ Select · Enter
|
|
160
|
+
description: 'Select active workflow.',
|
|
161
|
+
help: returnTo ? '↑/↓ Select · Enter Choose · Esc Settings' : '↑/↓ Select · Enter Choose · Esc Back',
|
|
162
162
|
labelWidth: 18,
|
|
163
|
-
initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialWorkflowId || ''))),
|
|
164
163
|
items,
|
|
165
164
|
onSelect: (_value, item) => {
|
|
166
165
|
const workflow = item?._workflow;
|
|
167
166
|
if (!workflow) return;
|
|
168
|
-
// Depth mirrors the system model: a workflow pack owns the agent set
|
|
169
|
-
// it delegates to, so Enter opens the pack (activate + its agents)
|
|
170
|
-
// instead of switching immediately.
|
|
171
|
-
openWorkflowDetailPicker(workflow.id, { returnTo });
|
|
172
|
-
},
|
|
173
|
-
onCancel: () => {
|
|
174
167
|
setPicker(null);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
});
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
// Workflow detail: activation row plus the agents this pack delegates to
|
|
181
|
-
// (route editing inline). Packs without an `agents:` key delegate to all
|
|
182
|
-
// fixed agents; a configured-but-empty list means Lead works alone.
|
|
183
|
-
const openWorkflowDetailPicker = (workflowId, options = {}) => {
|
|
184
|
-
const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
|
|
185
|
-
let workflows = [];
|
|
186
|
-
try {
|
|
187
|
-
workflows = store.listWorkflows?.() || [];
|
|
188
|
-
} catch (e) {
|
|
189
|
-
store.pushNotice(`could not list workflows: ${e?.message || e}`, 'error');
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const workflow = workflows.find((item) => item.id === workflowId);
|
|
193
|
-
if (!workflow) {
|
|
194
|
-
store.pushNotice(`workflow "${workflowId}" not found`, 'warn');
|
|
195
|
-
openWorkflowPicker({ returnTo });
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
let agents = [];
|
|
199
|
-
try {
|
|
200
|
-
agents = store.listAgents?.() || [];
|
|
201
|
-
} catch (e) {
|
|
202
|
-
store.pushNotice(`could not list agents: ${e?.message || e}`, 'error');
|
|
203
|
-
agents = [];
|
|
204
|
-
}
|
|
205
|
-
const packAgents = workflow.agentsConfigured === true
|
|
206
|
-
? (workflow.agents || [])
|
|
207
|
-
.map((id) => agents.find((agent) => agent.id === id) || { id, label: id, route: null, description: '' })
|
|
208
|
-
: agents.filter((agent) => agent.custom !== true);
|
|
209
|
-
const routeOverrides = options.routeOverrides && typeof options.routeOverrides === 'object' ? options.routeOverrides : {};
|
|
210
|
-
const items = [
|
|
211
|
-
{
|
|
212
|
-
value: '__activate',
|
|
213
|
-
label: workflow.active ? 'Active' : 'Activate',
|
|
214
|
-
marker: workflow.active ? '✓' : '',
|
|
215
|
-
markerColor: theme.success,
|
|
216
|
-
description: workflow.active
|
|
217
|
-
? 'This workflow is active.'
|
|
218
|
-
: 'Make this the active workflow.',
|
|
219
|
-
},
|
|
220
|
-
...(packAgents.length
|
|
221
|
-
? packAgents.map((agent) => ({
|
|
222
|
-
value: agent.id,
|
|
223
|
-
label: agent.label,
|
|
224
|
-
metaParts: agentModelParts(routeOverrides[agent.id] || agent.route),
|
|
225
|
-
description: agent.description || agent.definition?.description || '',
|
|
226
|
-
_agent: agent,
|
|
227
|
-
}))
|
|
228
|
-
: [{
|
|
229
|
-
value: '__lead-only',
|
|
230
|
-
label: 'Lead only',
|
|
231
|
-
description: 'This workflow delegates to no agents.',
|
|
232
|
-
}]),
|
|
233
|
-
];
|
|
234
|
-
setPicker({
|
|
235
|
-
title: workflow.name,
|
|
236
|
-
description: workflow.description || 'Workflow activation and agents.',
|
|
237
|
-
help: '↑/↓ Select · Enter Choose · Esc Back',
|
|
238
|
-
indexMode: 'always',
|
|
239
|
-
labelWidth: 18,
|
|
240
|
-
metaWidth: 33,
|
|
241
|
-
initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialValue || '__activate'))),
|
|
242
|
-
items,
|
|
243
|
-
onSelect: (_value, item) => {
|
|
244
|
-
if (item?.value === '__activate') {
|
|
245
|
-
if (workflow.active) {
|
|
246
|
-
store.pushNotice(`${workflow.name} is already the active workflow`, 'info');
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
setPicker(null);
|
|
250
|
-
void store.setWorkflow?.(workflow.id)
|
|
251
|
-
.then((result) => {
|
|
252
|
-
if (!result) {
|
|
253
|
-
store.pushNotice('Workflow switch is already running.', 'warn');
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
store.pushNotice(workflowSwitchNotice(result), 'info');
|
|
257
|
-
openWorkflowPicker({ returnTo, initialWorkflowId: workflow.id });
|
|
258
|
-
})
|
|
259
|
-
.catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'));
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
const agent = item?._agent;
|
|
263
|
-
if (!agent) return;
|
|
264
|
-
void openModelPicker({
|
|
265
|
-
title: `${agent.label} Model`,
|
|
266
|
-
providerDescription: 'Choose a provider for this agent.',
|
|
267
|
-
currentRoute: agent.route || null,
|
|
268
|
-
returnTo: () => openWorkflowDetailPicker(workflow.id, { returnTo, initialValue: agent.id }),
|
|
269
|
-
onImmediateSelect: (routeInput) => {
|
|
270
|
-
openWorkflowDetailPicker(workflow.id, {
|
|
271
|
-
returnTo,
|
|
272
|
-
routeOverrides: { [agent.id]: routeInput },
|
|
273
|
-
initialValue: agent.id,
|
|
274
|
-
});
|
|
275
|
-
},
|
|
276
|
-
onSelectRoute: async (routeInput) => {
|
|
277
|
-
const result = await store.setAgentRoute?.(agent.id, routeInput);
|
|
168
|
+
void store.setWorkflow?.(workflow.id)
|
|
169
|
+
.then((result) => {
|
|
278
170
|
if (!result) {
|
|
279
|
-
store.pushNotice('
|
|
171
|
+
store.pushNotice('Workflow switch is already running.', 'warn');
|
|
280
172
|
return;
|
|
281
173
|
}
|
|
282
|
-
store.pushNotice(
|
|
283
|
-
|
|
284
|
-
|
|
174
|
+
store.pushNotice(workflowSwitchNotice(result), 'info');
|
|
175
|
+
if (returnTo) returnTo();
|
|
176
|
+
})
|
|
177
|
+
.catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'));
|
|
285
178
|
},
|
|
286
179
|
onCancel: () => {
|
|
287
|
-
|
|
180
|
+
setPicker(null);
|
|
181
|
+
if (returnTo) returnTo();
|
|
288
182
|
},
|
|
289
183
|
});
|
|
290
184
|
};
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -21142,118 +21142,26 @@ function createRoutePickers({
|
|
|
21142
21142
|
closeUsagePanel();
|
|
21143
21143
|
setPicker({
|
|
21144
21144
|
title: "Workflow",
|
|
21145
|
-
description: "
|
|
21146
|
-
help: returnTo ? "\u2191/\u2193 Select \xB7 Enter
|
|
21145
|
+
description: "Select active workflow.",
|
|
21146
|
+
help: returnTo ? "\u2191/\u2193 Select \xB7 Enter Choose \xB7 Esc Settings" : "\u2191/\u2193 Select \xB7 Enter Choose \xB7 Esc Back",
|
|
21147
21147
|
labelWidth: 18,
|
|
21148
|
-
initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialWorkflowId || ""))),
|
|
21149
21148
|
items,
|
|
21150
21149
|
onSelect: (_value, item) => {
|
|
21151
21150
|
const workflow = item?._workflow;
|
|
21152
21151
|
if (!workflow) return;
|
|
21153
|
-
openWorkflowDetailPicker(workflow.id, { returnTo });
|
|
21154
|
-
},
|
|
21155
|
-
onCancel: () => {
|
|
21156
21152
|
setPicker(null);
|
|
21157
|
-
|
|
21158
|
-
|
|
21159
|
-
|
|
21160
|
-
};
|
|
21161
|
-
const openWorkflowDetailPicker = (workflowId, options = {}) => {
|
|
21162
|
-
const returnTo = typeof options.returnTo === "function" ? options.returnTo : null;
|
|
21163
|
-
let workflows = [];
|
|
21164
|
-
try {
|
|
21165
|
-
workflows = store.listWorkflows?.() || [];
|
|
21166
|
-
} catch (e) {
|
|
21167
|
-
store.pushNotice(`could not list workflows: ${e?.message || e}`, "error");
|
|
21168
|
-
return;
|
|
21169
|
-
}
|
|
21170
|
-
const workflow = workflows.find((item) => item.id === workflowId);
|
|
21171
|
-
if (!workflow) {
|
|
21172
|
-
store.pushNotice(`workflow "${workflowId}" not found`, "warn");
|
|
21173
|
-
openWorkflowPicker({ returnTo });
|
|
21174
|
-
return;
|
|
21175
|
-
}
|
|
21176
|
-
let agents = [];
|
|
21177
|
-
try {
|
|
21178
|
-
agents = store.listAgents?.() || [];
|
|
21179
|
-
} catch (e) {
|
|
21180
|
-
store.pushNotice(`could not list agents: ${e?.message || e}`, "error");
|
|
21181
|
-
agents = [];
|
|
21182
|
-
}
|
|
21183
|
-
const packAgents = workflow.agentsConfigured === true ? (workflow.agents || []).map((id) => agents.find((agent) => agent.id === id) || { id, label: id, route: null, description: "" }) : agents.filter((agent) => agent.custom !== true);
|
|
21184
|
-
const routeOverrides = options.routeOverrides && typeof options.routeOverrides === "object" ? options.routeOverrides : {};
|
|
21185
|
-
const items = [
|
|
21186
|
-
{
|
|
21187
|
-
value: "__activate",
|
|
21188
|
-
label: workflow.active ? "Active" : "Activate",
|
|
21189
|
-
marker: workflow.active ? "\u2713" : "",
|
|
21190
|
-
markerColor: theme.success,
|
|
21191
|
-
description: workflow.active ? "This workflow is active." : "Make this the active workflow."
|
|
21192
|
-
},
|
|
21193
|
-
...packAgents.length ? packAgents.map((agent) => ({
|
|
21194
|
-
value: agent.id,
|
|
21195
|
-
label: agent.label,
|
|
21196
|
-
metaParts: agentModelParts2(routeOverrides[agent.id] || agent.route),
|
|
21197
|
-
description: agent.description || agent.definition?.description || "",
|
|
21198
|
-
_agent: agent
|
|
21199
|
-
})) : [{
|
|
21200
|
-
value: "__lead-only",
|
|
21201
|
-
label: "Lead only",
|
|
21202
|
-
description: "This workflow delegates to no agents."
|
|
21203
|
-
}]
|
|
21204
|
-
];
|
|
21205
|
-
setPicker({
|
|
21206
|
-
title: workflow.name,
|
|
21207
|
-
description: workflow.description || "Workflow activation and agents.",
|
|
21208
|
-
help: "\u2191/\u2193 Select \xB7 Enter Choose \xB7 Esc Back",
|
|
21209
|
-
indexMode: "always",
|
|
21210
|
-
labelWidth: 18,
|
|
21211
|
-
metaWidth: 33,
|
|
21212
|
-
initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialValue || "__activate"))),
|
|
21213
|
-
items,
|
|
21214
|
-
onSelect: (_value, item) => {
|
|
21215
|
-
if (item?.value === "__activate") {
|
|
21216
|
-
if (workflow.active) {
|
|
21217
|
-
store.pushNotice(`${workflow.name} is already the active workflow`, "info");
|
|
21153
|
+
void store.setWorkflow?.(workflow.id).then((result) => {
|
|
21154
|
+
if (!result) {
|
|
21155
|
+
store.pushNotice("Workflow switch is already running.", "warn");
|
|
21218
21156
|
return;
|
|
21219
21157
|
}
|
|
21220
|
-
|
|
21221
|
-
|
|
21222
|
-
|
|
21223
|
-
store.pushNotice("Workflow switch is already running.", "warn");
|
|
21224
|
-
return;
|
|
21225
|
-
}
|
|
21226
|
-
store.pushNotice(workflowSwitchNotice2(result), "info");
|
|
21227
|
-
openWorkflowPicker({ returnTo, initialWorkflowId: workflow.id });
|
|
21228
|
-
}).catch((e) => store.pushNotice(`Couldn\u2019t switch workflow: ${e?.message || e}`, "error"));
|
|
21229
|
-
return;
|
|
21230
|
-
}
|
|
21231
|
-
const agent = item?._agent;
|
|
21232
|
-
if (!agent) return;
|
|
21233
|
-
void openModelPicker({
|
|
21234
|
-
title: `${agent.label} Model`,
|
|
21235
|
-
providerDescription: "Choose a provider for this agent.",
|
|
21236
|
-
currentRoute: agent.route || null,
|
|
21237
|
-
returnTo: () => openWorkflowDetailPicker(workflow.id, { returnTo, initialValue: agent.id }),
|
|
21238
|
-
onImmediateSelect: (routeInput) => {
|
|
21239
|
-
openWorkflowDetailPicker(workflow.id, {
|
|
21240
|
-
returnTo,
|
|
21241
|
-
routeOverrides: { [agent.id]: routeInput },
|
|
21242
|
-
initialValue: agent.id
|
|
21243
|
-
});
|
|
21244
|
-
},
|
|
21245
|
-
onSelectRoute: async (routeInput) => {
|
|
21246
|
-
const result = await store.setAgentRoute?.(agent.id, routeInput);
|
|
21247
|
-
if (!result) {
|
|
21248
|
-
store.pushNotice("Agent model save is already running.", "warn");
|
|
21249
|
-
return;
|
|
21250
|
-
}
|
|
21251
|
-
store.pushNotice(`${agent.label} model set to ${agentModelProfile2(result)}`, "info");
|
|
21252
|
-
}
|
|
21253
|
-
});
|
|
21158
|
+
store.pushNotice(workflowSwitchNotice2(result), "info");
|
|
21159
|
+
if (returnTo) returnTo();
|
|
21160
|
+
}).catch((e) => store.pushNotice(`Couldn\u2019t switch workflow: ${e?.message || e}`, "error"));
|
|
21254
21161
|
},
|
|
21255
21162
|
onCancel: () => {
|
|
21256
|
-
|
|
21163
|
+
setPicker(null);
|
|
21164
|
+
if (returnTo) returnTo();
|
|
21257
21165
|
}
|
|
21258
21166
|
});
|
|
21259
21167
|
};
|
|
@@ -15,9 +15,11 @@ changed request resets planning; a scope change requires fresh approval.
|
|
|
15
15
|
On approval, Lead executes all work itself — never spawn, send, or delegate
|
|
16
16
|
to agents. Complete in-scope fixes without reapproval.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Verification is single-pass: run the one check that most directly proves the
|
|
19
|
+
deliverable exactly once; a pass is final. Iterate only on a failing check,
|
|
20
|
+
re-running just that check after each fix, or report the blocker.
|
|
19
21
|
|
|
20
|
-
Report the
|
|
22
|
+
Report the result and its proving check against the approved plan. Build, deploy, commit,
|
|
21
23
|
and push happen only on an explicit user request.
|
|
22
24
|
|
|
23
25
|
On direction change, pause and re-consult the user.
|