mixdog 0.9.45 → 0.9.47

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 (123) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/headless-command.mjs +139 -0
  34. package/src/headless-role.mjs +121 -10
  35. package/src/help.mjs +4 -1
  36. package/src/rules/agent/00-common.md +3 -3
  37. package/src/rules/agent/00-core.md +8 -9
  38. package/src/rules/agent/20-skip-protocol.md +2 -3
  39. package/src/rules/agent/30-explorer.md +50 -56
  40. package/src/rules/agent/40-cycle1-agent.md +10 -12
  41. package/src/rules/agent/41-cycle2-agent.md +12 -9
  42. package/src/rules/agent/42-cycle3-agent.md +4 -6
  43. package/src/rules/lead/01-general.md +5 -6
  44. package/src/rules/lead/02-channels.md +1 -1
  45. package/src/rules/lead/lead-brief.md +14 -17
  46. package/src/rules/lead/lead-tool.md +3 -3
  47. package/src/rules/shared/01-tool.md +41 -43
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  50. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  51. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  52. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  53. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  55. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  56. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  57. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  58. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  60. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  64. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  66. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  67. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  68. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  69. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  70. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  71. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  72. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  73. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  74. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  75. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  76. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  79. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  80. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  81. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  82. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  83. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  86. package/src/runtime/memory/index.mjs +0 -1
  87. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  88. package/src/runtime/memory/lib/http-router.mjs +0 -193
  89. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  90. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  91. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  92. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  93. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  94. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  95. package/src/runtime/memory/tool-defs.mjs +5 -6
  96. package/src/runtime/shared/config.mjs +11 -34
  97. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  98. package/src/runtime/shared/pristine-execution.mjs +356 -0
  99. package/src/runtime/shared/provider-api-key.mjs +43 -0
  100. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  101. package/src/session-runtime/context-status.mjs +61 -13
  102. package/src/session-runtime/mcp-glue.mjs +29 -2
  103. package/src/session-runtime/plugin-mcp.mjs +7 -0
  104. package/src/session-runtime/resource-api.mjs +38 -5
  105. package/src/session-runtime/runtime-core.mjs +5 -1
  106. package/src/session-runtime/session-turn-api.mjs +14 -2
  107. package/src/session-runtime/settings-api.mjs +5 -0
  108. package/src/session-runtime/tool-catalog.mjs +13 -2
  109. package/src/session-runtime/tool-defs.mjs +1 -3
  110. package/src/standalone/agent-task-status.mjs +50 -11
  111. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  112. package/src/standalone/explore-tool.mjs +257 -49
  113. package/src/standalone/seeds.mjs +1 -0
  114. package/src/tui/App.jsx +23 -10
  115. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  116. package/src/tui/app/use-transcript-window.mjs +12 -21
  117. package/src/tui/components/ContextPanel.jsx +19 -25
  118. package/src/tui/dist/index.mjs +77 -65
  119. package/src/tui/engine/agent-envelope.mjs +16 -5
  120. package/src/tui/engine/labels.mjs +1 -1
  121. package/src/workflows/default/WORKFLOW.md +21 -51
  122. package/src/workflows/solo/WORKFLOW.md +12 -17
  123. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -8,83 +8,6 @@ import { appendAgentTrace } from '../../agent-trace.mjs';
8
8
  import { level2SteerMessage } from './completion-guards.mjs';
9
9
  import { isEagerDispatchable } from './tool-helpers.mjs';
10
10
 
