mixdog 0.9.111 → 0.9.113

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.
Files changed (31) hide show
  1. package/package.json +10 -58
  2. package/src/rules/shared/01-tool.md +15 -6
  3. package/src/runtime/agent/orchestrator/config.mjs +0 -1
  4. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +6 -0
  5. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +3 -1
  6. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +11 -1
  7. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
  8. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +74 -3
  9. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +26 -7
  10. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -1
  11. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  12. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -5
  13. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +7 -7
  14. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +14 -30
  15. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +3 -3
  18. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +2 -2
  19. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +40 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +0 -1
  21. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  22. package/src/runtime/shared/agent-route-config.mjs +0 -5
  23. package/src/session-runtime/settings-api.mjs +9 -1
  24. package/src/session-runtime/tool-defs.mjs +2 -2
  25. package/src/standalone/daemon.mjs +1 -0
  26. package/src/standalone/session-protocol.mjs +1 -0
  27. package/src/tui/session/session-api.mjs +2 -1
  28. package/src/tui/session/session-flow.mjs +25 -1
  29. package/src/ui/statusline-segments.mjs +20 -5
  30. package/scripts/run-suite.mjs +0 -101
  31. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +0 -188
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.111",
3
+ "version": "0.9.113",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -48,72 +48,24 @@
48
48
  "prepublishOnly": "node -e \"if(!process.env.CI){console.error('local npm publish is disabled — run npm run release:patch');process.exit(1)}\"",
49
49
  "start": "node src/cli.mjs",
50
50
  "smoke": "node scripts/smoke.mjs",
51
- "test:contract": "node scripts/run-suite.mjs contract",
52
- "smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:patch && npm run smoke:output && npm run smoke:tui && npm run smoke:live-worker",
51
+ "smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:patch && npm run smoke:compact",
53
52
  "smoke:boot": "node scripts/boot-smoke.mjs",
54
53
  "smoke:compact": "node scripts/compact-smoke.mjs",
55
- "test:compact": "node --test scripts/compact-active-turn-test.mjs scripts/compact-file-reattach-test.mjs scripts/compact-prior-context-flatten-test.mjs scripts/compact-recall-digest-test.mjs scripts/compact-pressure-test.mjs",
56
- "test:context": "node --test scripts/context-mcp-metering-test.mjs scripts/v4a-context-miss-excerpt-test.mjs",
54
+ "test:compact": "node --test scripts/suite-compact-test.mjs",
57
55
  "smoke:loop": "node scripts/smoke-loop.mjs",
58
56
  "smoke:loop:final": "node scripts/smoke-loop-report.mjs --require-complete --min-elapsed 5h --min-iterations 400 --max-gap 60s --max-smoke-ms 10000 --max-avg-smoke-ms 8500 --max-step-ms smoke.mjs=4000 --max-step-ms boot-smoke.mjs=8000 --max-rss-mb 140 --max-rss-growth-mb 50",
59
57
  "smoke:loop:report": "node scripts/smoke-loop-report.mjs",
60
58
  "test:tool-contracts": "node scripts/tool-smoke.mjs",
61
59
  "smoke:patch": "node scripts/apply-patch-edit-smoke.mjs",
