mixdog 0.9.36 → 0.9.38

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 (115) 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/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-sweep.mjs — DRY-RUN ONLY session-store retention reporter.
4
+ *
5
+ * Reports which session files a prune would reclaim WITHOUT touching disk:
6
+ * NO unlink, NO writes to session-summaries.json, NO mutation of store.mjs
7
+ * runtime behavior. It only reads.
8
+ *
9
+ * Reuses the real store helpers read-only:
10
+ * - getPluginData() → resolves the live data dir (…/.mixdog/data)
11
+ * - summaryIndexPath()/listStoredSessionSummaries() → authoritative
12
+ * per-session lifecycle rows (updatedAt/closed/status), avoiding a full
13
+ * 476MB JSON.parse of every session file.
14
+ *
15
+ * Candidate policy (conservative; keep everything else):
16
+ * - closed/tombstoned sessions (row.closed === true || status === 'closed')
17
+ * ONLY when their closedAt/updatedAt is older than --min-closed-age-days
18
+ * (default 7d) — a recently-closed session may still be resumed, so it is
19
+ * kept until the gate elapses.
20
+ * - OR sessions older than --max-age-days by updatedAt (default 30d)
21
+ * Thresholds are params: --max-age-days=<n> --min-closed-age-days=<n> --now=<epochMs>
22
+ *
23
+ * This tool NEVER deletes. It only prints a report.
24
+ */
25
+ import { existsSync, readdirSync, statSync } from 'fs';
26
+ import { readFileSync } from 'fs';
27
+ import { join } from 'path';
28
+ import { getPluginData } from '../src/runtime/agent/orchestrator/config.mjs';
29
+ import {
30
+ summaryIndexPath,
31
+ listStoredSessionSummaries,
32
+ } from '../src/runtime/agent/orchestrator/session/store.mjs';
33
+
34
+ // ── Cheap top-level-only scalar scan ────────────────────────────────────────
35
+ // Same depth-1 tokenizer idea as lifecycle-scan.mjs's scanTopLevelLifecycle
36
+ // (bracket-depth + string-escape aware; the whole `messages` array is skipped
37
+ // by depth counting, never parsed), extended to capture the lifecycle+age
38
+ // scalars we classify on (closed/status/updatedAt/createdAt). This lets the
39
+ // report read the AUTHORITATIVE per-file lifecycle without a full 476MB
40
+ // JSON.parse and without depending on the (stale) summary index.
41
+ const WANT = new Set(['closed', 'status', 'updatedAt', 'createdAt', 'closedAt']);
42
+ function isWs(ch) { return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; }
43
+ function skipString(raw, i) {
44
+ const len = raw.length;
45
+ i++;
46
+ while (i < len) {
47
+ const ch = raw[i];
48
+ if (ch === '\\') { i += 2; continue; }
49
+ if (ch === '"') return i + 1;
50
+ i++;
51
+ }
52
+ return i;
53
+ }
54
+ function skipValue(raw, i) {
55
+ const len = raw.length;
56
+ const c = raw[i];
57
+ if (c === '"') return skipString(raw, i);
58
+ if (c === '{' || c === '[') {
59
+ let depth = 1; i++;
60
+ while (i < len && depth > 0) {
61
+ const ch = raw[i];
62
+ if (ch === '"') { i = skipString(raw, i); continue; }
63
+ if (ch === '{' || ch === '[') depth++;
64
+ else if (ch === '}' || ch === ']') depth--;
65
+ i++;
66
+ }
67
+ return i;
68
+ }
69
+ while (i < len && raw[i] !== ',' && raw[i] !== '}' && raw[i] !== ']' && !isWs(raw[i])) i++;
70
+ return i;
71
+ }
72
+ function scanTopLevelScalars(raw) {
73
+ const len = raw.length;
74
+ let i = 0;
75
+ while (i < len && isWs(raw[i])) i++;
76
+ if (raw[i] !== '{') return null;
77
+ i++;
78
+ const out = {};
79
+ while (i < len) {
80
+ while (i < len && isWs(raw[i])) i++;
81
+ if (i >= len) return out;
82
+ if (raw[i] === '}') return out;
83
+ if (raw[i] === ',') { i++; continue; }
84
+ if (raw[i] !== '"') return out;
85
+ const keyStart = i;
86
+ i = skipString(raw, i);
87
+ let key;
88
+ try { key = JSON.parse(raw.slice(keyStart, i)); } catch { return out; }
89
+ while (i < len && isWs(raw[i])) i++;
90
+ if (raw[i] !== ':') return out;
91
+ i++;
92
+ while (i < len && isWs(raw[i])) i++;
93
+ const valStart = i;
94
+ i = skipValue(raw, i);
95
+ if (WANT.has(key)) {
96
+ try { out[key] = JSON.parse(raw.slice(valStart, i)); } catch { /* ignore */ }
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function parseArgs(argv) {
103
+ const out = { maxAgeDays: 30, minClosedAgeDays: 7, now: Date.now() };
104
+ for (const arg of argv) {
105
+ const m = /^--([^=]+)=(.*)$/.exec(arg);
106
+ if (!m) continue;
107
+ const [, key, val] = m;
108
+ if (key === 'max-age-days') out.maxAgeDays = Number(val);
109
+ else if (key === 'min-closed-age-days') out.minClosedAgeDays = Number(val);
110
+ else if (key === 'now') out.now = Number(val);
111
+ }
112
+ if (!Number.isFinite(out.maxAgeDays) || out.maxAgeDays < 0) out.maxAgeDays = 30;
113
+ if (!Number.isFinite(out.minClosedAgeDays) || out.minClosedAgeDays < 0) out.minClosedAgeDays = 7;
114
+ if (!Number.isFinite(out.now) || out.now <= 0) out.now = Date.now();
115
+ return out;
116
+ }
117
+
118
+ function fmtBytes(n) {
119
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
120
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
121
+ let i = 0;
122
+ let v = n;
123
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
124
+ return `${v.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
125
+ }
126
+
127
+ function fmtTs(ms) {
128
+ if (!Number.isFinite(ms) || ms <= 0) return 'n/a';
129
+ try { return new Date(ms).toISOString(); } catch { return String(ms); }
130
+ }
131
+
132
+ function main() {
133
+ const { maxAgeDays, minClosedAgeDays, now } = parseArgs(process.argv.slice(2));
134
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
135
+ const minClosedAgeMs = minClosedAgeDays * 24 * 60 * 60 * 1000;
136
+
137
+ const dir = join(getPluginData(), 'sessions');
138
+ if (!existsSync(dir)) {
139
+ process.stdout.write(`[session-sweep] no sessions dir at ${dir}\n`);
140
+ return;
141
+ }
142
+
143
+ // Authoritative lifecycle rows (read-only). rebuildIfMissing:false so this
144
+ // report never triggers a summary-index write.
145
+ const rows = listStoredSessionSummaries({ rebuildIfMissing: false });
146
+ const rowById = new Map();
147
+ for (const r of rows) if (r?.id) rowById.set(r.id, r);
148
+
149
+ // Disk scan for sizes + mtime fallback. `.hb` sidecar bytes are attributed
150
+ // to their session so reclaimable bytes reflect the full on-disk footprint.
151
+ const files = readdirSync(dir);
152
+ const jsonFiles = files.filter((f) => f.endsWith('.json'));
153
+ const hbSizeById = new Map();
154
+ for (const f of files) {
155
+ if (!f.endsWith('.hb')) continue;
156
+ const id = f.slice(0, -3);
157
+ try { hbSizeById.set(id, statSync(join(dir, f)).size || 0); } catch { /* ignore */ }
158
+ }
159
+
160
+ let totalFiles = 0;
161
+ let totalBytes = 0;
162
+ const candidates = []; // { id, reason, updatedAt, bytes, inIndex }
163
+ let closedCount = 0;
164
+ let ageOnlyCount = 0;
165
+ let closedBytes = 0;
166
+ let ageOnlyBytes = 0;
167
+ let reclaimBytes = 0;
168
+ let scanFailFiles = 0;
169
+ let closedButFreshCount = 0; // closed, but within the min-closed-age gate → kept
170
+ let closedButFreshBytes = 0;
171
+ const fileIds = new Set();
172
+
173
+ for (const f of jsonFiles) {
174
+ const id = f.slice(0, -5);
175
+ fileIds.add(id);
176
+ const full = join(dir, f);
177
+ let size = 0;
178
+ let mtimeMs = 0;
179
+ try { const st = statSync(full); size = st.size || 0; mtimeMs = st.mtimeMs || 0; } catch { /* skip */ }
180
+ const hb = hbSizeById.get(id) || 0;
181
+ const bytes = size + hb;
182
+ totalFiles += 1;
183
+ totalBytes += bytes;
184
+
185
+ const row = rowById.get(id) || null;
186
+ // Authoritative per-file lifecycle via cheap top-level scan; the summary
187
+ // index is stale here (most on-disk files are unindexed), so the file
188
+ // itself — not the index — decides closed/age. Fall back to the index
189
+ // row, then to file mtime, only when the scan can't resolve a field.
190
+ let scan = null;
191
+ try { scan = scanTopLevelScalars(readFileSync(full, 'utf-8')); } catch { scan = null; }
192
+ if (!scan) scanFailFiles += 1;
193
+ const closedScan = scan && (scan.closed === true || scan.status === 'closed');
194
+ const closedRow = row && (row.closed === true || row.status === 'closed');
195
+ const closed = scan ? !!closedScan : !!closedRow;
196
+ let updatedAt = scan && Number(scan.updatedAt) > 0 ? Number(scan.updatedAt) : 0;
197
+ if (!updatedAt && row && Number(row.updatedAt) > 0) updatedAt = Number(row.updatedAt);
198
+ if (!updatedAt) updatedAt = mtimeMs;
199
+ const ageMs = now - updatedAt;
200
+ // Min-closed-age gate: a closed session only qualifies once its close
201
+ // timestamp (closedAt when present, else updatedAt — markSessionClosed
202
+ // sets updatedAt=Date.now() at tombstone time) is older than the gate.
203
+ // Recently-closed sessions may still be resumed, so keep them.
204
+ const closedAt = scan && Number(scan.closedAt) > 0 ? Number(scan.closedAt) : updatedAt;
205
+ const closedAge = now - closedAt;
206
+ const closedQualifies = closed
207
+ && (minClosedAgeMs <= 0
208
+ || (Number.isFinite(closedAt) && closedAt > 0 && closedAge > minClosedAgeMs));
209
+ const ageOnly = !closed && maxAgeMs > 0 && Number.isFinite(updatedAt) && updatedAt > 0 && ageMs > maxAgeMs;
210
+
211
+ if (closed && !closedQualifies) {
212
+ closedButFreshCount += 1;
213
+ closedButFreshBytes += bytes;
214
+ }
215
+ if (!closedQualifies && !ageOnly) continue; // keep
216
+
217
+ const reason = closedQualifies ? 'closed' : 'age';
218
+ candidates.push({ id, reason, updatedAt, bytes, inIndex: !!row });
219
+ reclaimBytes += bytes;
220
+ if (closedQualifies) { closedCount += 1; closedBytes += bytes; }
221
+ else { ageOnlyCount += 1; ageOnlyBytes += bytes; }
222
+ }
223
+
224
+ candidates.sort((a, b) => (a.updatedAt || 0) - (b.updatedAt || 0));
225
+ const oldest = candidates[0] || null;
226
+ const newest = candidates[candidates.length - 1] || null;
227
+ const dropRows = candidates.filter((c) => c.inIndex).length;
228
+ const remainFiles = totalFiles - candidates.length;
229
+ // Rows in the index whose session file is already gone from disk — a
230
+ // rebuild (scans existing files only) drops these regardless of retention.
231
+ const staleRows = rows.filter((r) => r?.id && !fileIds.has(r.id)).length;
232
+ // A real prune would delete candidate files then rebuild the index from the
233
+ // surviving files, so the post-rebuild index equals the surviving-file count
234
+ // (every remaining file gets a row, including currently-unindexed ones).
235
+ const rebuiltIndexRows = remainFiles;
236
+
237
+ const L = [];
238
+ L.push('══ session-sweep DRY-RUN report (NO files deleted, NO writes) ══');
239
+ L.push(`data dir : ${dir}`);
240
+ L.push(`summary index : ${summaryIndexPath()} (${rows.length} rows)`);
241
+ L.push(`now : ${fmtTs(now)}`);
242
+ L.push(`retention (age) : > ${maxAgeDays} days by updatedAt`);
243
+ L.push(`retention (closed) : (closed=true OR status='closed') AND closed > ${minClosedAgeDays}d ago`);
244
+ L.push('');
245
+ L.push(`total session files : ${totalFiles} (${fmtBytes(totalBytes)})`);
246
+ L.push(`candidates (drop) : ${candidates.length} (${fmtBytes(reclaimBytes)} reclaimable)`);
247
+ L.push(` ├─ closed : ${closedCount} (${fmtBytes(closedBytes)})`);
248
+ L.push(` └─ age-only >${maxAgeDays}d : ${ageOnlyCount} (${fmtBytes(ageOnlyBytes)})`);
249
+ L.push(`kept: closed <${minClosedAgeDays}d : ${closedButFreshCount} (${fmtBytes(closedButFreshBytes)}) — within min-closed-age gate`);
250
+ L.push(`would remain : ${remainFiles} files (${fmtBytes(totalBytes - reclaimBytes)})`);
251
+ L.push(`oldest candidate : ${oldest ? `${oldest.id} @ ${fmtTs(oldest.updatedAt)} [${oldest.reason}]` : 'n/a'}`);
252
+ L.push(`newest candidate : ${newest ? `${newest.id} @ ${fmtTs(newest.updatedAt)} [${newest.reason}]` : 'n/a'}`);
253
+ if (scanFailFiles > 0) L.push(`unparsable files : ${scanFailFiles} (fell back to index/mtime)`);
254
+ L.push('');
255
+ L.push('── summary-index rebuild plan ──');
256
+ L.push(`index rows total : ${rows.length}`);
257
+ L.push(`stale rows (no file): ${staleRows} (already-deleted sessions; drop on rebuild)`);
258
+ L.push(`candidate rows drop : ${dropRows} (candidate files that also have an index row)`);
259
+ L.push(`orphan candidates : ${candidates.length - dropRows} (candidate file present, no index row)`);
260
+ L.push(`index rows after : ${rebuiltIndexRows} (= surviving files; rebuild reindexes all remaining)`);
261
+ L.push('');
262
+ L.push('DRY-RUN ONLY — this tool performed no unlink and no disk writes.');
263
+ process.stdout.write(L.join('\n') + '\n');
264
+ }
265
+
266
+ main();
@@ -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;