mixdog 0.9.91 → 0.9.92

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.91",
3
+ "version": "0.9.92",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -0,0 +1,60 @@
1
+ // Micro-bench: fixed per-call overhead of the shell tool path vs raw spawn.
2
+ // Usage: node scripts/tool-overhead-microbench.mjs [bash|powershell] [N]
3
+ // Prints per-call ms for executeBashTool('echo hi') and raw child spawn,
4
+ // so (tool - raw) isolates our tool-layer overhead (policy, wrappers, I/O).
5
+ import { spawn } from 'node:child_process';
6
+ import { executeBashTool } from '../src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs';
7
+
8
+ const shell = process.argv[2] || (process.platform === 'win32' ? 'powershell' : 'bash');
9
+ const N = Number(process.argv[3] || 15);
10
+
11
+ const stats = (arr) => {
12
+ const s = [...arr].sort((a, b) => a - b);
13
+ const sum = s.reduce((t, v) => t + v, 0);
14
+ return {
15
+ mean: (sum / s.length).toFixed(1),
16
+ p50: s[Math.floor(s.length / 2)].toFixed(1),
17
+ min: s[0].toFixed(1),
18
+ max: s[s.length - 1].toFixed(1),
19
+ };
20
+ };
21
+
22
+ const rawOnce = () => new Promise((resolveDone, reject) => {
23
+ const child = shell === 'powershell'
24
+ ? spawn('pwsh', ['-NoProfile', '-NonInteractive', '-Command', 'echo hi'])
25
+ : spawn('bash', ['-c', 'echo hi']);
26
+ let out = '';
27
+ child.stdout.on('data', (c) => { out += c; });
28
+ child.on('error', reject);
29
+ child.on('close', () => resolveDone(out));
30
+ });
31
+
32
+ const toolOnce = async () => {
33
+ const out = await executeBashTool({ command: 'echo hi', shell }, process.cwd(), {});
34
+ return String(out);
35
+ };
36
+
37
+ // Warm-up both paths (module init, shell resolution cache, standbys).
38
+ await toolOnce(); await toolOnce();
39
+ await rawOnce(); await rawOnce();
40
+
41
+ const toolMs = [];
42
+ for (let i = 0; i < N; i++) {
43
+ const t0 = performance.now();
44
+ await toolOnce();
45
+ toolMs.push(performance.now() - t0);
46
+ }
47
+ const rawMs = [];
48
+ for (let i = 0; i < N; i++) {
49
+ const t0 = performance.now();
50
+ await rawOnce();
51
+ rawMs.push(performance.now() - t0);
52
+ }
53
+
54
+ const t = stats(toolMs);
55
+ const r = stats(rawMs);
56
+ console.log(`shell=${shell} n=${N}`);
57
+ console.log(`tool mean=${t.mean}ms p50=${t.p50}ms min=${t.min}ms max=${t.max}ms`);
58
+ console.log(`raw mean=${r.mean}ms p50=${r.p50}ms min=${r.min}ms max=${r.max}ms`);
59
+ console.log(`overhead(mean tool-raw)=${(Number(t.mean) - Number(r.mean)).toFixed(1)}ms`);
60
+ process.exit(0);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "debugger",
3
3
  "name": "Debugger",
4
- "description": "Failure reproduction and root-cause analysis.",
4
+ "description": "Use for deep root-cause analysis or a bug surviving 2+ fix cycles.",
5
5
  "entry": "AGENT.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "explore",
3
3
  "name": "Explore",
4
- "description": "Broad codebase exploration and target narrowing.",
4
+ "description": "Use when targets are unknown: broad codebase exploration and narrowing.",
5
5
  "entry": "AGENT.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "heavy-worker",
3
3
  "name": "Heavy Worker",
4
- "description": "Broad or multi-file implementation.",
4
+ "description": "Use for complex, high-difficulty tasks.",
5
5
  "entry": "AGENT.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "maintainer",
3
3
  "name": "Maintainer",
4
- "description": "Maintenance, upkeep, and long-running health work.",
4
+ "description": "Use for memory upkeep and long-running maintenance work.",
5
5
  "entry": "AGENT.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "reviewer",
3
3
  "name": "Reviewer",
4
- "description": "Change review and risk finding.",
4
+ "description": "Use when an implementation finishes: cross-verify the change for correctness and risk before reporting.",
5
5
  "entry": "AGENT.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "worker",
3
3
  "name": "Worker",
4
- "description": "Scoped implementation.",
4
+ "description": "Use for simple, well-scoped tasks.",
5
5
  "entry": "AGENT.md"
6
6
  }
@@ -156,8 +156,8 @@ function stripFrontmatter(markdown) {
156
156
  }
157
157
 