62
- "smoke:patch-replay": "node scripts/apply-patch-replay-smoke.mjs",
63
- "smoke:tool-rescue": "node scripts/tool-rescue-smoke.mjs",
64
- "smoke:output": "node scripts/output-style-smoke.mjs",
65
- "smoke:tui": "node scripts/build-tui.mjs && node scripts/tui-render-smoke.mjs && npm run test:tui-input-render && npm run test:tui-streaming-window && npm run test:tui-queue",
66
- "smoke:freevars": "node scripts/freevar-smoke.mjs",
67
- "smoke:logguard": "node scripts/log-writer-guard-smoke.mjs",
68
- "smoke:live-worker": "node scripts/live-worker-smoke.mjs",
69
- "smoke:agent-tag-reuse": "node scripts/agent-tag-reuse-smoke.mjs",
70
- "test:agent-terminal-reap": "node scripts/agent-terminal-reap-test.mjs",
71
- "test:agent-job-views": "node --test scripts/agent-job-terminal-view-test.mjs",
72
- "test:agent-fanout": "node scripts/agent-parallel-smoke.mjs && node --test scripts/agent-route-batch-test.mjs scripts/execution-completion-dedup-test.mjs",
73
- "test:toolcall": "node --test scripts/toolcall-args-test.mjs",
74
- "test:tool-batching": "node --test scripts/eager-patch-shell-order-test.mjs scripts/session-bench-batching-test.mjs scripts/tool-cwd-rebind-test.mjs",
75
- "test:shipmode": "node --test scripts/ship-mode-test.mjs",
76
- "test:shellhardening": "node --test scripts/shell-hardening-test.mjs scripts/shell-failure-diagnostics-test.mjs scripts/windows-hide-spawn-options-test.mjs scripts/shell-job-session-scope-test.mjs",
77
- "test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
78
- "test:providers": "node --test scripts/provider-toolcall-test.mjs scripts/provider-contract-test.mjs scripts/provider-stream-stall-test.mjs scripts/provider-stream-outcome-test.mjs scripts/stream-frame-fault-matrix-test.mjs scripts/gemini-provider-test.mjs scripts/anthropic-transport-policy-test.mjs scripts/anthropic-native-block-replay-test.mjs scripts/openai-oauth-ws-1006-retry-test.mjs scripts/stream-close-retry-test.mjs scripts/openai-end-turn-signal-test.mjs",
79
- "test:provider-admission": "node --test scripts/provider-admission-scheduler-test.mjs scripts/provider-stream-json-test.mjs",
80
- "test:resource-admission": "node --test scripts/resource-admission-test.mjs scripts/child-spawn-isolation-test.mjs scripts/owner-fair-gate-test.mjs",
81
- "test:deferred-tools": "node --test scripts/deferred-tool-loading-test.mjs",
82
- "test:anthropic-oauth-race": "node --test scripts/anthropic-oauth-refresh-race-test.mjs",
83
- "test:grok-oauth-race": "node --test scripts/grok-oauth-refresh-race-test.mjs",
84
- "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
85
- "test:keychain": "node --test scripts/keychain-prewarm-test.mjs",
86
- "test:memory-leaks": "node --expose-gc --test scripts/memory-retention-test.mjs scripts/memory-worker-stability-test.mjs scripts/session-transport-test.mjs apps/desktop/src/renderer/memory-retention.test.mjs",
87
- "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/maintenance-default-routes-test.mjs scripts/embedding-worker-exit-test.mjs scripts/embedding-runtime-prune-test.mjs",
88
- "test:embedding-runtime": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs",
89
- "test:embedding-runtime:core": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs --core",
60
+ "test:shellhardening": "node --test scripts/suite-shellhardening-test.mjs",
61
+ "test:providers": "node --test scripts/provider-toolcall-test.mjs scripts/provider-contract-test.mjs scripts/provider-stream-outcome-test.mjs",
90
62
  "test:embedding-runtime:warmup": "node scripts/verify-embedding-runtime.mjs --warmup",
