mixdog 0.9.36 → 0.9.37

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 (103) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  40. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  41. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  43. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  44. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  46. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  47. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  49. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  51. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  53. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  55. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  57. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  58. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  60. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  62. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  63. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  64. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  65. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  67. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  68. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  69. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  70. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  71. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  72. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  73. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  74. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  75. package/src/runtime/shared/config.mjs +98 -12
  76. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  77. package/src/session-runtime/cwd-plugins.mjs +6 -3
  78. package/src/session-runtime/model-route-api.mjs +50 -2
  79. package/src/session-runtime/provider-auth-api.mjs +8 -1
  80. package/src/session-runtime/resource-api.mjs +22 -0
  81. package/src/session-runtime/runtime-core.mjs +13 -2
  82. package/src/session-runtime/settings-api.mjs +11 -2
  83. package/src/standalone/agent-tool.mjs +15 -9
  84. package/src/standalone/explore-tool.mjs +4 -1
  85. package/src/tui/App.jsx +6 -5
  86. package/src/tui/app/transcript-window.mjs +60 -10
  87. package/src/tui/app/use-transcript-window.mjs +2 -1
  88. package/src/tui/components/ContextPanel.jsx +4 -1
  89. package/src/tui/components/ToolExecution.jsx +15 -12
  90. package/src/tui/components/TranscriptItem.jsx +1 -1
  91. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  92. package/src/tui/dist/index.mjs +467 -151
  93. package/src/tui/engine/agent-job-feed.mjs +26 -6
  94. package/src/tui/engine/notification-plan.mjs +3 -3
  95. package/src/tui/engine/render-timing.mjs +104 -8
  96. package/src/tui/engine/session-api.mjs +58 -3
  97. package/src/tui/engine/session-flow.mjs +78 -28
  98. package/src/tui/engine/tool-card-results.mjs +28 -13
  99. package/src/tui/engine/turn.mjs +232 -156
  100. package/src/tui/engine.mjs +17 -8
  101. package/src/tui/index.jsx +10 -1
  102. package/src/workflows/default/WORKFLOW.md +8 -4
  103. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -226,11 +226,11 @@ function cacheRatioFromUsage(rows) {
226
226
 
227
227
  function cacheBreakExplanation(reason) {
228
228
  const r = String(reason || '');
229
- if (r === 'no_anchor') return 'delta 기준점/previous response 없음';
230
- if (r === 'input_prefix_mismatch') return '요청 prefix 이전 turn 달라짐';
231
- if (r.startsWith('response_output_mismatch')) return '이전 응답 output 체인이 기대값과 다름';
232
- if (r === 'cache_key_changed') return 'cache key 변경';
233
- return r ? '원인 미분류' : '원인 기록 없음';
229
+ if (r === 'no_anchor') return 'delta \uAE30\uC900\uC810/previous response \uC5C6\uC74C';
230
+ if (r === 'input_prefix_mismatch') return '\uC694\uCCAD prefix\uAC00 \uC774\uC804 turn\uACFC \uB2EC\uB77C\uC9D0';
231
+ if (r.startsWith('response_output_mismatch')) return '\uC774\uC804 \uC751\uB2F5 output \uCCB4\uC778\uC774 \uAE30\uB300\uAC12\uACFC \uB2E4\uB984';
232
+ if (r === 'cache_key_changed') return 'cache key \uBCC0\uACBD';
233
+ return r ? '\uC6D0\uC778 \uBBF8\uBD84\uB958' : '\uC6D0\uC778 \uAE30\uB85D \uC5C6\uC74C';
234
234
  }
235
235
 
236
236
  function classifyCacheBreakPhase(row, usageRows, transportRows) {
@@ -1342,27 +1342,27 @@ function buildExecutiveSummary(report) {
1342
1342
  const tools = report.summary.total_tool_ms || 0;
1343
1343
  const total = headers + stream + tools;
1344
1344
  const dominant = [['stream', stream], ['tools', tools], ['headers', headers]].sort((a, b) => b[1] - a[1])[0];
1345
- lines.push(`주 병목=${dominant[0]} ${fmtMs(dominant[1])}/${fmtMs(total)}; cache=${fmtPct((report.summary.cache_ratio ?? 0) * 100)}; turns=${report.summary.turns}; tools=${report.summary.tool_calls}`);
1345
+ lines.push(`\uC8FC \uBCD1\uBAA9=${dominant[0]} ${fmtMs(dominant[1])}/${fmtMs(total)}; cache=${fmtPct((report.summary.cache_ratio ?? 0) * 100)}; turns=${report.summary.turns}; tools=${report.summary.tool_calls}`);
1346
1346
  if (report.cache.cache_breaks.length) {
1347
1347
  const summary = countBy(report.cache.cache_breaks, (b) => `${b.reason || 'unknown'}/${b.phase || 'unknown'}`).slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
1348
- lines.push(`캐시 깨짐=${report.cache.cache_breaks.length} (${summary})`);
1348
+ lines.push(`\uCE90\uC2DC \uAE68\uC9D0=${report.cache.cache_breaks.length} (${summary})`);
1349
1349
  }
1350
1350
  if (report.cache.actual_cache_misses?.length) {
1351
1351
  const top = [...report.cache.actual_cache_misses].sort((a, b) => (b.uncached_tokens || 0) - (a.uncached_tokens || 0))[0];
1352
- lines.push(`실제 캐시 미스=${report.cache.actual_cache_misses.length} (top uncached=${fmtTok(top?.uncached_tokens || 0)})`);
1352
+ lines.push(`\uC2E4\uC81C \uCE90\uC2DC \uBBF8\uC2A4=${report.cache.actual_cache_misses.length} (top uncached=${fmtTok(top?.uncached_tokens || 0)})`);
1353
1353
  }
1354
1354
  if (report.compact?.cache_breaks?.length) {
1355
1355
  const summary = countBy(report.compact.cache_breaks, (b) => `${b.reason || 'unknown'}/${b.phase || 'unknown'}`).slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
1356
- lines.push(`컴팩트 호출=${report.compact.sessions.length} session, cache reset=${report.compact.cache_breaks.length} (${summary})`);
1356
+ lines.push(`\uCEF4\uD329\uD2B8 \uD638\uCD9C=${report.compact.sessions.length} session, cache reset=${report.compact.cache_breaks.length} (${summary})`);
1357
1357
  }
1358
1358
  if (report.tools.failures.length) {
1359
1359
  const summary = countBy(report.tools.failures, (f) => f.category || f.tool || 'unknown').slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
1360
- lines.push(`툴 실패=${report.tools.failures.length} (${summary})`);
1360
+ lines.push(`\uD234 \uC2E4\uD328=${report.tools.failures.length} (${summary})`);
1361
1361
  }
1362
1362
  const topGrowth = report.tokens.growth_turns[0];
1363
- if (topGrowth) lines.push(`토큰 증폭=${topGrowth.agent || '-'} it=${topGrowth.turn_label} promptΔ=${fmtTok(topGrowth.prompt_delta)} out=${fmtTok(topGrowth.output_tokens + topGrowth.thinking_tokens)}`);
1363
+ if (topGrowth) lines.push(`\uD1A0\uD070 \uC99D\uD3ED=${topGrowth.agent || '-'} it=${topGrowth.turn_label} promptΔ=${fmtTok(topGrowth.prompt_delta)} out=${fmtTok(topGrowth.output_tokens + topGrowth.thinking_tokens)}`);
1364
1364
  const topChurn = report.tools.duplicates[0];
1365
- if (topChurn) lines.push(`툴 헛돎=${topChurn.tool}×${topChurn.count} ${compactText(topChurn.target, 90)}`);
1365
+ if (topChurn) lines.push(`\uD234 \uD5DB\uB3CE=${topChurn.tool}×${topChurn.count} ${compactText(topChurn.target, 90)}`);
1366
1366
  return lines;
1367
1367
  }
1368
1368
 
@@ -1626,7 +1626,7 @@ function renderText(report) {
1626
1626
  if (report.tools.failures.length) {
1627
1627
  lines.push('tool failures:');
1628
1628
  for (const f of report.tools.failures.slice(0, 10)) {
1629
- lines.push(`- ${f.agent || '-'} it=${f.iteration ?? '-'} ${f.tool || '-'} ${fmtMs(f.tool_ms)} category=${f.category || '-'} reason=${f.reason || 'trace 상세 stderr/예외 미저장'} result=${Math.round(f.bytes / 1024)}KB/${f.lines}l: ${f.target}`);
1629
+ lines.push(`- ${f.agent || '-'} it=${f.iteration ?? '-'} ${f.tool || '-'} ${fmtMs(f.tool_ms)} category=${f.category || '-'} reason=${f.reason || 'trace\uC5D0 \uC0C1\uC138 stderr/\uC608\uC678 \uBBF8\uC800\uC7A5'} result=${Math.round(f.bytes / 1024)}KB/${f.lines}l: ${f.target}`);
1630
1630
  if (opts.failuresOnly && f.preview) {
1631
1631
  const preview = compactText(f.preview, 700);
1632
1632
  lines.push(` preview: ${preview}`);
@@ -7,6 +7,7 @@ import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
7
7
  // a valid session key). No provider/runTurn wiring needed.
8
8
  function makeFlow() {
9
9
  let seq = 0;
10
+ let runTurns = 0;
10
11
  const state = { queued: [], busy: false };
11
12
  const bag = {
12
13
  runtime: { id: null },
@@ -27,33 +28,93 @@ function makeFlow() {
27
28
  routeState: {},
28
29
  syncContextStats: () => {},
29
30
  flushDeferredExecutionPendingResumeKick: () => {},
31
+ runTurn: async () => {
32
+ runTurns += 1;
33
+ return 'done';
34
+ },
30
35
  };
31
- return { flow: createSessionFlow(bag), bag };
36
+ return { flow: createSessionFlow(bag), bag, getRunTurns: () => runTurns };
32
37
  }
33
38
 
34
- test('drainPendingSteering empties every non-slash priority/mode bucket in one call', () => {
39
+ test('drainPendingSteering drains prompt/next mid-turn but leaves later notifications', () => {
35
40
  const { flow, bag } = makeFlow();
36
- // Concurrent user steering (prompt bucket) + task notification (its own
37
- // priority/mode bucket) queued while a turn is running.
38
41
  bag.pending.push(flow.makeQueueEntry('steer the turn', { mode: 'prompt' }));
39
42
  bag.pending.push(flow.makeQueueEntry('task finished', { mode: 'task-notification', key: 'task-1' }));
40
43
 
41
44
  const out = flow.drainPendingSteering();
42
45
 
43
- assert.equal(bag.pending.length, 0, 'no bucket left pending to spawn a follow-up turn');
44
- assert.equal(out.length, 2, 'both buckets injected into the current turn');
45
- assert.ok(out.some((v) => String(typeof v === 'string' ? v : v.text).includes('steer the turn')));
46
- assert.ok(out.some((v) => String(typeof v === 'string' ? v : v.text).includes('task finished')));
46
+ assert.equal(out.length, 1, 'next-priority prompt is injected into the active continuation');
47
+ assert.equal(out[0], 'steer the turn');
48
+ assert.equal(bag.pending.length, 1, 'later task notification waits for post-turn or explicit later flush');
49
+ assert.equal(bag.pending[0].content, 'task finished');
47
50
  });
48
51
 
49
- test('drainPendingSteering leaves slash commands pending for the post-turn processor', () => {
52
+ test('drainPendingSteering can explicitly flush later notifications', () => {
53
+ const { flow, bag } = makeFlow();
54
+ bag.pending.push(flow.makeQueueEntry('task finished', { mode: 'task-notification', key: 'task-1' }));
55
+
56
+ const out = flow.drainPendingSteering({ maxPriority: 'later' });
57
+
58
+ assert.equal(out.length, 1);
59
+ assert.equal(out[0], 'task finished');
60
+ assert.equal(bag.pending.length, 0);
61
+ });
62
+
63
+ test('drainPendingSteering accepts runtime callback signature', () => {
64
+ const { flow, bag } = makeFlow();
65
+ bag.pending.push(flow.makeQueueEntry('task finished', { mode: 'task-notification', key: 'task-1' }));
66
+
67
+ const out = flow.drainPendingSteering('session-id', { maxPriority: 'later' });
68
+
69
+ assert.deepEqual(out, ['task finished']);
70
+ assert.equal(bag.pending.length, 0);
71
+ });
72
+
73
+ test('drained notification key remains deduped and displayed key is not cleared', () => {
74
+ const { flow, bag } = makeFlow();
75
+ bag.getState().busy = true;
76
+ assert.equal(flow.enqueue('task finished', { mode: 'task-notification', key: 'task-1' }), true);
77
+ bag.displayedExecutionNotificationKeys.add('task-1');
78
+
79
+ const out = flow.drainPendingSteering({ maxPriority: 'later' });
80
+
81
+ assert.deepEqual(out, ['task finished']);
82
+ assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }), false);
83
+ assert.equal(bag.displayedExecutionNotificationKeys.has('task-1'), true);
84
+ });
85
+
86
+ test('drainPendingSteering leaves slash commands pending', () => {
50
87
  const { flow, bag } = makeFlow();
51
88
  bag.pending.push(flow.makeQueueEntry('/clear', { mode: 'prompt' }));
52
89
  bag.pending.push(flow.makeQueueEntry('steer text', { mode: 'prompt' }));
53
90
 
54
91
  const out = flow.drainPendingSteering();
55
92
 
56
- assert.equal(out.length, 1, 'only the non-slash entry drained');
57
- assert.equal(bag.pending.length, 1, 'slash command stays queued');
93
+ assert.equal(out.length, 1, 'non-slash prompt drains');
94
+ assert.equal(out[0], 'steer text');
95
+ assert.equal(bag.pending.length, 1, 'slash command stays queued for post-turn processing');
96
+ assert.equal(bag.pending[0].content, '/clear');
97
+ });
98
+
99
+ test('enqueue while busy does not start a parallel Lead turn', async () => {
100
+ const { flow, bag, getRunTurns } = makeFlow();
101
+ bag.getState().busy = true;
102
+
103
+ assert.equal(flow.enqueue('scheduled message', { mode: 'task-notification' }), true);
104
+ await new Promise((resolve) => setImmediate(resolve));
105
+
106
+ assert.equal(getRunTurns(), 0, 'busy enqueue must wait for active turn boundary');
107
+ assert.equal(bag.pending.length, 1, 'message remains pending for post-turn drain');
108
+ });
109
+
110
+ test('post-turn drain does not send queued slash command to model', async () => {
111
+ const { flow, bag, getRunTurns } = makeFlow();
112
+ bag.pending.push(flow.makeQueueEntry('/clear', { mode: 'prompt' }));
113
+
114
+ await flow.drain();
115
+ await new Promise((resolve) => setImmediate(resolve));
116
+
117
+ assert.equal(getRunTurns(), 0, 'slash command must not be runTurn model text');
118
+ assert.equal(bag.pending.length, 1, 'slash command remains for command dispatcher');
58
119
  assert.equal(bag.pending[0].content, '/clear');
59
120
  });
@@ -0,0 +1,114 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
4
+ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
5
+
6
+ // Regression harness for prompt loss when a submit races an in-flight session
7
+ // command (commandBusy) or an auto-clear. Wires the real session-flow queue
8
+ // (enqueue/drain) + the real submit() against a minimal bag whose set() mirrors
9
+ // engine.mjs's commandBusy-release drain kick.
10
+ function makeEngine({ autoClearBeforeSubmit } = {}) {
11
+ let seq = 0;
12
+ const executed = [];
13
+ let state = { queued: [], busy: false, commandBusy: false };
14
+ const bag = {
15
+ runtime: { id: null, consumePendingSessionReset: () => null },
16
+ nextId: () => `id_${++seq}`,
17
+ tuiDebug: () => {},
18
+ flags: {},
19
+ pending: [],
20
+ listeners: new Set(),
21
+ pendingNotificationKeys: new Set(),
22
+ displayedExecutionNotificationKeys: new Set(),
23
+ getState: () => state,
24
+ set: (patch) => {
25
+ if (!patch || typeof patch !== 'object') return false;
26
+ const released = state.commandBusy === true
27
+ && Object.prototype.hasOwnProperty.call(patch, 'commandBusy')
28
+ && patch.commandBusy === false;
29
+ state = { ...state, ...patch };
30
+ if (released) queueMicrotask(() => { void bag.drain?.(); });
31
+ return true;
32
+ },
33
+ pushItem: () => {},
34
+ patchItem: () => {},
35
+ replaceItems: (x) => x,
36
+ pushNotice: () => {},
37
+ pushUserOrSyntheticItem: (text) => { executed.push(text); },
38
+ autoClearState: () => ({ enabled: false }),
39
+ agentStatusState: () => ({}),
40
+ routeState: () => ({}),
41
+ syncContextStats: () => {},
42
+ denyAllToolApprovals: () => {},
43
+ updateAgentJobCard: () => {},
44
+ requeueEntriesFront: () => {},
45
+ resetStatsAndSyncContext: () => {},
46
+ flushDeferredExecutionPendingResumeKick: () => {},
47
+ runTurn: async () => 'ok',
48
+ };
49
+ Object.assign(bag, createSessionFlow(bag));
50
+ if (autoClearBeforeSubmit) bag.autoClearBeforeSubmit = autoClearBeforeSubmit;
51
+ const api = createEngineApiA(bag);
52
+ return {
53
+ api,
54
+ bag,
55
+ getExecuted: () => executed,
56
+ getState: () => bag.getState(),
57
+ mutateState: (mutator) => { mutator(state); },
58
+ };
59
+ }
60
+
61
+ const tick = () => new Promise((r) => setTimeout(r, 0));
62
+
63
+ test('submit during commandBusy queues the prompt instead of dropping it', async () => {
64
+ const { api, bag, getExecuted } = makeEngine();
65
+ bag.set({ commandBusy: true });
66
+ const ok = api.submit('queued while busy');
67
+ assert.equal(ok, true, 'submit accepted (not dropped)');
68
+ assert.equal(bag.pending.length, 1, 'prompt preserved in queue');
69
+ await tick();
70
+ assert.equal(getExecuted().length, 0, 'drain bails while commandBusy');
71
+ bag.set({ commandBusy: false });
72
+ await tick(); await tick();
73
+ assert.deepEqual(getExecuted(), ['queued while busy'], 'prompt runs after command releases');
74
+ assert.equal(bag.pending.length, 0);
75
+ });
76
+
77
+ test('blocked drain retries even if commandBusy is cleared outside set hook', async () => {
78
+ const { api, bag, getExecuted, mutateState } = makeEngine();
79
+ bag.set({ commandBusy: true });
80
+ assert.equal(api.submit('queued until direct release'), true);
81
+ assert.equal(bag.pending.length, 1);
82
+ await tick();
83
+ assert.equal(getExecuted().length, 0);
84
+
85
+ mutateState((state) => { state.commandBusy = false; });
86
+ await new Promise((r) => setTimeout(r, 80));
87
+ assert.deepEqual(getExecuted(), ['queued until direct release']);
88
+ assert.equal(bag.pending.length, 0);
89
+ });
90
+
91
+ test('idle submit runs the prompt after autoClearBeforeSubmit resolves', async () => {
92
+ const { api, getExecuted, bag } = makeEngine();
93
+ const ok = api.submit('idle prompt');
94
+ assert.equal(ok, true);
95
+ await tick(); await tick();
96
+ assert.deepEqual(getExecuted(), ['idle prompt']);
97
+ assert.equal(bag.pending.length, 0);
98
+ });
99
+
100
+ test('submit still enqueues when autoClearBeforeSubmit rejects', async () => {
101
+ const { api, getExecuted } = makeEngine({
102
+ autoClearBeforeSubmit: () => Promise.reject(new Error('compaction timed out')),
103
+ });
104
+ const ok = api.submit('survives rejection');
105
+ assert.equal(ok, true);
106
+ await tick(); await tick();
107
+ assert.deepEqual(getExecuted(), ['survives rejection'], 'rejected auto-clear must not lose the prompt');
108
+ });
109
+
110
+ test('empty and whitespace submits are still rejected', () => {
111
+ const { api, bag } = makeEngine();
112
+ assert.equal(api.submit(' '), false);
113
+ assert.equal(bag.pending.length, 0);
114
+ });
@@ -193,7 +193,12 @@ function assertOk(name, result, pattern = null) {
193
193
 
194
194
  {
195
195
  const prevTraceDisable = process.env.MIXDOG_AGENT_TRACE_DISABLE;
196
+ const prevOaiTransport = process.env.MIXDOG_OAI_TRANSPORT;
196
197
  process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
198
+ // This smoke intentionally verifies the pinned WS-only escape hatch. Default
199
+ // transport is now refs-style auto (WS-first with HTTP fallback), so
200
+ // forceHttpFallback would legitimately call fakeHttp unless we pin ws-delta.
201
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
197
202
  try {
198
203
  const provider = new OpenAIOAuthProvider({});
199
204
  provider.ensureAuth = async () => ({ access_token: 'fake-token' });
@@ -252,6 +257,8 @@ function assertOk(name, result, pattern = null) {
252
257
  } finally {
253
258
  if (prevTraceDisable == null) delete process.env.MIXDOG_AGENT_TRACE_DISABLE;
254
259
  else process.env.MIXDOG_AGENT_TRACE_DISABLE = prevTraceDisable;
260
+ if (prevOaiTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
261
+ else process.env.MIXDOG_OAI_TRANSPORT = prevOaiTransport;
255
262
  }
256
263
  }
257
264
 
@@ -485,6 +492,28 @@ const findOut = await executeBuiltinTool('find', {
485
492
  }, root);
486
493
  assertOk('find', findOut, /scripts[\\/]tool-smoke\.mjs/i);
487
494
 
495
+ // find query[] batch: fan-out must emit one section per query in caller order
496
+ // and share the broad enumeration sweep across queries (single-flight dedup).
497
+ const findBatchOut = await executeBuiltinTool('find', {
498
+ query: ['tool smoke', 'smoke'],
499
+ path: '.',
500
+ head_limit: 5,
501
+ }, root);
502
+ {
503
+ const s = String(findBatchOut);
504
+ const iA = s.indexOf('# find tool smoke');
505
+ const iB = s.indexOf('# find smoke');
506
+ if (iA < 0 || iB < 0) {
507
+ throw new Error(`find query[] must emit a section per query:\n${s.slice(0, 600)}`);
508
+ }
509
+ if (!(iA < iB)) {
510
+ throw new Error(`find query[] must preserve caller order:\n${s.slice(0, 600)}`);
511
+ }
512
+ if (!/scripts[\\/]tool-smoke\.mjs/i.test(s)) {
513
+ throw new Error(`find query[] sections must carry match bodies:\n${s.slice(0, 600)}`);
514
+ }
515
+ }
516
+
488
517
  const readOut = await executeBuiltinTool('read', {
489
518
  path: 'scripts/smoke.mjs',
490
519
  offset: 0,
@@ -743,13 +772,22 @@ const shellOut = await shellOutPromise;
743
772
  assertOk('bash explicit shell/cwd', shellOut, /v\d+\.\d+\.\d+/);
744
773
 
745
774
  const shellFailOut = await shellFailOutPromise;
746
- if (!/^Error[\s:[]/.test(String(shellFailOut)) || !/\[exit code: 7\]/.test(String(shellFailOut))) {
747
- throw new Error(`bash non-zero exit must be classified as Error:\n${shellFailOut}`);
775
+ if (!/^Error[\s:[]/.test(String(shellFailOut)) || !/\[shell-run-failed\]/.test(String(shellFailOut)) || !/\[exit code: 7\]/.test(String(shellFailOut))) {
776
+ throw new Error(`bash non-zero exit must be classified as shell-run-failed Error:\n${shellFailOut}`);
748
777
  }
749
778
 
750
779
  const shellTimeoutOut = await shellTimeoutOutPromise;
751
- if (!/^Error[\s:[]/.test(String(shellTimeoutOut)) || !/\[timeout: 500ms\b/.test(String(shellTimeoutOut))) {
752
- throw new Error(`bash timeout must be milliseconds and classified as Error:\n${shellTimeoutOut}`);
780
+ if (!/^Error[\s:[]/.test(String(shellTimeoutOut)) || !/\[shell-run-failed\]/.test(String(shellTimeoutOut)) || !/\[timeout: 500ms\b/.test(String(shellTimeoutOut))) {
781
+ throw new Error(`bash timeout must be milliseconds and classified as shell-run-failed Error:\n${shellTimeoutOut}`);
782
+ }
783
+
784
+ const shellArgFailOut = await executeBuiltinTool('shell', {
785
+ command: '',
786
+ cwd: root,
787
+ shell: 'powershell',
788
+ }, root);
789
+ if (!/^Error[\s:[]/.test(String(shellArgFailOut)) || !/\[shell-tool-failed\]/.test(String(shellArgFailOut))) {
790
+ throw new Error(`shell tool/preflight failures must be classified as shell-tool-failed Error:\n${shellArgFailOut}`);
753
791
  }
754
792
 
755
793
  // Auto-promotion: a sync foreground command still running past the (soft)
@@ -1207,6 +1245,14 @@ const explorerPrompt = buildExplorerPrompt('where is <agent> & status?');
1207
1245
  if (!explorerPrompt.includes('&lt;agent&gt;') || !explorerPrompt.includes('&amp;') || /verdicts, ratings, or recommendations/.test(explorerPrompt) === false) {
1208
1246
  throw new Error(`explorer prompt contract failed: ${explorerPrompt}`);
1209
1247
  }
1248
+ if (
1249
+ !/STOP and answer NOW/.test(explorerPrompt)
1250
+ || !/Turns 2-3 exist SOLELY as zero-hit recovery/.test(explorerPrompt)
1251
+ || !/HARD max 3 tool turns/.test(explorerPrompt)
1252
+ || !/turn 1\/3/.test(explorerPrompt)
1253
+ ) {
1254
+ throw new Error(`explorer prompt must force immediate answer on a specific-token anchor while preserving the 3-turn cap: ${explorerPrompt}`);
1255
+ }
1210
1256
  setInternalToolsProvider({
1211
1257
  executor: async () => 'tool-smoke internal tool',
1212
1258
  tools: [
@@ -1737,8 +1783,35 @@ if (/line\/context/i.test(JSON.stringify(readTool?.inputSchema || {}))) {
1737
1783
  if (!/applyToCurrentSession = options\?\.applyToCurrentSession === true/.test(setRouteBlock)) {
1738
1784
  throw new Error('setRoute must default applyToCurrentSession to false (model changes apply to the next session only)');
1739
1785
  }
1740
- if (!/if \(!applyToCurrentSession\)/.test(setRouteBlock) || !/return (?:route|getRoute\(\));/.test(setRouteBlock)) {
1741
- throw new Error('setRoute must early-return before touching the live session when applyToCurrentSession is false');
1786
+ if (!/const applyLive = applyToCurrentSession \|\| currentSessionEmpty/.test(setRouteBlock)
1787
+ || !/if \(!applyLive\)/.test(setRouteBlock)
1788
+ || !/return getRoute\(\);/.test(setRouteBlock)) {
1789
+ throw new Error('setRoute must early-return before touching a non-empty live session when applyToCurrentSession is false');
1790
+ }
1791
+ // Empty current session must apply live so /model before the first chat
1792
+ // updates route + statusline at once, but compact summary anchors are route
1793
+ // history and must keep a compacted session next-session-only. Seeded system
1794
+ // or synthetic assistant/tool rows alone must NOT make the session non-empty.
1795
+ if (!/!hasRouteHistoryMessage\(session\.messages\)/.test(setRouteBlock)
1796
+ || !/!hasRouteHistoryMessage\(session\.liveTurnMessages\)/.test(setRouteBlock)
1797
+ || !/SUMMARY_PREFIX/.test(runtimeSrc)
1798
+ || !/hasUserConversationMessage\(list\) \|\| list\.some\(isSummaryAnchorMessage\)/.test(runtimeSrc)
1799
+ || !/function hasRouteHistoryMessage/.test(runtimeSrc)) {
1800
+ throw new Error('setRoute must apply live only to route-empty sessions and must treat compact summary anchors as non-empty route history');
1801
+ }
1802
+ if (!/createCurrentSession\('model-switch-empty'\)/.test(setRouteBlock)
1803
+ || !/createCurrentSession\('model-switch-empty-drain'\)/.test(setRouteBlock)
1804
+ || !/const emptySession = getSession\(\)/.test(setRouteBlock)
1805
+ || !/cli-model-switch-empty/.test(setRouteBlock)
1806
+ || !/pushTranscriptRebind\?\.\(\)/.test(setRouteBlock)
1807
+ || !/invalidatePreSessionToolSurface\?\.\(\)/.test(setRouteBlock)) {
1808
+ throw new Error('setRoute must drain in-flight create then recreate/rebind empty live sessions so provider-specific BP1/tool surface is rebuilt for /model before first chat');
1809
+ }
1810
+ const sessionLifecycleSrc = readMjsSources('src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs');
1811
+ const updateSessionRouteBlock = sessionLifecycleSrc.match(/export function updateSessionRoute\(id, route = \{\}\) \{[\s\S]*?\n\}/)?.[0] || '';
1812
+ if (!/session\.promptCacheKey = providerCacheKey\(session\.provider\)/.test(updateSessionRouteBlock)
1813
+ || !/session\.providerCacheOpts = buildSessionProviderCacheOpts\(session\.provider, session\.id, session\.agent\) \|\| null/.test(updateSessionRouteBlock)) {
1814
+ throw new Error('updateSessionRoute must refresh provider-scoped prompt cache fields when an empty live session changes provider/model');
1742
1815
  }
1743
1816
  const engineSrc = [readMjsSources('src/tui/engine.mjs'), readMjsSources('src/tui/engine')].join('\n');
1744
1817
  if (/setRoute\(\{ model: m \}, \{ applyToCurrentSession: true \}\)/.test(engineSrc)) {
@@ -2074,13 +2147,13 @@ const grepPathDescription = grepTool?.inputSchema?.properties?.path?.description
2074
2147
  const grepGlobDescription = grepTool?.inputSchema?.properties?.glob?.description || '';
2075
2148
  const grepOutputModeDescription = grepTool?.inputSchema?.properties?.output_mode?.description || '';
2076
2149
  const grepHeadLimitDescription = grepTool?.inputSchema?.properties?.head_limit?.description || '';
2077
- if (!/Array = variants in one call/i.test(grepPatternDescription) || !/Verified file or dir/i.test(grepPathDescription)) {
2150
+ if (!/Array = variants in one call/i.test(grepPatternDescription) || !/Verified file\/dir/i.test(grepPathDescription)) {
2078
2151
  throw new Error('grep schema must keep compact pattern/path guidance');
2079
2152
  }
2080
- if (!/verified file\/dir scope/i.test(grepTool?.description || '') || !/Unknown scope.*find\/glob first/i.test(grepTool?.description || '')) {
2153
+ if (!/verified scope/i.test(grepTool?.description || '') || !/Unknown path\/name.*find first/i.test(grepTool?.description || '')) {
2081
2154
  throw new Error('grep description must require verified scopes and locator-first unknown paths');
2082
2155
  }
2083
- if (!/narrow scope/i.test(grepGlobDescription)) {
2156
+ if (!/Glob filter/i.test(grepGlobDescription) || !/no guessed src\/\*\*/i.test(grepGlobDescription)) {
2084
2157
  throw new Error('grep glob schema must describe scope narrowing');
2085
2158
  }
2086
2159
  if (!/files_with_matches\/count/i.test(grepOutputModeDescription) || !/content_with_context/i.test(grepOutputModeDescription)) {
@@ -2098,7 +2171,7 @@ const listTool = BUILTIN_TOOLS.find((tool) => tool.name === 'list');
2098
2171
  if (!/exact glob from verified roots/i.test(globTool?.description || '')) {
2099
2172
  throw new Error('glob description must route exact-pattern unknown paths before read/grep/list');
2100
2173
  }
2101
- if (!/unverified path\/name guesses/i.test(findTool?.description || '') || !/returns verified paths/i.test(findTool?.description || '')) {
2174
+ if (!/Partial path\/name lookup/i.test(findTool?.description || '') || !/verify roots before grep\/glob/i.test(findTool?.description || '')) {
2102
2175
  throw new Error('find description must advertise unverified path/name lookup and verified outputs');
2103
2176
  }
2104
2177
  if (!/List verified directories/i.test(listTool?.description || '') || !/Unknown dir.*find first/i.test(listTool?.description || '') || !/Verified directory/i.test(listTool?.inputSchema?.properties?.path?.description || '')) {
package/src/app.mjs CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { performance } from 'node:perf_hooks';
5
+ import { ensureProcessListenerHeadroom } from './runtime/shared/process-listener-headroom.mjs';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  // --workflow is consumed (with its value) but ignored on the headless role
@@ -35,7 +36,7 @@ const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__
35
36
  // which legitimately exceeds Node's default 10-listener warning threshold in a
36
37
  // fully-loaded runtime. Raise the cap once at the CLI entry so a benign
37
38
  // MaxListenersExceededWarning never leaks into the user's terminal.
38
- try { process.setMaxListeners(Math.max(process.getMaxListeners(), 64)); } catch { /* ignore */ }
39
+ ensureProcessListenerHeadroom(64);
39
40
 
40
41
  function bootProfile(event, fields = {}) {
41
42
  if (!BOOT_PROFILE_ENABLED) return;
@@ -51,6 +51,14 @@ or inconclusive, keep the CORE entry.
51
51
  - `keep` — durable and already one short clause.
52
52
  - `update` — durable but verbose or multi-sentence → rewrite as one ≤120-char clause.
53
53
  - `merge` — duplicates another entry → fold into the survivor (same project pool).
54
+ - `reclassify` — the entry is filed under the WRONG project pool. Its subject
55
+ clearly belongs to a different pool shown among the entries under review: a
56
+ project-specific fact stored in COMMON belongs under that project, or a generic
57
+ cross-project rule mis-filed under one project belongs in COMMON. Name the
58
+ correct destination: `<id>|reclassify|<project_slug|common>`. The target must
59
+ be COMMON or a project pool that already appears on another entry above — never
60
+ invent a new pool. Reclassify only MOVES the entry (its text is unchanged and
61
+ nothing is deleted); use it only when the mis-scoping is unambiguous, else `keep`.
54
62
  - `superseded` — a NEWER active core entry directly CONTRADICTS the value/state
55
63
  this older entry asserts (same
56
64
  subject, changed fact: a renamed thing, a moved location, a reversed
@@ -103,6 +111,7 @@ One line per entry id, any order:
103
111
  <id>|keep
104
112
  <id>|update|<element>|<summary>
105
113
  <id>|merge|<target_id>|<source_ids_csv>
114
+ <id>|reclassify|<project_slug|common>
106
115
  <id>|superseded|<newer_id>
107
116
  <id>|delete|<reason>
108
117
  ```