11
- // --- Steering detector helpers (pure) ---
12
- const _WORD_RE = /[A-Za-z_$][\w$]*/g;
13
- const _IDENT_RE = /^[A-Za-z_$][\w$]{2,}$/;
14
- const _SRC_DIR_RE = /(^|[\\/])(src|lib|app|packages|components|pages)([\\/]|$)/i;
15
- function _grepPatterns(c) {
16
- const p = c?.arguments?.pattern;
17
- if (typeof p === 'string') return [p];
18
- if (Array.isArray(p)) return p.filter((x) => typeof x === 'string');
19
- return [];
20
- }
21
- function _patternTokens(c) {
22
- const out = new Set();
23
- for (const pat of _grepPatterns(c)) {
24
- for (const t of (String(pat).match(_WORD_RE) || [])) {
25
- if (t.length >= 3) out.add(t.toLowerCase());
26
- }
27
- }
28
- return out;
29
- }
30
- function _pathsOf(arg) {
31
- const out = [];
32
- const push = (v) => {
33
- if (typeof v === 'string' && v) out.push(v);
34
- else if (v && typeof v === 'object' && typeof v.path === 'string' && v.path) out.push(v.path);
35
- };
36
- if (Array.isArray(arg)) arg.forEach(push); else push(arg);
37
- return out;
38
- }
39
- // Case-insensitive (win32 paths) forward-slash normalization for all path compares.
40
- function _normPath(p) { return String(p).replace(/\\/g, '/').toLowerCase(); }
41
- function _baseName(p) { const s = _normPath(p); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(i + 1) : s; }
42
- function _dirName(p) { const s = _normPath(p); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(0, i) : ''; }
43
- // A path targets a specific file (not a directory scope) when its basename
44
- // carries a file extension — the signal that a grep was already file-scoped.
45
- function _isFileScopedPath(p) { return /\.[A-Za-z0-9]{1,12}$/.test(_baseName(p)); }
46
- // Grep context flag: -C/-A/-B count as context ONLY when > 0. An explicit
47
- // -C:0 / -A:0 / -B:0 is NO context (bare match lines).
48
- function _hasGrepContext(a) { return Number(a?.['-A']) > 0 || Number(a?.['-B']) > 0 || Number(a?.['-C']) > 0; }
49
- // Canonical path segments: normalize slashes/case, drop '.' and resolve '..'
50
- // so ./ and ../ variants collapse. Used by the abs/rel-tolerant path match.
51
- function _canonSegs(p) {
52
- const out = [];
53
- for (const seg of _normPath(p).split('/')) {
54
- if (seg === '' || seg === '.') continue;
55
- if (seg === '..') { if (out.length && out[out.length - 1] !== '..') out.pop(); else out.push('..'); continue; }
56
- out.push(seg);
57
- }
58
- return out;
59
- }
60
- function _canonPath(p) { return _canonSegs(p).join('/'); }
61
- // abs/rel-tolerant same-file test: equal when one canonical path is a
62
- // segment-aligned suffix of the other (so C:/proj/src/x.mjs, src/x.mjs and
63
- // ./src/x.mjs all match). Basename equality is implied by the suffix compare.
64
- function _pathsMatch(a, b) {
65
- if (!a || !b) return false;
66
- const A = _canonSegs(a); const B = _canonSegs(b);
67
- if (!A.length || !B.length) return false;
68
- const long = A.length >= B.length ? A : B;
69
- const short = A.length >= B.length ? B : A;
70
- for (let i = 0; i < short.length; i += 1) {
71
- if (long[long.length - short.length + i] !== short[i]) return false;
72
- }
73
- return true;
74
- }
75
- // Exact (normalized) same-file test — used where "related" is too loose.
76
- function _samePath(a, b) { return !!a && !!b && _normPath(a) === _normPath(b); }
77
- // Loose sibling/variant relation — cluster detector pairs this WITH token overlap.
78
- function _pathsRelated(a, b) {
79
- if (!a || !b) return false;
80
- const na = _normPath(a); const nb = _normPath(b);
81
- if (na === nb) return true; // identical
82
- if (na.includes(nb) || nb.includes(na)) return true; // normalized variant
83
- if (_dirName(na) && _dirName(na) === _dirName(nb)) return true; // siblings
84
- const ba = _baseName(na); const bb = _baseName(nb);
85
- return !!ba && ba === bb; // same file name
86
- }
87
- function _setsOverlap(a, b) { for (const x of a) { if (b.has(x)) return true; } return false; }
88
11
  // A single chained segment is read-only only if it STARTS with a read-only verb.
89
12
  function _isReadOnlyShellSegment(seg) {
90
13
  const s = seg.trim();
@@ -108,23 +31,6 @@ function _isReadOnlyShellCmd(cmd) {
108
31
  }
109
32
  return sawCmd;
110
33
  }
111
- // Extract (path, offset) windows from a read call: path may be a string, an
112
- // array of strings, or an array of {path,offset,limit} regions; a top-level
113
- // offset applies to string entries.
114
- function _readWindows(c) {
115
- const a = c?.arguments || {};
116
- const out = [];
117
- const push = (p, off) => { if (typeof p === 'string' && p) out.push({ path: p, offset: Number(off) }); };
118
- const v = a.path;
119
- if (Array.isArray(v)) {
120
- for (const e of v) {
121
- if (typeof e === 'string') push(e, a.offset);
122
- else if (e && typeof e === 'object') push(e.path, e.offset ?? a.offset);
123
- }
124
- } else if (typeof v === 'string') push(v, a.offset);
125
- return out;
126
- }
127
-
128
34
  // Build the completion-first steering-ladder controller. `ctx` supplies live