91
- "prepare:code-graph-test": "node scripts/prepare-code-graph-test.mjs",
92
- "test:code-graph-dispatch": "node --test scripts/code-graph-dispatch-test.mjs",
93
- "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/message-rewind-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs scripts/pending-stale-injection-test.mjs",
94
- "test:tui-input-render": "node --test scripts/prompt-immediate-render-test.mjs scripts/prompt-key-parity-test.mjs",
95
- "test:tui-streaming-window": "node --test scripts/streaming-tail-window-test.mjs scripts/tui-store-frame-batch-test.mjs && node scripts/tui-transcript-jitter-harness.mjs",
96
- "test:tui-ambiguous-width": "node --test scripts/tui-ambiguous-width-test.mjs",
97
- "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --check scripts/deploy-workflow-test.mjs && node --check scripts/release-version-discipline-test.mjs && node --test scripts/verify-release-assets-test.mjs scripts/deploy-workflow-test.mjs scripts/release-version-discipline-test.mjs",
98
- "test:release-focused": "npm run test:release-focused:contracts && npm run test:release-focused:providers && npm run test:release-focused:compact && npm run test:release-focused:session",
99
- "test:release-focused:contracts": "npm run test:release-assets && npm run test:tool-contracts && npm run test:tool-batching && npm run test:placeholder && npm run smoke:patch && npm run test:patch-binary-cache",
100
- "test:release-focused:providers": "npm run test:providers && npm run test:deferred-tools && npm run test:compact && npm run test:context && npm run prepare:code-graph-test && node --test scripts/code-graph-root-federation-test.mjs scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs",
101
- "test:release-focused:compact": "npm run smoke:compact && npm run test:shellhardening",
102
- "test:release-focused:session": "npm run test:project-registry && npm run test:session && npm run test:workflow-editor && npm run test:embedding-runtime && node --test scripts/tui-transcript-perf-test.mjs",
103
- "test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
104
- "test:patch-binary-cache": "node --test scripts/patch-binary-cache-test.mjs",
105
- "test:patch-parity": "node --test scripts/v4a-parity-test.mjs",
106
- "test:project-registry": "node --test scripts/project-registry-isolation-test.mjs",
107
- "test:session-transport": "node --test scripts/fair-call-scheduler-test.mjs scripts/session-transport-test.mjs scripts/session-tui-parity-test.mjs scripts/session-recovery-test.mjs scripts/session-submit-delivery-test.mjs scripts/daemon-process-lifetime-test.mjs scripts/session-multipane-stress-test.mjs && node --test scripts/runtime-isolation-stress-test.mjs",
108
- "smoke:session": "node scripts/session-smoke.mjs",
109
- "test:session": "node --test scripts/runtime-turn-contract-test.mjs scripts/session-orphan-sweep-test.mjs scripts/interrupted-turn-history-test.mjs scripts/turn-checkpoint-crash-test.mjs scripts/turn-outcome-fault-matrix-test.mjs scripts/session-save-fault-store-test.mjs scripts/session-disk-authority-test.mjs scripts/session-load-cache-race-test.mjs scripts/agent-loop-complete-turn-test.mjs scripts/session-heartbeat-lifecycle-test.mjs scripts/remote-transition-order-test.mjs scripts/session-new-reset-test.mjs scripts/turn-ttft-critical-path-test.mjs",
110
- "test:live-canary": "node --test scripts/live-canary-test.mjs",
111
- "test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs scripts/channel-remote-format-test.mjs",
112
- "test:workflow-editor": "node --test scripts/workflow-id-test.mjs scripts/workflow-pack-editor-test.mjs",
113
- "test:route-scope": "node --test scripts/route-scope-isolation-test.mjs",
114
- "test:schedule-reload": "node --test scripts/schedule-reload-arm-test.mjs",
63
+ "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/release-gate-test.mjs && node --test scripts/release-gate-test.mjs",
64
+ "test:release-focused": "npm run test:release-critical && npm run test:compact && npm run test:shellhardening && npm run test:tool-contracts && npm run test:session && npm run test:session-transport",
65
+ "test:release-critical": "npm run test:release-assets && npm run smoke:patch && npm run test:providers",
66
+ "test:session-transport": "node --test scripts/session-transport-test.mjs",
67
+ "test:session": "node --test scripts/runtime-turn-contract-test.mjs scripts/session-save-fault-store-test.mjs",
115
68
  "test:media": "node --test src/runtime/media/store.test.mjs src/runtime/media/renditions.test.mjs src/runtime/media/adapters/codex-image.test.mjs",
116
- "test:shell-harness": "node --test scripts/shell-harness-regression-test.mjs",
117
69
  "failures": "node scripts/tool-failures.mjs",
118
70
  "trace:llm": "node scripts/llm-trace-summary.mjs",
119
71
  "diag:sessions": "node scripts/session-diag.mjs",
@@ -8,7 +8,8 @@
8
8
  known file/range→`read`;
9
9
  web/current→`search`; returned URL body→`web_fetch`; prior work→`recall`
10
10
  (history only, never current local state);
11
- durable compact English memory→`memory`; explicit project change→`cwd`;
11
+ durable compact English memory→`memory`; explicit Project change→`cwd`
12
+ (`shell.cwd` is call-local and never changes the Project);
12
13
  explicit user-requested conversation reset→`session_manage`.
13
14
  Use only named tools present in the current tool surface.
14
15
  - Act only on verified identities (cwd/project/user/tool-returned) — paths,
@@ -24,16 +25,23 @@
24
25
  another's output or can change another's inputs/state; otherwise
25
26
  serialize. Before each batch, deduplicate the facets still required by the request,
26
27
  route each once to the cheapest sufficient tool with all required
27
- variants/scopes, and launch every independent call together never
28
+ variants/scopes every distinct sample/format in the same batch
29
+ and launch every independent call together — never
28
30
  split or duplicate a facet across tools, mutate merely to widen
29
- retrieval, reserve known work, or cap fanout. Symbol relations end at
31
+ retrieval, reserve known work, or cap fanout. Guessed terms go wide
32
+ with batched fan-out; narrow a scope only on verified cues — returned
33
+ siblings/conventions or known literals. Mine each returned output for
34
+ every remaining facet before the next round. Symbol relations end at
30
35
  `code_graph`; values/locations end at the context grep returns; `read`
31
36
  covers only what returned spans cannot, as an anchored offset/limit
32
37
  window. The moment evidence determines the answer, edit, or deliverable,
33
38
  stop retrieving; patch if needed.
34
39
  - Once the edit or deliverable is determined, finish in one assistant turn:
35
- issue `apply_patch` calls serially, never in parallel; use one cohesive call
36
- with one file section per target, all patches first, then one
40
+ before `apply_patch`, obtain every target hunk's exact current content and
41
+ anchor from `grep`, `code_graph`, or `read`; never infer patch context from
42
+ another file, a sample, or expected text. Then issue `apply_patch` calls
43
+ serially, never in parallel; use one cohesive call with one file section per
44
+ target, all patches first, then one
37
45
  batched verification `shell` that runs the real required postconditions