158
158
  function normalizeOutputStyleName(value) {
159
- const name = String(value || 'default').trim();
160
- return /^[A-Za-z0-9_.-]+$/.test(name) ? name : 'default';
159
+ const name = String(value || 'simple').trim();
160
+ return /^[A-Za-z0-9_.-]+$/.test(name) ? name : 'simple';
161
161
  }
162
162
 
163
163
  function loadOutputStyle({ PLUGIN_ROOT, DATA_DIR }) {
@@ -172,10 +172,10 @@ function loadOutputStyle({ PLUGIN_ROOT, DATA_DIR }) {
172
172
  const body = stripFrontmatter(readOptional(candidate));
173
173
  if (body) return body;
174
174
  }
175
- if (styleName !== 'default') {
175
+ if (styleName !== 'simple') {
176
176
  const fallback = [
177
- path.join(DATA_DIR, 'output-styles', 'default.md'),
178
- path.join(PLUGIN_ROOT, 'output-styles', 'default.md'),
177
+ path.join(DATA_DIR, 'output-styles', 'simple.md'),
178
+ path.join(PLUGIN_ROOT, 'output-styles', 'simple.md'),
179
179
  ];
180
180
  for (const candidate of fallback) {
181
181
  const body = stripFrontmatter(readOptional(candidate));
@@ -2,7 +2,7 @@
2
2
  name: simple
3
3
  title: Simple
4
4
  description: Outcome-first concise handoffs for coding work
5
- aliases: concise, handoff, default
5
+ aliases: concise, handoff
6
6
  keep-coding-instructions: true
7
7
  ---
8
8
 
@@ -14,8 +14,9 @@
14
14
  `{path,offset,limit}` array; graph targets as arrays; `explore` facets in
15
15
  one `query[]` (max 8, no rephrased duplicates); all new edits in one patch.
16
16
  Distinct facets, not alternative routes; sequential singles only for a
17
- genuinely dependent step (unconditional follow-ups are not dependent
18
- batch them); only apply_patch executes in order.
17
+ step whose arguments require the previous result fixed follow-ups
18
+ (pinned installs, known writes) go in the same batch; only apply_patch
19
+ executes in order.
19
20
  - Verified paths: project root, session cwd, user-provided, tool-returned.
20
21
  `find` first for guessed path/name fragments; on ENOENT, find the basename.
21
22
  Retry `EXPLORATION_FAILED` once with changed tokens.
@@ -50,8 +50,10 @@ import {
50
50
  anthropicRequestTimeoutMs,
51
51
  classifyError,
52
52
  anthropicMaxAttempts,
53
+ createStallRetryBudget,
53
54
  midstreamBackoffFor,
54
55
  retryAfterMsFromError,
56
+ STREAM_STALL_RETRY_BUDGET_MS,
55
57
  withRetry,
56
58
  } from './retry-classifier.mjs';
57
59
  import {
@@ -774,6 +776,9 @@ export class AnthropicOAuthProvider {
774
776
  const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
775
777
  let firstAttemptError = null;
776
778
  let firstAttemptClassifier = null;
779
+ // Send-scoped stall window: in-place stall retries share one wall
780
+ // clock starting at the first stall (see createStallRetryBudget).
781
+ const stallRetryBudget = createStallRetryBudget();
777
782
 
778
783
  const recoverNonStreaming = async (midState, streamingError, controller) => {
779
784
  const exposedChars = Number(midState?.emittedTextChars) || 0;
@@ -1052,6 +1057,11 @@ export class AnthropicOAuthProvider {
1052
1057
  continue;
1053
1058
  }
1054
1059
  const classifier = _classifyMidstreamError(err, midState);
1060
+ if (classifier === 'stream_stalled' && !stallRetryBudget.allowStallRetry()) {
1061
+ try { process.stderr.write(`[anthropic-oauth] stall retry budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first stall) — surfacing for fresh-request retry\n`); } catch {}
1062
+ try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
1063
+ throw err;
1064
+ }
1055
1065
  if (classifier && attemptIndex < MAX_MIDSTREAM_RETRIES) {
1056
1066
  firstAttemptError = err;
1057
1067
  firstAttemptClassifier = classifier;
@@ -8,8 +8,10 @@ import {
8
8
  anthropicMaxAttempts,
9
9
  anthropicRequestTimeoutMs,
10
10
  classifyError,
11
+ createStallRetryBudget,
11
12
  midstreamBackoffFor,
12
13
  sleepWithAbort,
14
+ STREAM_STALL_RETRY_BUDGET_MS,
13
15
  withRetry,
14
16
  retryAfterMsFromError,
15
17
  } from './retry-classifier.mjs';
@@ -249,6 +251,9 @@ export class AnthropicProvider {
249
251
  const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
250
252
  let firstAttemptError = null;
251
253
  let firstAttemptClassifier = null;
254
+ // Send-scoped stall window: in-place stall retries share one wall
255
+ // clock starting at the first stall (see createStallRetryBudget).
256
+ const stallRetryBudget = createStallRetryBudget();
252
257
 
253
258
  const buildReturnFromParse = (parseResult) => {
254
259
  const usageRaw = parseResult.usage?.raw || null;
@@ -592,6 +597,15 @@ export class AnthropicProvider {
592
597
  continue;
593
598
  }
594
599
  const classifier = _classifyMidstreamError(err, midState);
600
+ if (classifier === 'stream_stalled' && !stallRetryBudget.allowStallRetry()) {
601
+ try {
602
+ process.stderr.write(
603
+ `[${this.name}] stall retry budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first stall) — surfacing for fresh-request retry\n`,
604
+ );
605
+ } catch {}
606
+ try { streamController.abort?.(err); } catch {}
607
+ throw err;
608
+ }
595
609
  if (classifier && attemptIndex < MAX_MIDSTREAM_RETRIES) {
596
610
  firstAttemptError = err;
597
611
  firstAttemptClassifier = classifier;
@@ -36,9 +36,11 @@ import {
36
36
  classifyHandshakeError,
37
37
  classifyMidstreamError,
38
38
  createStreamSafetyStamps,
39
+ createStallRetryBudget,
39
40
  jitterDelayMs,
40
41
  MIDSTREAM_RETRY_POLICY,
41
42
  sleepWithAbort,
43
+ STREAM_STALL_RETRY_BUDGET_MS,
42
44
  } from './retry-classifier.mjs';
43
45
  import { stampStreamOutcome, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs';
44
46
  import {
@@ -459,6 +461,9 @@ export async function sendViaWebSocket({
459
461
  const MAX_MIDSTREAM_RETRIES = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT;
460
462
  let firstAttemptError = null;
461
463
  let firstAttemptClassifier = null;
464
+ // Send-scoped stall window: in-place stall retries share one wall clock
465
+ // starting at the first stall (see createStallRetryBudget).
466
+ const stallRetryBudget = createStallRetryBudget();
462
467
  // A generate:false prewarm is billable even if its main request later
463
468
  // retries on a fresh socket or falls back to HTTP. Retain one completed
464
469
  // result across the whole logical send and attach it to terminal errors.
@@ -1006,6 +1011,11 @@ export async function sendViaWebSocket({
1006
1011
  const classifier = err?.unsafeToRetry === true
1007
1012
  ? null
1008
1013
  : _classifyMidstreamError(err, midState);
1014
+ if (classifier === 'stream_stalled' && !stallRetryBudget.allowStallRetry()) {
1015
+ try { process.stderr.write(`[openai-oauth] stall retry budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first stall) — surfacing for fresh-request retry\n`); } catch {}
1016
+ emitSendSpan('error');
1017
+ throw _stampTool(_stampLiveText(err));
1018
+ }
1009
1019
  const retryLimit = classifier ? _midstreamRetryLimit(classifier) : 0;
1010
1020
  if (classifier && attemptIndex < retryLimit) {
1011
1021
  // Retry-eligible: stash the first-attempt error, emit progress,
@@ -314,6 +314,41 @@ export function jitterDelayMs(ms, ratio = PROVIDER_RETRY_JITTER_RATIO, mode = 's
314
314
  return Math.max(0, Math.round(base + offset))
315
315
  }
316
316
 
317
+ // ── Stall-retry wall-clock budget (send-scoped) ──────────────────────────────
318
+ // Mid-stream 'stream_stalled' recoveries retry in place, which is right for a
319
+ // one-off blip but lets a chronically dying stream burn a whole task budget
320
+ // slowly (observed live: one send stretched 149s→298s→556s across stall
321
+ // retries before the agent deadline killed the task). Reference stacks bound
322
+ // this instead of retrying forever: Claude Code caps each request at ~300s
323
+ // wall clock (API_TIMEOUT_MS) and Codex kills a stream after one 300s silent
324
+ // gap (stream_idle_timeout). This guard is the equivalent for our in-place
325
+ // recovery: the clock starts at the FIRST stall of a send, and stall-classified
326
+ // retries are allowed only inside that window; past it the stall error
327
+ // surfaces so loop-level transport retry issues a FRESH request. Healthy
328
+ // streams never consult the clock (no stall → no budget reads), so long
329
+ // thinking/output can never trip it.
330
+ export const STREAM_STALL_RETRY_BUDGET_MS = (() => {
331
+ const v = Number(process.env.MIXDOG_STREAM_STALL_BUDGET_MS)
332
+ return Number.isFinite(v) && v > 0 ? Math.floor(v) : 300_000
333
+ })()
334
+
335
+ // One instance per provider send() call (NOT per attempt — the whole point is
336
+ // bounding the cross-attempt stall window). `now` is injectable for tests.
337
+ export function createStallRetryBudget(budgetMs = STREAM_STALL_RETRY_BUDGET_MS, now = Date.now) {
338
+ let firstStallAt = 0
339
+ return {
340
+ // Record a stall-classified retry candidate. Returns true while the
341
+ // send's stall window still has budget; false once exhausted (the caller
342
+ // surfaces the error instead of retrying in place).
343
+ allowStallRetry() {
344
+ const t = now()
345
+ if (!firstStallAt) firstStallAt = t
346
+ return (t - firstStallAt) <= budgetMs
347
+ },
348
+ get firstStallAt() { return firstStallAt },
349
+ }
350
+ }
351
+
317
352
  // ── Shared network-resilience interface ──────────────────────────────────────
318
353
  // One home for the logic shared across providers: mid-stream classifier
319
354
  // (WS + SSE), transport fallback predicate, stream-safety stamp latches,
@@ -444,6 +444,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
444
444
  Math.floor(maxLoopIterations * 0.9),
445
445
  ];
446
446
  while (true) {
447
+ const _iterT0 = Date.now();
447
448
  throwIfAborted();
448
449
  if (iterations >= maxLoopIterations) {
449
450
  // Final-answer turn: instead of breaking mid-transcript (which
@@ -642,6 +643,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
642
643
  transportRetriesUsed: _transportRetriesUsed, signal,
643
644
  }),
644
645
  );
646
+ const _sendEndedAt = Date.now();
645
647
  if (_sendResult.action === 'retry') {
646
648
  delete opts.cacheBreakIntent;
647
649
  contextOverflowRetryUsed = true;
@@ -1038,6 +1040,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1038
1040
  continue;
1039
1041
  }
1040
1042
  try { opts.onToolPhaseStarted?.(); } catch {}
1043
+ const _toolsT0 = Date.now();
1041
1044
  ({ dedupStubTotal: _dedupStubTotal, editCount: _editCount } = await processToolBatch({
1042
1045
  calls: _callsToExecute, messages, tools, cwd, sessionId, sessionRef, signal, opts,
1043
1046
  iterations, assistantTurnMsg: _assistantTurnMsg,
@@ -1050,6 +1053,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1050
1053
  }));
1051
1054
  // Settle the stop hook on the batch that actually executed.
1052
1055
  _toolFailureStopHook.endBatch(_callsToExecute);
1056
+ // Loop-phase timing (diagnostics): where non-model time goes per
1057
+ // iteration — presend (repair/compact/snapshot), send (provider
1058
+ // round-trip incl. streaming), tools (batch execution). Gated by the
1059
+ // same env as [turn-timing] so bench runs opt in via -AgentEnv.
1060
+ if (process.env.MIXDOG_TURN_TIMING === '1') {
1061
+ try {
1062
+ process.stderr.write(`[loop-timing] iter=${nextIteration} presend=${sendStartedAt - _iterT0}ms send=${_sendEndedAt - sendStartedAt}ms tools=${Date.now() - _toolsT0}ms calls=${_callsToExecute.length}\n`);
1063
+ } catch { /* diagnostics only */ }
1064
+ }
1053
1065
  _toolBatchJustCompleted = true;
1054
1066
  _continuationsSinceToolBatch = 0;
1055
1067
  _lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
@@ -14,6 +14,8 @@
14
14
 
15
15
  export const STOP_HOOK_SOURCE = 'tool-failure-stop-hook';
16
16
 
17
+ import { isInformationalShellExitOne } from '../result-classification.mjs';
18
+
17
19
  // Only a genuinely EXECUTED result resolves a failure, i.e. kind 'normal'.
18
20
  // Cache hits ('cache-hit' / 'scoped-cache-hit') replay an earlier result
19
21
  // without running anything, and dedup/guard skips ('skipped') execute nothing
@@ -59,6 +61,13 @@ export function createToolFailureStopHook() {
59
61
  // nothing was dispatched — they must not arm the hook.
60
62
  if (message.guardSkip === true) return;
61
63
  if (message.toolKind === 'error') {
64
+ // Informational exit-1 probes (grep-family no-match inside a
65
+ // compound command: useful stdout, blank stderr) stay 'error'
66
+ // for display/history, but blocking the terminal message over
67
+ // them forces a pointless re-verify turn — observed live
68
+ // (kv-store-grpc: /proc PID scan exit 1 → hook misfire, +2
69
+ // turns). Neutral here: neither arms nor clears.
70
+ if (isInformationalShellExitOne(message.content)) return;
62
71
  batchFailure = true;
63
72
  if (message.toolCallId) failedCallIds.add(message.toolCallId);
64
73
  } else if (EXECUTED_SUCCESS_TOOL_KINDS.has(message.toolKind)) {
@@ -68,3 +68,31 @@ export function classifyResultKind(result, explicitSuccess = false) {
68
68
  }
69
69
  return 'normal';
70
70
  }
71
+
72
+ /**
73
+ * Informational shell exit-1: `Error: [shell-run-failed] [exit code: 1]`
74
+ * with a non-empty stdout body and NO stderr evidence (neither an inline
75
+ * `[stderr]` block nor a `[stderr: path]` spill). grep-family "no match"
76
+ * semantics inside compound probes (loops, `;`-chains, substitutions) land
77
+ * exactly here: the run produced useful output, wrote nothing to stderr,
78
+ * and exited 1 only because the final stage matched nothing. The static
79
+ * single-pipeline gate (bash-tool _isBenignSearchExitOne) deliberately
80
+ * refuses these ambiguous shapes, so the result stays toolKind 'error' —
81
+ * consumers that must not overreact to an informational failure (the turn
82
+ * stop hook) test this signature instead of reclassifying the result.
83
+ * Signals, timeouts, and other exit codes carry different status markers
84
+ * and never match; a destructive-warning prefix also disqualifies.
85
+ *
86
+ * @param {unknown} result
87
+ * @returns {boolean}
88
+ */
89
+ export function isInformationalShellExitOne(result) {
90
+ if (typeof result !== 'string') return false;
91
+ const trimmed = result.trimStart();
92
+ const header = /^error:\s*\[shell-run-failed\]\s*\[exit code: 1\]\s*\n/i.exec(trimmed);
93
+ if (!header) return false;
94
+ const payload = trimmed.slice(header[0].length).trim();
95
+ if (!payload || payload === '(no output)') return false;
96
+ if (payload.startsWith('[stderr') || payload.includes('\n[stderr')) return false;
97
+ return true;
98
+ }
@@ -126,6 +126,36 @@ export async function sendWithRecovery(ctx) {
126
126
  // Every branch below consumes it instead of re-inferring safety
127
127
  // from provider-specific flags.
128
128
  const outcome = readStreamOutcome(sendErr, relayWitness);
129
+ // Text-only exposure retraction (cross-provider): a stream that
130
+ // died after relaying ONLY text — no dispatched or complete tool
131
+ // calls, no terminal — is replayable IF the ask owner retracts the
132
+ // exposed characters (onTextReset ack === true: the TUI truncates
133
+ // its live tail, the bench driver truncates its accumulator).
134
+ // This is the loop-level analogue of anthropic's
135
+ // recoverNonStreaming for providers WITHOUT a non-streaming
136
+ // fallback (gemini, openai-compat, openai WS) and for stalls that
137
+ // outlived the provider's in-place recovery. Observed live:
138
+ // make-mips-interpreter died with 31 exposed chars + a pending
139
+ // never-dispatched tool input and burned the whole trial.
140
+ const retractExposedTextForReplay = async () => {
141
+ if (outcome.terminalObserved === true) return false;
142
+ if (outcome.sideEffectDispatched === true) return false;
143
+ if (outcome.dispatchAmbiguous === true) return false;
144
+ if (Number(outcome.toolCallsDispatched) > 0) return false;
145
+ if (Number(outcome.toolCallsComplete) > 0) return false;
146
+ if (relayWitness.toolCallsDispatched > 0) return false;
147
+ if (typeof opts?.onTextReset !== 'function') return false;
148
+ const chars = Math.max(0, Number(outcome.textObservedChars) || 0)
149
+ || (typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0);
150
+ if (chars <= 0) return false;
151
+ let acked = false;
152
+ try {
153
+ acked = await opts.onTextReset({ chars, reason: 'loop-transport-retraction' }) === true;
154
+ } catch { acked = false; }
155
+ if (!acked) return false;
156
+ relayWitness.textEmitted = false;
157
+ return true;
158
+ };
129
159
  // Gemini REST/SDK reports MAX_TOKENS by throwing a typed
130
160
  // ProviderIncompleteError after preserving the streamed candidate.
131
161
  // Normalize only that exact, safe no-tool output-limit shape into a
@@ -177,6 +207,36 @@ export async function sendWithRecovery(ctx) {
177
207
  && sendErr.partialContent.trim().length > 0
178
208
  && outcome.toolCallsComplete === 0
179
209
  ) {
210
+ // Retractable shape: text-only exposure with the owner's
211
+ // acknowledgement replays on a fresh request instead of
212
+ // failing the turn. Non-acked (or tool-bearing) shapes keep
213
+ // the explicit-failure contract below unchanged.
214
+ if (
215
+ transportRetriesUsed < TRANSPORT_RETRY_MAX
216
+ && classifyError(sendErr) === 'transient'
217
+ && await retractExposedTextForReplay()
218
+ ) {
219
+ const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
220
+ try {
221
+ process.stderr.write(
222
+ `[loop] exposed-text stall retracted (sess=${sessionId || 'unknown'} `
223
+ + `iter=${nextIteration} len=${sendErr.partialContent.length}); `
224
+ + `transport retry ${transportRetriesUsed + 1}/${TRANSPORT_RETRY_MAX} after ${waitMs}ms\n`,
225
+ );
226
+ } catch { /* best-effort */ }
227
+ try {
228
+ appendAgentTrace({
229
+ kind: 'exposed_text_retraction_retry',
230
+ sessionId: sessionId || null,
231
+ iteration: nextIteration,
232
+ attempt: transportRetriesUsed + 1,
233
+ waitMs,
234
+ partialContentLen: sendErr.partialContent.length,
235
+ });
236
+ } catch { /* best-effort */ }
237
+ await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
238
+ return { action: 'retry_transport' };
239
+ }
180
240
  try {
181
241
  process.stderr.write(
182
242
  `[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
@@ -265,8 +325,8 @@ export async function sendWithRecovery(ctx) {
265
325
  // send after a bounded wait instead of failing the turn.
266
326
  if (
267
327
  transportRetriesUsed < TRANSPORT_RETRY_MAX
268
- && outcome.replaySafe === true
269
328
  && classifyError(sendErr) === 'transient'
329
+ && (outcome.replaySafe === true || await retractExposedTextForReplay())
270
330
  ) {
271
331
  const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
272
332
  try {
@@ -206,6 +206,11 @@ export async function executeTaskTool(args, options = {}) {
206
206
  // consumed synchronously, so no re-arm. Drop the persisted ctx here
207
207
  // or it leaks (cleanup only runs on a real watcher settle, which
208
208
  // never happens for a never-re-armed entry).
209
+ // EXCEPT when this wait's tool call was aborted: its result was
210
+ // discarded, so nobody consumed the outcome — re-arm so the
211
+ // watcher delivers the completion notification instead of
212
+ // swallowing it.
213
+ else if (options?.signal?.aborted) watchBackgroundShellJob(taskId);
209
214
  else clearShellJobNotifyCtx(taskId);
210
215
  }
211
216
  }
@@ -5,11 +5,8 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import { clean } from './session-text.mjs';
6
6
  import { readJsonSafe } from './fs-utils.mjs';
7
7
 
8
- const OUTPUT_STYLE_ORDER = ['simple', 'detailed', 'minimal', 'extreme-minimal'];
8
+ const OUTPUT_STYLE_ORDER = ['detailed', 'simple', 'minimal', 'extreme-minimal'];
9
9
  const OUTPUT_STYLE_ALIASES = new Map([
10
- ['compact', 'simple'],
11
- ['normal', 'simple'],
12
- ['detail', 'detailed'],
13
10
  ['extreme', 'extreme-minimal'],
14
11
  ['extremesimple', 'extreme-minimal'],
15
12
  ['extreme-simple', 'extreme-minimal'],
@@ -16,12 +16,18 @@ export const FIXED_AGENT_SLOTS = Object.freeze([
16
16
  // (user: 창이 크지 않으니 설명은 짧게).
17
17
  { id: 'explore', label: 'Explore', description: 'Repository exploration', workflowSlot: 'explorer' },
18
18
  { id: 'maintainer', label: 'Maintainer', description: 'Memory and upkeep', workflowSlot: 'memory' },
19
- { id: 'worker', label: 'Worker', description: 'Scoped implementation' },
20
- { id: 'heavy-worker', label: 'Heavy Worker', description: 'Multi-file implementation' },
19
+ { id: 'worker', label: 'Worker', description: 'Simple tasks' },
20
+ { id: 'heavy-worker', label: 'Heavy Worker', description: 'Complex tasks' },
21
21
  { id: 'reviewer', label: 'Reviewer', description: 'Diff and risk review' },
22
22
  { id: 'debugger', label: 'Debugger', description: 'Root-cause debugging' },
23
23
  ]);
24
24
  const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
25
+ // Slot-backed built-ins (explore/maintainer) run through their own dedicated
26
+ // channels — the explore tool and the memory cycle — so they are never
27
+ // Lead-delegation targets and stay out of the Available Agents catalog.
28
+ const BUILTIN_SLOT_AGENT_IDS = new Set(
29
+ FIXED_AGENT_SLOTS.filter((agent) => agent.workflowSlot).map((agent) => agent.id),
30
+ );
25
31
  export const DEFAULT_WORKFLOW_ID = 'default';
26
32
 
27
33
  const SEARCH_CAPABLE_PROVIDERS = new Set([
@@ -282,16 +288,20 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
282
288
  lines.push(pack.body);
283
289
  // A hand-edited pack may name a hidden role in its `agents:` frontmatter;
284
290
  // internal roles are never delegatable, so they never enter the catalog.
291
+ // Slot-backed built-ins are equally non-delegatable (they ride the explore
292
+ // tool / memory cycle), so they are filtered even when a pack names them.
285
293
  const agentIds = (pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id))
286
- .filter((id) => !isHiddenAgent(id));
294
+ .filter((id) => !isHiddenAgent(id) && !BUILTIN_SLOT_AGENT_IDS.has(id));
287
295
  const agentBlocks = agentIds.map((id) => loadAgentDefinition(dir, id)).filter(Boolean);
288
296
  if (agentBlocks.length) {
289
297
  lines.push('# Available Agents');
290
- for (const agent of agentBlocks) {
291
- lines.push(`## ${agent.name} (${agent.id})`);
292
- if (agent.description) lines.push(agent.description);
293
- lines.push(agent.body);
294
- }
298
+ // Name + description only: the AGENT.md body is the worker's own system
299
+ // prompt and rides in the worker session at spawn time — repeating it in
300
+ // the Lead prompt only bloats context. Lead picks agents by description
301
+ // (Claude Code whenToUse pattern); the workflow body carries the rules.
302
+ lines.push(agentBlocks
303
+ .map((agent) => `- ${agent.name} (${agent.id})${agent.description ? `: ${agent.description}` : ''}`)
304
+ .join('\n'));
295
305
  }
296
306
  return lines.join('\n\n');
297
307
  }
@@ -26573,6 +26573,7 @@ function drainTuiSteeringPersist(leadSessionId) {
26573
26573
  return _serialize(async () => {
26574
26574
  let drained = [];
26575
26575
  let droppedStale = 0;
26576
+ let prunedOrphans = 0;
26576
26577
  try {
26577
26578
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
26578
26579
  const next = normalizePendingStore(raw);
@@ -26586,7 +26587,21 @@ function drainTuiSteeringPersist(leadSessionId) {
26586
26587
  return !stale;
26587
26588
  });
26588
26589
  drained = fresh.map(drainedRowToRestore).filter(Boolean);
26589
- if (drained.length === 0 && droppedStale === 0) return void 0;
26590
+ for (const otherKey of Object.keys(next.sessions)) {
26591
+ if (otherKey === key) continue;
26592
+ const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
26593
+ const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
26594
+ const allStale = rows.every((row) => {
26595
+ const at = Number(row?.at) || otherTouched;
26596
+ return at > 0 && now - at > STALE_STEERING_RESTORE_TTL_MS;
26597
+ });
26598
+ if (rows.length === 0 || allStale) {
26599
+ delete next.sessions[otherKey];
26600
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
26601
+ prunedOrphans += rows.length;
26602
+ }
26603
+ }
26604
+ if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return void 0;
26590
26605
  delete next.sessions[key];
26591
26606
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
26592
26607
  next.updatedAt = Date.now();
@@ -26602,6 +26617,13 @@ function drainTuiSteeringPersist(leadSessionId) {
26602
26617
  if (droppedStale > 0) {
26603
26618
  try {
26604
26619
  process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}
26620
+ `);
26621
+ } catch {
26622
+ }
26623
+ }
26624
+ if (prunedOrphans > 0) {
26625
+ try {
26626
+ process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions
26605
26627
  `);
26606
26628
  } catch {
26607
26629
  }
@@ -30212,6 +30234,15 @@ function createEngineApiA(bag) {
30212
30234
  restoreState.requeueEntries = [];
30213
30235
  restoreState.discardExecutionPendingResumeKeys = [];
30214
30236
  }
30237
+ const pendingAfterAbortKick = setTimeout(() => {
30238
+ try {
30239
+ if (flags.disposed) return;
30240
+ if (getState().busy) return;
30241
+ if (pending.length > 0 && typeof drain === "function") void drain();
30242
+ } catch {
30243
+ }
30244
+ }, 150);
30245
+ pendingAfterAbortKick.unref?.();
30215
30246
  const abortEpoch = flags.leadTurnEpoch;
30216
30247
  const recoveryMs = Number(flags.manualAbortRecoveryMs) > 0 ? Number(flags.manualAbortRecoveryMs) : MANUAL_ABORT_RECOVERY_MS;
30217
30248
  const recoveryTimer = setTimeout(() => {
@@ -616,6 +616,23 @@ export function createEngineApiA(bag) {
616
616
  // ── Bounded manual-abort recovery ───────────────────────────────────
617
617
  // runtime.abort() above normally rejects the in-flight runtime.ask() so
618
618
  // the turn's own finally clears busy within a tick. If that unwind is
619
+ // starved the recovery timer below hard-releases busy. Separately from
620
+ // recovery: queued work must never strand behind a cancelled turn
621
+ // (Codex parity — abort preserves pending input for the next turn; CC
622
+ // parity — the command queue survives cancel and fires when idle). The
623
+ // drain loop that owns a normal turn continues on its own; this bounded
624
+ // kick covers unwinds where no drain owner re-checks pending after busy
625
+ // clears. drain() self-guards (busy/draining/commandBusy), so the
626
+ // drain-owned path is unchanged and a duplicate kick is a no-op.
627
+ const pendingAfterAbortKick = setTimeout(() => {
628
+ try {
629
+ if (flags.disposed) return;
630
+ if (getState().busy) return;
631
+ if (pending.length > 0 && typeof drain === 'function') void drain();
632
+ } catch { /* best-effort */ }
633
+ }, 150);
634
+ pendingAfterAbortKick.unref?.();
635
+ // If that unwind is
619
636
  // starved — e.g. a provider abort that never settles after a post-tool
620
637
  // fetch stall — busy would stay true until the far-larger turn watchdog
621
638
  // trips, wedging the TUI with dead input. Arm a short grace timer that
@@ -195,6 +195,7 @@ export function drainTuiSteeringPersist(leadSessionId) {
195
195
  return _serialize(async () => {
196
196
  let drained = [];
197
197
  let droppedStale = 0;
198
+ let prunedOrphans = 0;
198
199
  try {
199
200
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
200
201
  const next = normalizePendingStore(raw);
@@ -210,7 +211,26 @@ export function drainTuiSteeringPersist(leadSessionId) {
210
211
  return !stale;
211
212
  });
212
213
  drained = fresh.map(drainedRowToRestore).filter(Boolean);
213
- if (drained.length === 0 && droppedStale === 0) return undefined;
214
+ // Orphan cleanup: buckets of OTHER sessions whose every row already
215
+ // aged past the restore TTL can never be restored (restore is keyed
216
+ // by the live session id), so they only grow the file forever
217
+ // (observed live: queued rows from sessions closed days ago). Prune
218
+ // them under the same lock/write.
219
+ for (const otherKey of Object.keys(next.sessions)) {
220
+ if (otherKey === key) continue;
221
+ const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
222
+ const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
223
+ const allStale = rows.every((row) => {
224
+ const at = Number(row?.at) || otherTouched;
225
+ return at > 0 && (now - at) > STALE_STEERING_RESTORE_TTL_MS;
226
+ });
227
+ if (rows.length === 0 || allStale) {
228
+ delete next.sessions[otherKey];
229
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
230
+ prunedOrphans += rows.length;
231
+ }
232
+ }
233
+ if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return undefined;
214
234
  delete next.sessions[key];
215
235
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
216
236
  next.updatedAt = Date.now();
@@ -222,6 +242,9 @@ export function drainTuiSteeringPersist(leadSessionId) {
222
242
  if (droppedStale > 0) {
223
243
  try { process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}\n`); } catch {}
224
244
  }
245
+ if (prunedOrphans > 0) {
246
+ try { process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions\n`); } catch {}
247
+ }
225
248
  return drained;
226
249
  });
227
250
  }
@@ -2,7 +2,7 @@
2
2
  id: default
3
3
  name: Cowork
4
4
  description: "Parallel delegation."
5
- agents: worker, heavy-worker, reviewer, debugger, maintainer
5
+ agents: worker, heavy-worker, reviewer, debugger
6
6
  ---
7
7
 
8
8
  # Cowork
@@ -13,23 +13,12 @@ investigation and planning — no edits, no state mutation, no delegation.
13
13
  A new or changed request resets planning; a scope change requires fresh
14
14
  approval.
15
15
 
16
- On approval, fan out at maximum width: one agent per independent scope, all
17
- spawned in one turn; only a scope that depends on another's output waits.
18
- Split the plan into as many scopes as possible: disjoint file/module sets
19
- are independent; merge only on a true output dependency. Prefer parallel
20
- scopes over sequential slices in one agent. Brief each agent per the Lead
21
- Brief contract.
22
-
23
- Route by complexity: simple, well-understood implementation goes to Worker;
24
- complex or investigative implementation goes to Heavy Worker; Lead itself
25
- edits only a local, one-turn configuration/git change. Debugger only on a
26
- defect needing deep root-cause analysis or a bug surviving 2+ review/fix
27
- cycles.
28
-
29
- Every implementation gets its own Reviewer, attached per scope — only the
30
- local Lead-direct edits above are exempt. Keep the same reviewer through the
31
- fix loop and repeat fix -> re-verify until clean; Lead cross-verifies in
32
- parallel with the Reviewer.
16
+ On approval, delegate maximally: one agent per independent scope, fit to the
17
+ situation, all spawned in one turn; only a scope that depends on another's
18
+ output waits. Split the plan into as many scopes as possible: disjoint
19
+ file/module sets are independent; merge only on a true output dependency.
20
+ Prefer parallel scopes over sequential slices in one agent. Brief each agent
21
+ per the Lead Brief contract.
33
22
 
34
23
  Report the verified result against the approved plan. Build, deploy, commit,
35
24
  and push happen only on an explicit user request.