129
35
  // accessors so every read reflects the loop's current mutable state:
130
36
  // - messages, sessionId, sessionAgent, tools (stable refs/values)
@@ -168,35 +74,7 @@ export function createSteeringLadder(ctx) {
168
74
  // call (missed parallelism). Not reset per-iteration — only by the
169
75
  // steering-hint fire below or by a turn that batches/edits.
170
76
  let _serialReadOnlyStreak = 0;
171
- // Same-concept grep cluster (broadened serial-rewording spiral): counts
172
- // consecutive grep-only turns that stay on ONE concept — related by
173
- // identical/normalized/sibling paths OR pattern-token overlap, not just an
174
- // identical path. `_sameGrepPrev` holds the prior turn's {paths,tokens}.
175
- let _sameGrepStreak = 0;
176
- let _sameGrepPrev = null;
177
- // Prior turn's uncapped content/context grep paths — seeds the
178
- // grep-context-then-read detector (a read of the same file next turn).
179
- let _lastGrepContentPaths = null;
180
- // Consecutive turns grepping the SAME identifier-like token in source dirs
181
- // (symbol lookups that belong in code_graph).
182
- let _identGrepStreak = 0;
183
- let _identGrepToken = null;
184
77
  let _level2FireCount = 0;
185
- // Read-fragmentation detector state: normalized path -> cumulative
186
- // single-window offset reads. Mirrors session-bench read_fragmentation
187
- // (3+ windowed reads of one file within an 800-line span). Multi-window
188
- // same-path regions in ONE call are the recommended batched form and are
189
- // never counted. Fires once per path per session.
190
- const _readWindowsByPath = new Map();
191
- // Detector A state: prior turn's file-scoped grep paths that ran WITHOUT
192
- // any -C/-B/-A context (a bare match-line grep on a specific file). Seeds
193
- // the grep-no-context-then-read nudge for the next turn.
194
- let _lastFileScopedGrepNoCtxPaths = null;
195
- // Detector B state: per-path consecutive-read streak (canonical path ->
196
- // count). Tracked as a Map so multi-file turns keep independent streaks
197
- // ([A,B]->B->B fires on B). Files not read this turn are pruned; a path is
198
- // dropped after it fires.
199
- const _readStreaks = new Map();
200
78
 
201
79
  // Level-2 steering emitter shared by both ladder paths (single-call
202
80
  // level-1 streak and the independent all-read-only streak). Sets the latch
@@ -225,25 +103,8 @@ export function createSteeringLadder(ctx) {
225
103
  emitPostBatchSteering(calls, hintAlreadyFired) {
226
104
  const iterations = getIterations();
227
105
  const editCount = getEditCount();
228
- // Steering hint gate: at most ONE hint per turn (priority:
229
- // level-2 > grep/shell detectors > level-1).
106
+ // Steering hint gate: at most ONE hint per turn.
230
107
  let _hintFiredThisTurn = hintAlreadyFired;
231
- // Specific-detector predicates (A: grep-no-context then re-read; B:
232
- // 3rd consecutive same-file read). Evaluated up front so the GENERIC
233
- // level-1 batching nudge can defer to them (more specific wins),
234
- // while the level-2 runaway escalation below still outranks them.
235
- const _readCallsThisTurn = calls.filter((c) => c?.name === 'read');
236
- const _aWouldFire = !!_lastFileScopedGrepNoCtxPaths
237
- && _readCallsThisTurn.some((c) => _pathsOf(c?.arguments?.path)
238
- .some((p) => [..._lastFileScopedGrepNoCtxPaths].some((g) => _pathsMatch(p, g))));
239
- let _bWouldFire = false;
240
- for (const c of _readCallsThisTurn) {
241
- for (const w of _readWindows(c)) {
242
- for (const [k, cnt] of _readStreaks) { if (cnt >= 2 && _pathsMatch(k, w.path)) { _bWouldFire = true; break; } }
243
- if (_bWouldFire) break;
244
- }
245
- if (_bWouldFire) break;
246
- }
247
108
  // Missed-parallelism steering: 2+ consecutive turns of a single
248
109
  // read-only tool call suggest the model isn't batching independent
249
110
  // lookups. Nudge once, then reset (fires again after 2 more).
@@ -252,10 +113,9 @@ export function createSteeringLadder(ctx) {
252
113
  // Escalation ladder (Step 1). Cumulative level-1 fires are tracked
253
114
  // and NEVER reset. Once level-1 has fired >=3 times with ZERO edits,
254
115
  // escalate to level-2 steering (latched once per 5 turns). The
255
- // level-2 escalation always wins; the plain batching nudge instead
256
- // DEFERS to the specific A/B detectors when either would fire.
116
+ // level-2 escalation always wins.
257
117
  const _canEscalate = (_level1FireCount + 1) >= 3 && editCount === 0 && (iterations - _level2LatchAtIteration) >= 5;
258
- if (_serialReadOnlyStreak >= 2 && !_hintFiredThisTurn && (_canEscalate || (!_aWouldFire && !_bWouldFire))) {
118
+ if (_serialReadOnlyStreak >= 2 && !_hintFiredThisTurn) {
259
119
  _serialReadOnlyStreak = 0;
260
120
  _level1FireCount += 1;
261
121
  if (_canEscalate) {
@@ -288,227 +148,6 @@ export function createSteeringLadder(ctx) {
288
148
  _allReadOnlyStreak = 0;
289
149
  }
290
150
  }
291
- const _grepCalls = calls.filter((c) => c?.name === 'grep');
292
- const _allGrep = calls.length > 0 && _grepCalls.length === calls.length;
293
- // Detector: repeated identifier-like grep in source dirs → code_graph.
294
- // Symbol lookups (a bare \w+ token scoped to src/) belong in
295
- // code_graph (find_symbol/references/callers), not text grep. Fires
296
- // when the same token is grepped on 2+ consecutive qualifying turns.
297
- {
298
- let _identTok = null;
299
- for (const c of _grepCalls) {
300
- const _srcScoped = _pathsOf(c?.arguments?.path).some((p) => _SRC_DIR_RE.test(p));
301
- if (!_srcScoped) continue;
302
- const _hit = _grepPatterns(c).map((p) => p.trim()).find((p) => _IDENT_RE.test(p));
303
- if (_hit) { _identTok = _hit.toLowerCase(); break; }
304
- }
305
- if (_identTok) {
306
- if (_identTok === _identGrepToken) _identGrepStreak += 1;
307
- else { _identGrepToken = _identTok; _identGrepStreak = 1; }
308
- if (_identGrepStreak >= 2 && !_hintFiredThisTurn) {
309
- pushSystemReminder(`Grepping the symbol "${_identGrepToken}" across source dirs again — use code_graph (find_symbol/references/callers) for symbol lookups instead of text grep.`);
310
- _identGrepStreak = 0;
311
- _identGrepToken = null;
312
- _hintFiredThisTurn = true;
313
- }
314
- } else {
315
- _identGrepStreak = 0;
316
- _identGrepToken = null;
317
- }
318
- }
319
- // Same-concept grep cluster (broadened serial-rewording): 3+
320
- // consecutive grep-only turns on one concept — related by
321
- // an exact-same path, OR a sibling/variant path that ALSO shares a
322
- // pattern token (sibling alone is too loose to count).
323
- // Fires once per spiral, then resets; read-the-span is the answer.
324
- {
325
- if (_allGrep) {
326
- const _paths = [];
327
- const _tokens = new Set();
328
- for (const c of _grepCalls) {
329
- for (const p of _pathsOf(c?.arguments?.path)) _paths.push(p);
330
- for (const t of _patternTokens(c)) _tokens.add(t);
331
- }
332
- let _related = false;
333
- if (_sameGrepPrev) {
334
- // Exact same-path spiral fires alone; a merely sibling/
335
- // variant path must ALSO share a pattern token to count.
336
- const _pathExact = _paths.some((a) => _sameGrepPrev.paths.some((b) => _samePath(a, b)));
337
- const _pathRel = _paths.some((a) => _sameGrepPrev.paths.some((b) => _pathsRelated(a, b)));
338
- const _tokRel = _setsOverlap(_tokens, _sameGrepPrev.tokens);
339
- _related = _pathExact || (_pathRel && _tokRel);
340
- }
341
- _sameGrepStreak = _related ? _sameGrepStreak + 1 : 1;
342
- const _label = _paths[0] || [..._tokens][0] || 'same concept';
343
- _sameGrepPrev = { paths: _paths, tokens: _tokens };
344
- if (_sameGrepStreak >= 3 && !_hintFiredThisTurn) {
345
- pushSystemReminder(`3+ consecutive grep turns on the same concept (${_label}) — reworded patterns / sibling paths are not converging. Read the relevant span (read with offset/limit) or act on what you have.`);
346
- _sameGrepStreak = 0;
347
- _sameGrepPrev = null;
348
- _hintFiredThisTurn = true;
349
- }
350
- } else {
351
- _sameGrepStreak = 0;
352
- _sameGrepPrev = null;
353
- }
354
- }
355
- // Detector: content/context grep on X then read of X next turn — the
356
- // grep context should have sufficed. Fire off the PRIOR turn's
357
- // uncapped content grep, then recompute for the next turn (skip when
358
- // the grep was capped via head_limit or paths-only mode).
359
- {
360
- if (_lastGrepContentPaths && !_hintFiredThisTurn) {
361
- const _readCall = calls.find((c) => c?.name === 'read');
362
- if (_readCall) {
363
- const _rp = _pathsOf(_readCall?.arguments?.path);
364
- // Same-file only — a sibling read is a new lookup, not a re-read.
365
- const _hit = _rp.find((p) => [..._lastGrepContentPaths].some((g) => _samePath(p, g)));
366
- if (_hit) {
367
- pushSystemReminder(`Reading ${_baseName(_hit)} right after a content grep on it — the grep context should have sufficed; answer from grep output or widen -C instead of re-reading.`);
368
- _hintFiredThisTurn = true;
369
- }
370
- }
371
- }
372
- const _next = new Set();
373
- for (const c of _grepCalls) {
374
- const a = c?.arguments || {};
375
- // Bare output_mode:'content' WITHOUT real context is routed to
376
- // Detector A (which gives the add-`-C` advice); this detector
377
- // only owns greps that actually returned context.
378
- const _isContent = _hasGrepContext(a) || a.output_mode === 'content_with_context';
379
- const _pathsOnly = a.output_mode === 'files_with_matches' || a.output_mode === 'count';
380
- const _capped = a.head_limit != null;
381
- if (_isContent && !_pathsOnly && !_capped) {
382
- for (const p of _pathsOf(a.path)) _next.add(p);
383
- }
384
- }
385
- _lastGrepContentPaths = _next.size ? _next : null;
386
- }
387
- // Detector A (fire): a file-scoped grep WITHOUT context last turn,
388
- // then a read of that same file this turn. Fires ahead of the read-
389
- // fragmentation / B detectors below; the generic level-1 nudge
390
- // already deferred to it via _aWouldFire, while the level-2
391
- // escalation above still outranks it. On pre-emption by an
392
- // earlier hint, the pending set is kept (not cleared) so A can fire
393
- // next turn.
394
- let _aPending = false;
395
- if (_lastFileScopedGrepNoCtxPaths) {
396
- const _rp = [];
397
- for (const c of _readCallsThisTurn) { for (const p of _pathsOf(c?.arguments?.path)) _rp.push(p); }
398
- const _hit = _rp.find((p) => [..._lastFileScopedGrepNoCtxPaths].some((g) => _pathsMatch(p, g)));
399
- if (_hit) {
400
- if (!_hintFiredThisTurn) {
401
- pushSystemReminder(`You grepped ${_baseName(_hit)} last turn then re-opened it — request context with -C on file-scoped grep so the match window is already on screen.`);
402
- try {
403
- appendAgentTrace({
404
- sessionId,
405
- iteration: iterations,
406
- kind: 'steer',
407
- payload: { tag: 'grep_nocontext_reread', file: _baseName(_hit) },
408
- agent: sessionAgent || null,
409
- });
410
- } catch { /* best-effort */ }
411
- _hintFiredThisTurn = true;
412
- _lastFileScopedGrepNoCtxPaths = null;
413
- } else {
414
- _aPending = true; // pre-empted this turn — keep pending for next turn
415
- }
416
- }
417
- }
418
- // Detector A (seed): record this turn's file-scoped greps that ran
419
- // WITHOUT context so a same-file read next turn triggers the fire
420
- // block above. When A was pre-empted this turn (_aPending) the
421
- // existing pending set is kept rather than cleared.
422
- if (!_aPending) {
423
- const _next = new Set();
424
- for (const c of _grepCalls) {
425
- const a = c?.arguments || {};
426
- // Exact complement of the content detector's seed: skip greps
427
- // that carried real context (positive -A/-B/-C) OR ran in
428
- // content_with_context mode — those belong to that detector.
429
- if (_hasGrepContext(a) || a.output_mode === 'content_with_context') continue;
430
- for (const p of _pathsOf(a.path)) {
431
- if (_isFileScopedPath(p)) _next.add(p);
432
- }
433
- }
434
- _lastFileScopedGrepNoCtxPaths = _next.size ? _next : null;
435
- }
436
- // Detector: read fragmentation — 2nd+ single-window read into the
437
- // SAME file with offsets inside an 800-line span means the model is
438
- // paging small windows across turns instead of reading one wider
439
- // span (or batching {path,offset,limit}[] regions in one call).
440
- {
441
- for (const c of calls.filter((x) => x?.name === 'read')) {
442
- const _byPath = new Map();
443
- for (const w of _readWindows(c)) {
444
- const k = _normPath(w.path);
445
- if (!_byPath.has(k)) _byPath.set(k, []);
446
- _byPath.get(k).push(w);
447
- }
448
- for (const [k, ws] of _byPath) {
449
- if (ws.length !== 1) continue; // batched regions on one path — the good form
450
- const off = ws[0].offset;
451
- if (!Number.isFinite(off) || off <= 0) continue;
452
- let rec = _readWindowsByPath.get(k);
453
- if (!rec) {
454
- rec = { offsets: [], fired: false };
455
- _readWindowsByPath.set(k, rec);
456
- if (_readWindowsByPath.size > 32) _readWindowsByPath.delete(_readWindowsByPath.keys().next().value);
457
- }
458
- rec.offsets.push(off);
459
- if (!rec.fired && !_hintFiredThisTurn && rec.offsets.length >= 2
460
- && (Math.max(...rec.offsets) - Math.min(...rec.offsets)) <= 800) {
461
- pushSystemReminder(`Windowed reads are fragmenting ${_baseName(k)} — stop paging small windows; read ONE wider span, or batch all needed spans as {path,offset,limit}[] regions in a single call.`);
462
- rec.fired = true;
463
- _hintFiredThisTurn = true;
464
- }
465
- }
466
- }
467
- }
468
- // Detector B: 3rd consecutive turn reading the SAME file (any
469
- // window). Per-path streaks in a Map so multi-file turns keep
470
- // independent counts ([A,B]->B->B fires on B). rel/abs spellings of
471
- // one file collapse onto a single streak key; files not read this
472
- // turn are pruned; a path is dropped after it fires.
473
- {
474
- // Resolve each read path to one streak key, merging rel/abs
475
- // spellings against existing keys and keys already seen this turn.
476
- const _touched = new Set();
477
- for (const c of calls.filter((x) => x?.name === 'read')) {
478
- for (const w of _readWindows(c)) {
479
- let _key = null;
480
- for (const k of _readStreaks.keys()) { if (_pathsMatch(k, w.path)) { _key = k; break; } }
481
- if (!_key) { for (const k of _touched) { if (_pathsMatch(k, w.path)) { _key = k; break; } } }
482
- _touched.add(_key || _canonPath(w.path));
483
- }
484
- }
485
- // Prune streaks for files not read this turn (breaks consecutiveness).
486
- for (const k of [..._readStreaks.keys()]) { if (!_touched.has(k)) _readStreaks.delete(k); }
487
- // Increment each file read this turn (once per turn).
488
- for (const k of _touched) {
489
- _readStreaks.set(k, (_readStreaks.get(k) || 0) + 1);
490
- if (_readStreaks.size > 32) _readStreaks.delete(_readStreaks.keys().next().value);
491
- }
492
- if (!_hintFiredThisTurn) {
493
- for (const [k, cnt] of _readStreaks) {
494
- if (cnt >= 3) {
495
- pushSystemReminder(`3rd window into ${_baseName(k)} — take the remaining span in ONE wide read (offset+limit) instead of paging.`);
496
- try {
497
- appendAgentTrace({
498
- sessionId,
499
- iteration: iterations,
500
- kind: 'steer',
501
- payload: { tag: 'consecutive_same_file_read', file: _baseName(k) },
502
- agent: sessionAgent || null,
503
- });
504
- } catch { /* best-effort */ }
505
- _readStreaks.delete(k);
506
- _hintFiredThisTurn = true;
507
- break;
508
- }
509
- }
510
- }
511
- }
512
151
  // Detector: read-only shell (git status/log/diff --stat, ls/dir/cat/
513
152
  // type/Get-ChildItem) inspects state the dedicated tools cover.
514
153
  // Nudge toward them; never blocks execution.
@@ -519,19 +158,10 @@ export function createSteeringLadder(ctx) {
519
158
  }
520
159
  }
521
160
  },
522
- // Reviewer fix: a zero-tool turn must not bridge ANY cross-turn streak
523
- // across non-tool turns — that would fire level-2 (or the grep/read
524
- // detectors) early on a worker that paused to synthesize text mid-run.
161
+ // A zero-tool turn must not bridge any cross-turn ladder streak.
525
162
  resetAllReadOnlyStreak() {
526
163
  _allReadOnlyStreak = 0;
527
164
  _serialReadOnlyStreak = 0;
528
- _sameGrepStreak = 0;
529
- _sameGrepPrev = null;
530
- _lastGrepContentPaths = null;
531
- _identGrepStreak = 0;
532
- _identGrepToken = null;
533
- _lastFileScopedGrepNoCtxPaths = null;
534
- _readStreaks.clear();
535
165
  },
536
166
  };
537
167
  }
@@ -106,78 +106,3 @@ function _restoreCompactedBodies(tcVal, origVal, key) {
106
106
  }
107
107
  return tcVal;
108
108
  }
109
-
110
- // Marker prefix emitted by compactStoredToolArgString for every compacted
111
- // body/long arg (`[mixdog compacted <key>: <N> chars, sha256:…]`). Both the
112
- // body-only form (marker alone) and the long form (marker + head/tail preview)
113
- // begin with this exact span.
114
- const COMPACTED_MARKER_PREFIX = '[mixdog compacted ';
115
-
116
- function _isCompactedPlaceholderString(v) {
117
- return typeof v === 'string' && v.startsWith(COMPACTED_MARKER_PREFIX);
118
- }
119
-
120
- // Recursively drop every key whose stored value is a compacted-placeholder
121
- // string, at any depth (batch shapes like edits[].old_string carry nested
122
- // compacted bodies too). Non-placeholder values are kept verbatim.
123
- function _dropCompactedPlaceholders(val) {
124
- if (Array.isArray(val)) return val.map((item) => _dropCompactedPlaceholders(item));
125
- if (val && typeof val === 'object') {
126
- const out = {};
127
- for (const [k, v] of Object.entries(val)) {
128
- if (_isCompactedPlaceholderString(v)) continue; // drop the key entirely
129
- out[k] = _dropCompactedPlaceholders(v);
130
- }
131
- return out;
132
- }
133
- return val;
134
- }
135
-
136
- // A SUCCESSFUL tool call's compacted body/long arg (patch / old_string /
137
- // new_string / content / rewrite / command / script) is never needed again —
138
- // the edit already applied. compactToolCallsForHistory (run at push time,
139
- // before the outcome is known) leaves a `[mixdog compacted …]` placeholder in
140
- // its place. For a success that placeholder persists in the stored assistant
141
- // tool_use and is transmitted back as a prior apply_patch INPUT, which the
142
- // model copies verbatim as new patch args — caught by the patch guard, but only
143
- // after a wasted turn. Drop those placeholder keys so no resubmittable
144
- // placeholder body survives in history. Failed calls take the opposite path
145
- // (restoreToolCallBodyForId expands to the full original); success and failure
146
- // are mutually exclusive per call id, so this never races the restore path.
147
- // Cache-safe: the caller runs this before the assistant message is transmitted.
148
- export function dropCompactedBodyArgsForId(assistantMsg, callId) {
149
- if (!assistantMsg || !Array.isArray(assistantMsg.toolCalls) || !callId) return;
150
- const tc = assistantMsg.toolCalls.find((t) => t && t.id === callId);
151
- if (!tc || !tc.arguments || typeof tc.arguments !== 'object') return;
152
- tc.arguments = _dropCompactedPlaceholders(tc.arguments);
153
- }
154
-
155
- // PRE-SEND INVARIANT. The per-call paths above run during tool-batch
156
- // processing: a SUCCESS drops its placeholder body (dropCompactedBodyArgsForId),
157
- // a FAILURE restores the full original (restoreToolCallBodyForId). But those are
158
- // gated per call id and several loop paths never reach them — dedup /
159
- // repeat-failure early-continue, the iteration-cap refusal stub, or a call whose
160
- // id never matched. Any of those can leave a `[mixdog compacted …]` placeholder
161
- // sitting in a provider-visible assistant toolCall, which the model then copies
162
- // back verbatim as new apply_patch input. Enforce the invariant once, right
163
- // before provider.send: sweep EVERY assistant toolCall arguments tree and drop
164
- // any placeholder-valued key at any depth (batch shapes like edits[].old_string
165
- // carry nested compacted bodies too).
166
- //
167
- // This does NOT re-inline full bodies, so it never raises history token cost. A
168
- // failed call whose body was restored upstream holds real patch content (it no
169
- // longer starts with the compacted marker), so the sweep leaves it untouched —
170
- // the full body stays available exactly where the failed call needs it. The
171
- // downstream apply_patch guard (isCompactedPlaceholderPatch) remains as a
172
- // backstop for any placeholder a model synthesizes from prose.
173
- export function scrubCompactedPlaceholderToolCalls(messages) {
174
- if (!Array.isArray(messages)) return messages;
175
- for (const msg of messages) {
176
- if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.toolCalls)) continue;
177
- for (const tc of msg.toolCalls) {
178
- if (!tc || !tc.arguments || typeof tc.arguments !== 'object') continue;
179
- tc.arguments = _dropCompactedPlaceholders(tc.arguments);
180
- }
181
- }
182
- return messages;
183
- }
@@ -5,7 +5,6 @@
5
5
  // strip any trailing/orphaned tool_use|tool_result so provider.send sees a