38
46
  on every changed file and produced artifact, never echoes a claim;
39
47
  runtime waits for every patch and skips the shell
@@ -43,6 +51,7 @@
43
51
  (data/reports/derived values) come from `shell` computation, never
44
52
  hand-transcribed numbers. Earlier `shell` is only for runtime/state
45
53
  evidence unavailable to file tools—an independent facet, batched with
46
- the rest.
54
+ the rest; independent probes are parallel shell calls, never one
55
+ serial script per round.
47
56
  - A background `task_id` ends the turn; completion resumes work. Never poll;
48
57
  use task control only for recovery or a required blocking result.
@@ -339,7 +339,6 @@ function canonicalizeShellStorage(value) {
339
339
  function canonicalizeModulesStorage(value) {
340
340
  const modules = configObject(value);
341
341
  delete modules.memory;
342
- delete modules.explore;
343
342
  for (const name of ['search']) {
344
343
  if (!Object.prototype.hasOwnProperty.call(modules, name)) continue;
345
344
  const raw = modules[name];
@@ -107,6 +107,12 @@ function _anthropicSseError(event) {
107
107
  err.httpStatus = status;
108
108
  err.status = status;
109
109
  }
110
+ // Wire-error marker: an error type OUTSIDE the documented enumeration
111
+ // (no status resolved above) default-retries under the shared wire-error
112
+ // contract instead of failing the turn as 'unknown'. Typed statuses and
113
+ // the fatal-code deny-list still take precedence in classifyError().
114
+ err.providerWireError = true;
115
+ if (typeof type === 'string' && type && type !== 'error') err.providerErrorCode = type;
110
116
  return err;
111
117
  }
112
118
 
@@ -841,7 +841,8 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
841
841
  // Copy the TYPED failure evidence a Responses `response.failed` / `error`
842
842
  // event carries (numeric HTTP status, provider error code/type) onto the
843
843
  // thrown error. Message text is never parsed, and nothing is synthesized when
844
- // the event declares no typed status.
844
+ // the event declares no typed status. The wire-event marker routes the error
845
+ // through the fatal-code deny-list / default-retry classification.
845
846
  function _applyTypedResponsesFailure(err, event) {
846
847
  const detail = event?.response?.error || event?.error || null;
847
848
  const typed = typedStatusFrom(detail, event);
@@ -849,6 +850,7 @@ function _applyTypedResponsesFailure(err, event) {
849
850
  const code = detail?.code ?? detail?.type ?? event?.code ?? null;
850
851
  if (code != null && code !== '') err.providerErrorCode = String(code);
851
852
  if (detail) err.providerError = detail;
853
+ err.providerWireError = true;
852
854
  return err;
853
855
  }
854
856
 
@@ -959,9 +959,15 @@ export async function sendViaHttpSse({
959
959
  case 'response.failed': {
960
960
  const msg = event.response?.error?.message || event.error?.message || event.message || 'response.failed';
961
961
  const err = new Error(`OpenAI OAuth HTTP fallback response.failed: ${msg}`);
962
- // Typed status only — a text-only failure stays unclassified.
962
+ // Typed status only — nothing is synthesized from text. The
963
+ // frame itself is preserved so the wire-error default-retry
964
+ // classification applies (fatal codes stay terminal).
965
+ err.responseFailed = event;
963
966
  const typed = typedStatusFrom(event.response?.error, event.error, event);
964
967
  if (typed) err.httpStatus = typed;
968
+ const detail = event.response?.error || event.error || null;
969
+ const code = detail?.code ?? detail?.type ?? null;
970
+ if (typeof code === 'string' && code) err.providerErrorCode = code;
965
971
  throw err;
966
972
  }
967
973
  case 'response.incomplete': {
@@ -987,8 +993,12 @@ export async function sendViaHttpSse({
987
993
  case 'error': {
988
994
  const msg = event.message || event.error?.message || 'unknown';
989
995
  const err = new Error(`OpenAI OAuth HTTP fallback error: ${msg}`);
996
+ // Same wire-error contract as response.failed.
997
+ err.responseFailed = event;
990
998
  const typed = typedStatusFrom(event.error, event);
991
999
  if (typed) err.httpStatus = typed;
1000
+ const code = event.error?.code ?? event.error?.type ?? event.code ?? null;
1001
+ if (typeof code === 'string' && code) err.providerErrorCode = code;
992
1002
  throw err;
993
1003
  }
994
1004
  default:
@@ -1310,11 +1310,15 @@ export async function _streamResponse({
1310
1310
  responseFailed: event,
1311
1311
  });
1312
1312
  // TYPED status only (event/error payload). A failure whose
1313
- // only evidence is message text is 'unknown' to the retry
1314
- // classifiers and is surfaced instead of replayed.
1313
+ // only evidence is message text synthesizes no status; the
1314
+ // typed code/type string is preserved for the wire-error
1315
+ // default-retry classification (fatal codes stay terminal).
1315
1316
  {
1316
1317
  const typed = typedStatusFrom(event.response?.error, event.error, event);
1317
1318
  if (typed) terminalError.httpStatus = typed;
1319
+ const detail = event.response?.error || event.error || null;
1320
+ const code = detail?.code ?? detail?.type ?? null;
1321
+ if (typeof code === 'string' && code) terminalError.providerErrorCode = code;
1318
1322
  }
1319
1323
  finish();
1320
1324
  break;
@@ -1322,9 +1326,16 @@ export async function _streamResponse({
1322
1326
  case 'error': {
1323
1327
  const errMsg = String(event.message || event.error?.message || 'unknown');
1324
1328
  terminalError = new Error(`${errLabel} error: ${errMsg}`);
1329
+ // Same wire-error contract as response.failed: stash the
1330
+ // frame so the mid-stream classifier and classifyError()
1331
+ // apply the typed fatal-code deny-list / default-retry.
1332
+ midState.responseFailedPayload = event;
1333
+ terminalError.responseFailed = event;
1325
1334
  {
1326
1335
  const typed = typedStatusFrom(event.error, event);
1327
1336
  if (typed) terminalError.httpStatus = typed;
1337
+ const code = event.error?.code ?? event.error?.type ?? event.code ?? null;
1338
+ if (typeof code === 'string' && code) terminalError.providerErrorCode = code;
1328
1339
  }
1329
1340
  finish();
1330
1341
  break;
@@ -133,6 +133,20 @@ export function classifyError(err) {
133
133
  String(item?.message || '').trim(),
134
134
  ))) return 'transient'
135
135
 
136
+ // Provider wire error event (`response.failed` / terminal `error` frame):
137
+ // default-retry, codex parity. codex maps every response.failed whose typed
138
+ // code is not a deterministic refusal to ApiError::Retryable (fatal codes
139
+ // are an explicit allow-list); cc retries all 5xx/overloaded 10×; opencode
140
+ // marks server_error/server_is_overloaded isRetryable. Evidence stays
141
+ // structural — the event's own typed code/type field — message text is
142
+ // never parsed. Exposure precedence is preserved: the replayUnsafe gate at
143
+ // the top of this function already returned 'permanent' for any stream
144
+ // that relayed output or dispatched a tool call.
145
+ {
146
+ const wireKind = classifyWireErrorEvent(err)
147
+ if (wireKind) return wireKind
148
+ }
149
+
136
150
  return 'unknown'
137
151
  }
138
152
 
@@ -305,6 +319,54 @@ function isPermanentQuotaError(err) {
305
319
  return false
306
320
  }
307
321
 
322
+ // ── Wire error events: default-retry with a fatal-code deny-list ────────────
323
+ // Deterministic refusal codes a `response.failed` / `error` wire event may
324
+ // carry: retrying the identical request can never succeed. Mirrors codex's
325
+ // fatal set (context/quota/policy) plus the auth/billing refusals the
326
+ // Responses and Anthropic wire formats use. Everything OUTSIDE this set —
327
+ // server_error, server_is_overloaded, slow_down, or an event with no code at
328
+ // all — is a server-side fault and is retried under the bounded budgets
329
+ // (observed live 2026-08-11: two turns failed on typed `server_error`
330
+ // response.failed events that every reference implementation retries).
331
+ const WIRE_ERROR_FATAL_CODES = new Set([
332
+ 'context_length_exceeded', 'context_window_exceeded',
333
+ 'insufficient_quota', 'quota_exceeded', 'resource_exhausted',
334
+ 'usage_not_included', 'usage_limit_reached',
335
+ 'invalid_prompt', 'bio_policy', 'cyber_policy',
336
+ 'invalid_request', 'invalid_request_error',
337
+ 'invalid_api_key', 'authentication_error', 'permission_error',
338
+ 'permission_denied', 'billing_not_active',
339
+ ])
340
+
341
+ function wireErrorCode(err) {
342
+ const failed = err?.responseFailed
343
+ const detail = failed?.response?.error || failed?.error || err?.providerError || failed || null
344
+ for (const field of [detail?.code, detail?.type, err?.providerErrorCode]) {
345
+ if (typeof field === 'string' && field.trim()) return field.trim().toLowerCase()
346
+ }
347
+ return ''
348
+ }
349
+
350
+ // 'transient' | 'permanent' for errors born from a provider wire error event;
351
+ // null for everything else (no blanket retry for untyped local failures).
352
+ function classifyWireErrorEvent(err) {
353
+ if (!err || (err.responseFailed == null && err.providerWireError !== true)) return null
354
+ const code = wireErrorCode(err)
355
+ if (code && WIRE_ERROR_FATAL_CODES.has(code)) return 'permanent'
356
+ return 'transient'
357
+ }
358
+
359
+ /**
360
+ * True when `err` came from a provider wire error event whose typed code is
361
+ * not a deterministic refusal — i.e. the send-with-recovery loop may replay
362
+ * it (after text retraction when something was exposed). classifyError()
363
+ * reports 'permanent' the moment output was exposed, so the loop names this
364
+ * symptom directly, exactly like stall/truncated/non-terminal-close.
365
+ */
366
+ export function isRetryableWireErrorEvent(err) {
367
+ return classifyWireErrorEvent(err) === 'transient'
368
+ }
369
+
308
370
  /**
309
371
  * Convenience predicate: should this error be retried at the request level?
310
372
  * Wraps classifyError() with the standard "transient = retry, otherwise no"
@@ -541,9 +603,10 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
541
603
  if (failed) {
542
604
  // STRUCTURED evidence only: the failure's own numeric status and its
543
605
  // explicit error code/type field. The payload is never stringified and
544
- // searched — a message/body that merely CONTAINS "network_error",
545
- // "stream_disconnected" or "auth context expired" is not a typed retry
546
- // signal and leaves the turn terminal.
606
+ // searched — a message/body that merely CONTAINS "network_error" or
607
+ // "stream_disconnected" never selects a SPECIFIC bucket. Buckets need
608
+ // typed codes; the DEFAULT for an unrecognized (or absent) code is the
609
+ // bounded retry below, with fatal refusal codes staying terminal.
547
610
  const detail = failed?.response?.error || failed?.error || failed
548
611
  const failedStatus = typedStatusFrom(detail, failed?.response, failed)
549
612
  if (failedStatus >= 500 && failedStatus < 600) {
@@ -553,7 +616,15 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
553
616
  const key = typeof field === 'string' ? field.trim().toLowerCase() : ''
554
617
  const classifier = RESPONSE_FAILED_CODE_CLASSIFIERS.get(key)
555
618
  if (classifier) return _allowMidstream(classifier, attemptIndex, policy)
619
+ if (key && WIRE_ERROR_FATAL_CODES.has(key)) return null
556
620
  }
621
+ // A typed non-transient 4xx on the failure payload is a deterministic
622
+ // refusal even without a recognized code string.
623
+ if (failedStatus >= 400 && failedStatus < 500 && !TRANSIENT_STATUSES.has(failedStatus)) return null
624
+ // Default-retry (codex parity): a wire failure that is neither a fatal
625
+ // refusal nor a typed 4xx is a server-side fault — re-issue it under the
626
+ // bounded mid-stream budget instead of failing the turn.
627
+ return _allowMidstream('response_failed_retryable', attemptIndex, policy)
557
628
  }
558
629
 
559
630
  return null
@@ -394,7 +394,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
394
394
  const EMPTY_NUDGE_MAX = 3;
395
395
  let _refusalRetryUsed = false;
396
396
  let _maxOutputRecoveryCount = 0;
397
- const _maxOutputContentParts = [];
397
+ // Committed-but-unsealed text segments for the caller-facing aggregate:
398
+ // max-output recovery parts plus (Lead/TUI only) text-only continuation
399
+ // segments (provider pause_turn / terminal steering / stop hook). The
400
+ // terminal response returns content = parts + terminal so the UI row that
401
+ // accumulated every streamed segment is not overwritten down to only the
402
+ // last segment; historyContent keeps persistence single-copy. Reset after
403
+ // each executed tool batch — the UI seals its row at tool boundaries, so a
404
+ // pre-tool part re-prepended at terminal would duplicate a sealed row.
405
+ const _committedTextParts = [];
398
406
  // Count of structured provider continuation signals honored this turn
399
407
  // (endTurn === false / stopReason === 'pause_turn'). Diagnostic only; the
400
408
  // hard iteration cap remains the sole bound on how long a provider may
@@ -799,7 +807,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
799
807
  const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
800
808
  const isOutputLimitStop = isOutputLimitStopReason(stopReason);
801
809
  if (hasContent && isOutputLimitStop) {
802
- _maxOutputContentParts.push(response.content);
810
+ _committedTextParts.push(response.content);
803
811
  if (_maxOutputRecoveryCount < MAX_OUTPUT_RECOVERY_LIMIT) {
804
812
  // The partial assistant turn must be visible to the model so
805
813
  // it can resume at the exact cutoff instead of reconstructing
@@ -818,7 +826,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
818
826
  const terminalSegment = `${response.content}\n\n${MAX_OUTPUT_EXHAUSTED_NOTICE}`;
819
827
  response = {
820
828
  ...response,
821
- content: `${_maxOutputContentParts.slice(0, -1).join('')}${terminalSegment}`,
829
+ content: `${_committedTextParts.slice(0, -1).join('')}${terminalSegment}`,
822
830
  historyContent: terminalSegment,
823
831
  maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
824
832
  };
@@ -864,6 +872,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
864
872
  } catch { /* best-effort */ }
865
873
  } else if (continuationSignal && pushIntermediateAssistantResponse(response)) {
866
874
  if (hasContent && !suppressMidTurnText) {
875
+ _committedTextParts.push(response.content);
867
876
  try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
868
877
  }
869
878
  _providerContinuationCount += 1;
@@ -913,7 +922,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
913
922
  // Commit the terminal text first (beforeAppend), then resume.
914
923
  if (!_capFinalToolsDisabled && drainSteeringIntoMessages('terminal', {
915
924
  maxPriority: 'next',
916
- beforeAppend: () => { pushIntermediateAssistantResponse(response); },
925
+ beforeAppend: () => {
926
+ if (pushIntermediateAssistantResponse(response) && hasContent && !suppressMidTurnText) {
927
+ _committedTextParts.push(response.content);
928
+ }
929
+ },
917
930
  })) {
918
931
  _emptyNudgeStreak = 0;
919
932
  continue;
@@ -928,6 +941,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
928
941
  const _hookPrompt = _toolFailureStopHook.takeContinuationPrompt();
929
942
  if (_hookPrompt) {
930
943
  pushIntermediateAssistantResponse(response);
944
+ if (hasContent && !suppressMidTurnText) _committedTextParts.push(response.content);
931
945
  messages.push({
932
946
  role: 'user',
933
947
  content: _hookPrompt,
@@ -945,13 +959,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
945
959
  continue;
946
960
  }
947
961
  }
948
- if (_maxOutputContentParts.length > 0) {
962
+ if (_committedTextParts.length > 0) {
949
963
  const terminalSegment = typeof response.content === 'string' ? response.content : '';
950
964
  response = {
951
965
  ...response,
952
- content: `${_maxOutputContentParts.join('')}${terminalSegment}`,
966
+ content: `${_committedTextParts.join('')}${terminalSegment}`,
953
967
  historyContent: terminalSegment,
954
- maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
968
+ ...(_maxOutputRecoveryCount > 0
969
+ ? { maxOutputRecoveryAttempts: _maxOutputRecoveryCount }
970
+ : {}),
955
971
  };
956
972
  }
957
973
  break;
@@ -1082,6 +1098,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1082
1098
  _lastToolBatchEndedAt = _toolsEndedAt;
1083
1099
  _toolBatchJustCompleted = true;
1084
1100
  _continuationsSinceToolBatch = 0;
1101
+ // The UI sealed its streaming row at this tool boundary; earlier
1102
+ // committed parts must not re-prepend at terminal (duplicate rows).
1103
+ _committedTextParts.length = 0;
1085
1104
  _lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
1086
1105
  }
1087
1106
  // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
@@ -128,7 +128,7 @@ export function createEagerDispatcher({
128
128
  }
129
129
  }
130
130
  await opts.beforeToolExecution?.();
131
- return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration(), deferShellCwdCommit: true }) };
131
+ return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration() }) };
132
132
  } catch (error) {
133
133
  return { ok: false, error };
134
134
  }
@@ -9,6 +9,7 @@ import {
9
9
  classifyError,
10
10
  isContextOverflowError,
11
11
  isNonTerminalStreamClose,
12
+ isRetryableWireErrorEvent,
12
13
  jitterDelayMs,
13
14
  } from '../providers/retry-classifier.mjs';
14
15
  import { setTimeout as sleepMs } from 'timers/promises';
@@ -405,6 +406,10 @@ export async function sendWithRecovery(ctx) {
405
406
  // gemini/compat EOF): classifyError calls it
406
407
  // 'transient' only while nothing was exposed.
407
408
  || outcome.truncatedStream === true
409
+ // And for retryable wire error events
410
+ // (response.failed server_error & co.): exposed
411
+ // text is retracted, then the send is replayed.
412
+ || isRetryableWireErrorEvent(sendErr)
408
413
  || isNonTerminalStreamClose(sendErr))
409
414
  && await retractExposedTextForReplay()
410
415
  )
@@ -37,7 +37,6 @@ import {
37
37
  parseNativeToolSearchPayload,
38
38
  } from './loop/tool-helpers.mjs';
39
39
  import { restoreToolCallBodyForId } from './loop/stored-tool-args.mjs';
40
- import { commitSessionCwdProbe } from '../tools/shell-state.mjs';
41
40
 
42
41
  function classifyToolReturn(value) {
43
42
  const normalized = normalizeToolEnvelope(value);
@@ -308,7 +307,7 @@ export async function processToolBatch(ctx) {
308
307
  _resultKind = 'error';
309
308
  } else {
310
309
  await opts.beforeToolExecution?.();
311
- result = await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations, deferShellCwdCommit: true });
310
+ result = await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations });
312
311
  toolEndedAt = Date.now();
