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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.88",
3
+ "version": "0.9.89",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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`. Use grep
12
- only for a requested literal occurrence or after graph zero/error.
13
- - Batch compatible targets and combine variants, symbols, scopes, paths, and
14
- queries. Parallelize distinct facets only, never alternative routes for one
15
- facet. Put independent calls in one turn; they run concurrently regardless
16
- of tool shell included. Later turns are only for targets dependent on
17
- prior results or unresolved facets. Only apply_patch executes in order.
18
- - After locator results, collect all known candidate files/regions before
19
- inspection. Batch compatible reads, including same-file regions real
20
- `{path,offset,limit}` arrays covering the whole logical unit in one
21
- `path[]` call and graph targets in arrays. Parallelize independent
22
- incompatible read-only inspections. Never page or reread returned spans,
23
- and do not start a singleton while a known compatible candidate remains.
24
- Put all new edits in one patch.
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 unitin 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; never re-locate, re-verify, or
34
- upgrade a sufficient anchor. A returned requested `path:line`
35
- freezes the LOCATION only; when content was not returned,
36
- read or code_graph detail inspection is validnever re-locate it.
37
- - Give grep content mode enough `-C` to avoid rereads; a sufficient contextual
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
- - Apply edits before verification, then verify in a separate shell turn and
46
- consume results in order. Otherwise remain parallel.
47
- - A long-running command promoted to background is a decision point, not a
48
- cue to wait: estimate whether observed progress can finish within budget;
49
- otherwise diagnose the bottleneck and switch routes. Choose waiting
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
- toolCalls: compactToolCallsForHistory(calls),
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
- const marker = `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}; do not copy]`;
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; guessed path/name → find first. Batch paths/regions as real arrays: path[] or {path,offset,limit}[] regions in one call. Not for directories.',
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. Calls in one turn run parallel; '
78
- + 'combine order-dependent commands. Use async for sleep/watch/dev loops.'
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. path[] batches verified scopes only; file/span reads use read path[] regions.',
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); guessed root/name → find first. Batch pattern[]/path[].',
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 { refreshShellJob, trackChildUntilConfirmedExit, releaseShellJobOwnershipWhenQuiescent, TIMER_MAX_MS } from './shell-jobs.mjs';
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
- detail.status = 'failed';
211
- detail.finishedAt = new Date().toISOString();
212
- detail.error = 'process exited without completion marker';
213
- writeShellJobDetail(detail);
214
- releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid);
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
- sectionHunks.push(`@@ -${oldStart},${emittedOld} +${oldStart},${emittedNew} @@${tail ? ` ${tail}` : ''}`);
734
- for (const bodyLine of bodyLines) sectionHunks.push(bodyLine);
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: 'Use the `apply_patch` tool to edit files.',
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. Every returned path:line freezes the LOCATION only; read/code_graph detail inspection is valid when content was not returned; never re-locate it — search only unresolved facets.',
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: 'Active workflow and its agents.',
161
- help: returnTo ? '↑/↓ Select · Enter Open · Esc Settings' : '↑/↓ Select · Enter Open · Esc Back',
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
- if (returnTo) returnTo();
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('Agent model save is already running.', 'warn');
171
+ store.pushNotice('Workflow switch is already running.', 'warn');
280
172
  return;
281
173
  }
282
- store.pushNotice(`${agent.label} model set to ${agentModelProfile(result)}`, 'info');
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
- openWorkflowPicker({ returnTo, initialWorkflowId: workflow.id });
180
+ setPicker(null);
181
+ if (returnTo) returnTo();
288
182
  },
289
183
  });
290
184
  };
@@ -21142,118 +21142,26 @@ function createRoutePickers({
21142
21142
  closeUsagePanel();
21143
21143
  setPicker({
21144
21144
  title: "Workflow",
21145
- description: "Active workflow and its agents.",
21146
- help: returnTo ? "\u2191/\u2193 Select \xB7 Enter Open \xB7 Esc Settings" : "\u2191/\u2193 Select \xB7 Enter Open \xB7 Esc Back",
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
- if (returnTo) returnTo();
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
- setPicker(null);
21221
- void store.setWorkflow?.(workflow.id).then((result) => {
21222
- if (!result) {
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
- openWorkflowPicker({ returnTo, initialWorkflowId: workflow.id });
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
- Verify directly: check and fix until clean, or report the blocker.
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 verified result against the approved plan. Build, deploy, commit,
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.