6
6
  // valid transcript instead of leaking the 400 to the user.
7
7
  import { sanitizeToolPairs } from '../context-utils.mjs';
8
- import { scrubCompactedPlaceholderToolCalls } from './stored-tool-args.mjs';
9
8
 
10
9
  // Transcript pairing guard. Anthropic 400-rejects when an assistant message
11
10
  // ends with tool_use blocks and the next message isn't tool results for
@@ -98,9 +97,5 @@ export function repairTranscriptBeforeProviderSend(messages, sessionId = null) {
98
97
  messages.push(...sanitized);
99
98
  }
100
99
  _ensureTranscriptPairing(messages, sessionId);
101
- // Pre-send invariant: no provider-visible assistant toolCall may carry a
102
- // compacted `[mixdog compacted …]` placeholder body that looks like
103
- // submittable patch input. Runs after pairing repair, still before send.
104
- scrubCompactedPlaceholderToolCalls(messages);
105
100
  return messages;
106
101
  }
@@ -28,6 +28,7 @@ import { _mergePendingMessageEntries, drainPendingMessages } from './pending-mes
28
28
  import { persistIterationMetrics, applyAskTerminalUsageTotals } from './usage-metrics.mjs';
29
29
  import {
30
30
  updateSessionStage,
31
+ linkParentSignalToSession,
31
32
  markSessionAskStart,
32
33
  markSessionStreamDelta,
33
34
  markSessionDone,
@@ -164,11 +165,22 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
164
165
  normalizeStaleCompactingStage(preSession);
165
166
  askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
166
167
  const runtime = _touchRuntime(sessionId);
168
+ // Preserve any parent-abort link agent-dispatch established BEFORE we
169
+ // swap in a fresh controller: replacing runtime.controller drops the
170
+ // abort state, so an already/early-aborted parent signal (user ESC /
171
+ // owner abort landing during setup) would be lost and provider
172
+ // computation would run detached. Capture the linked signal, install the
173
+ // fresh controller, then re-cascade it — aborting the new controller
174
+ // immediately when the parent already fired, or re-arming the listener.
175
+ const _linkedParentSignal = runtime.parentAbortLink?.signal;
167
176
  // Fresh controller per ask — the previous ask's controller may have aborted.
168
177
  runtime.controller = createAbortController();
169
178
  runtime.generation = askGeneration;
170
179
  runtime.closed = false;
171
180
  runtime.session = preSession;
181
+ if (_linkedParentSignal instanceof AbortSignal) {
182
+ linkParentSignalToSession(sessionId, _linkedParentSignal);
183
+ }
172
184
  markSessionAskStart(sessionId);
173
185
  // Preprocessing is inside try so provider-not-available / trim failures
174
186
  // fall into the catch and mark the session as errored rather than
@@ -389,9 +401,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
389
401
  updateSessionStage(sessionId, stage);
390
402
  try { askOpts?.onStageChange?.(stage); } catch {}
391
403
  },
392
- onStreamDelta: () => {
393
- markSessionStreamDelta(sessionId).catch(() => {});
394
- try { askOpts?.onStreamDelta?.(); } catch {}
404
+ onStreamDelta: (kind = 'semantic') => {
405
+ markSessionStreamDelta(sessionId, kind).catch(() => {});
406
+ // Raw transport is an internal health signal, not model
407
+ // progress. Preserve the public callback's historical
408
+ // semantic-only contract.
409
+ if (kind !== 'transport') {
410
+ try { askOpts?.onStreamDelta?.(kind); } catch {}
411
+ }
395
412
  },
396
413
  }),
397
414
  );