313
312
  // Boundary: tool-return string convention → structural kind.
314
313
  // The only prefix check in this codebase; downstream layers
@@ -330,9 +329,6 @@ export async function processToolBatch(ctx) {
330
329
  result = `Error: ${err instanceof Error ? err.message : String(err)}`;
331
330
  _resultKind = 'error';
332
331
  }
333
- if (_isShellTool(call.name) && _resultKind !== 'skipped') {
334
- commitSessionCwdProbe(sessionId, call.id);
335
- }
336
332
  // CENTRAL ENVELOPE NORMALIZE (general newMessages channel).
337
333
  // executeTool (serial + eager) and cache/error paths above all
338
334
  // funnel into `result`. Split ONCE here: downstream post-processing
@@ -461,7 +461,7 @@ function guardGrep(a) {
461
461
  const hl = Number(a.head_limit);
462
462
  if (grepContextKeyPresent(a, 'head_limit') && Number.isFinite(hl) && hl > GREP_CTX_HEAD_LIMIT_MAX) {
463
463
  a.head_limit = GREP_CTX_HEAD_LIMIT_MAX;
464
- pushClampNotice(a, `notice: grep head_limit clamped to ${GREP_CTX_HEAD_LIMIT_MAX} match blocks (context mode)`);
464
+ pushClampNotice(a, `notice: grep limit clamped to ${GREP_CTX_HEAD_LIMIT_MAX} match blocks (context mode)`);
465
465
  }
466
466
  }
467
467
  return null;
@@ -706,12 +706,12 @@ function guardList(a) {
706
706
  const coerced = coerceIntegerString(a.head_limit);
707
707
  if (coerced !== null) a.head_limit = coerced;
708
708
  if (!isFiniteInt(a.head_limit)) {
709
- return `Error: list arg "head_limit" must be a finite integer (got ${describeType(a.head_limit)})`;
709
+ return `Error: list arg "limit" must be a finite integer (got ${describeType(a.head_limit)})`;
710
710
  }
711
711
  // 0 is the valid "no cap" sentinel; a negative value is nonsensical
712
712
  // and downstream produces a degenerate window (clamps to 0 → empty).
713
713
  if (a.head_limit < 0) {
714
- return `Error: list arg "head_limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
714
+ return `Error: list arg "limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
715
715
  }
716
716
  }
717
717
  return null;
@@ -745,10 +745,10 @@ function guardFind(a) {
745
745
  const coerced = coerceIntegerString(a.head_limit);
746
746
  if (coerced !== null) a.head_limit = coerced;
747
747
  if (!isFiniteInt(a.head_limit)) {
748
- return `Error: find arg "head_limit" must be a finite integer (got ${describeType(a.head_limit)})`;
748
+ return `Error: find arg "limit" must be a finite integer (got ${describeType(a.head_limit)})`;
749
749
  }
750
750
  if (a.head_limit < 0) {
751
- return `Error: find arg "head_limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
751
+ return `Error: find arg "limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
752
752
  }
753
753
  }
754
754
  return null;
@@ -787,12 +787,12 @@ function guardGlob(a) {
787
787
  const coerced = coerceIntegerString(a.head_limit);
788
788
  if (coerced !== null) a.head_limit = coerced;
789
789
  if (!isFiniteInt(a.head_limit)) {
790
- return `Error: glob arg "head_limit" must be a finite integer (got ${describeType(a.head_limit)})`;
790
+ return `Error: glob arg "limit" must be a finite integer (got ${describeType(a.head_limit)})`;
791
791
  }
792
792
  // 0 is the valid "no cap" sentinel; a negative value is nonsensical
793
793
  // and downstream produces a degenerate window (clamps to 0 → empty).
794
794
  if (a.head_limit < 0) {
795
- return `Error: glob arg "head_limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
795
+ return `Error: glob arg "limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
796
796
  }
797
797
  }
798
798
  return null;