mixdog 0.9.37 → 0.9.39

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 (127) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  8. package/scripts/execution-completion-dedup-test.mjs +157 -0
  9. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  10. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  11. package/scripts/explore-bench.mjs +101 -8
  12. package/scripts/explore-prompt-policy-test.mjs +156 -11
  13. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  14. package/scripts/internal-comms-bench-test.mjs +226 -0
  15. package/scripts/internal-comms-bench.mjs +185 -58
  16. package/scripts/internal-comms-smoke.mjs +171 -23
  17. package/scripts/live-worker-smoke.mjs +38 -2
  18. package/scripts/memory-cycle-routing-test.mjs +111 -0
  19. package/scripts/memory-rule-contract-test.mjs +93 -0
  20. package/scripts/notify-completion-mirror-test.mjs +73 -0
  21. package/scripts/output-style-smoke.mjs +2 -2
  22. package/scripts/rg-runner-test.mjs +240 -0
  23. package/scripts/routing-corpus-test.mjs +349 -0
  24. package/scripts/routing-corpus.mjs +211 -32
  25. package/scripts/session-orphan-sweep-test.mjs +83 -0
  26. package/scripts/session-sweep.mjs +266 -0
  27. package/scripts/steering-drain-buckets-test.mjs +179 -0
  28. package/scripts/tool-smoke.mjs +21 -13
  29. package/scripts/tool-tui-presentation-test.mjs +202 -0
  30. package/src/agents/heavy-worker/AGENT.md +10 -7
  31. package/src/agents/reviewer/AGENT.md +6 -4
  32. package/src/agents/worker/AGENT.md +7 -5
  33. package/src/rules/agent/00-common.md +4 -4
  34. package/src/rules/agent/00-core.md +11 -14
  35. package/src/rules/agent/20-skip-protocol.md +3 -3
  36. package/src/rules/agent/30-explorer.md +56 -48
  37. package/src/rules/agent/40-cycle1-agent.md +15 -24
  38. package/src/rules/agent/41-cycle2-agent.md +33 -57
  39. package/src/rules/agent/42-cycle3-agent.md +28 -42
  40. package/src/rules/lead/01-general.md +7 -10
  41. package/src/rules/lead/lead-brief.md +11 -14
  42. package/src/rules/lead/lead-tool.md +6 -5
  43. package/src/rules/shared/01-tool.md +44 -41
  44. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  45. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  46. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  47. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  48. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  53. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  55. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  57. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  58. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  59. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  60. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
  62. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  67. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  68. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  76. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  77. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  80. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  81. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  82. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  83. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  84. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  85. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  86. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  87. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  88. package/src/runtime/shared/tool-primitives.mjs +4 -1
  89. package/src/runtime/shared/tool-status.mjs +27 -0
  90. package/src/runtime/shared/tool-surface.mjs +6 -3
  91. package/src/session-runtime/config-helpers.mjs +14 -0
  92. package/src/session-runtime/context-status.mjs +1 -0
  93. package/src/session-runtime/effort.mjs +6 -2
  94. package/src/session-runtime/model-recency.mjs +5 -2
  95. package/src/session-runtime/provider-models.mjs +3 -3
  96. package/src/session-runtime/runtime-core.mjs +78 -10
  97. package/src/session-runtime/tool-catalog.mjs +34 -0
  98. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  99. package/src/standalone/agent-tool/notify.mjs +13 -0
  100. package/src/standalone/agent-tool.mjs +45 -69
  101. package/src/standalone/explore-tool.mjs +6 -7
  102. package/src/tui/App.jsx +31 -0
  103. package/src/tui/app/model-options.mjs +5 -3
  104. package/src/tui/app/model-picker.mjs +12 -24
  105. package/src/tui/app/transcript-window.mjs +1 -0
  106. package/src/tui/components/ToolExecution.jsx +11 -6
  107. package/src/tui/components/TranscriptItem.jsx +1 -1
  108. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  109. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  110. package/src/tui/dist/index.mjs +533 -143
  111. package/src/tui/engine/agent-job-feed.mjs +153 -16
  112. package/src/tui/engine/agent-response-tail.mjs +68 -0
  113. package/src/tui/engine/notification-plan.mjs +16 -0
  114. package/src/tui/engine/queue-helpers.mjs +8 -0
  115. package/src/tui/engine/session-api.mjs +8 -2
  116. package/src/tui/engine/session-flow.mjs +34 -2
  117. package/src/tui/engine/tool-card-results.mjs +54 -32
  118. package/src/tui/engine/tool-result-status.mjs +75 -21
  119. package/src/tui/engine/turn.mjs +83 -43
  120. package/src/tui/engine.mjs +63 -2
  121. package/src/workflows/bench/WORKFLOW.md +25 -35
  122. package/src/workflows/default/WORKFLOW.md +38 -32
  123. package/src/workflows/solo/WORKFLOW.md +19 -22
  124. package/scripts/_jitter-fuzz.mjs +0 -44410
  125. package/scripts/_jitter-fuzz2.mjs +0 -44400
  126. package/scripts/_jitter-probe.mjs +0 -44397
  127. package/scripts/_jp2.mjs +0 -45614
@@ -5,7 +5,7 @@
5
5
  // set by re-running --eval before/after (input constant, only rules change).
6
6
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
- import { basename, resolve } from 'node:path';
8
+ import { basename, resolve, win32 } from 'node:path';
9
9
  import { resolvePluginData } from '../src/runtime/shared/plugin-paths.mjs';
10
10
 
11
11
  function argValue(name, fallback = null) {
@@ -64,7 +64,10 @@ function argsSummary(tool, args) {
64
64
  switch (tool) {
65
65
  case 'grep': return clip(Array.isArray(args.pattern) ? `[${args.pattern.length}]${args.pattern[0]}` : args.pattern);
66
66
  case 'read': return clip(basename(String(Array.isArray(args.path) ? args.path[0] : args.path || '')));
67
- case 'code_graph': return clip(`${args.mode || '?'}:${args.symbol || args.file || ''}`);
67
+ case 'code_graph': {
68
+ const values = args.symbols || args.files || args.symbol || args.file || '';
69
+ return clip(`${args.mode || '?'}:${Array.isArray(values) ? `[${values.length}]${values[0] || ''}` : values}`);
70
+ }
68
71
  case 'explore': return arr(args.query);
69
72
  case 'find': return clip(args.query);
70
73
  case 'glob': return clip(Array.isArray(args.pattern) ? args.pattern[0] : args.pattern);
@@ -75,9 +78,119 @@ function argsSummary(tool, args) {
75
78
  }
76
79
  }
77
80
 
78
- function normGrep(args) {
79
- const p = args?.pattern;
80
- return String(Array.isArray(p) ? p.join('|') : (p ?? '')).trim().toLowerCase();
81
+ function isMutation(tool) {
82
+ return ['apply_patch', 'edit', 'edit_many', 'write', 'shell'].includes(tool);
83
+ }
84
+ function failed(row) {
85
+ const kind = field(row, 'result_kind');
86
+ return /^(error|failed|failure|command-exit|timeout|permission)$/i.test(String(kind || ''));
87
+ }
88
+ function targetValues(tool, args) {
89
+ if (!args || typeof args !== 'object') return [];
90
+ let key = 'path';
91
+ if (tool === 'code_graph') {
92
+ const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode);
93
+ const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args.mode);
94
+ key = fileMode || (args.mode === 'symbols' && (args.files != null || args.file != null))
95
+ ? (args.files != null ? 'files' : 'file')
96
+ : symbolMode || args.mode === 'symbols' ? (args.symbols != null ? 'symbols' : 'symbol') : 'file';
97
+ } else if (tool === 'grep' || tool === 'glob') key = 'pattern';
98
+ else if (tool === 'find' || tool === 'explore') key = 'query';
99
+ const value = args[key];
100
+ return Array.isArray(value) ? value : value == null ? [] : [value];
101
+ }
102
+ function batchFields(tool, args) {
103
+ if (tool === 'grep' || tool === 'glob') return ['pattern', 'path'];
104
+ if (tool === 'code_graph') {
105
+ const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args?.mode);
106
+ const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args?.mode);
107
+ if (symbolMode && args?.files == null && args?.file == null) return ['symbols', 'symbol'];
108
+ if (symbolMode) return [];
109
+ return [fileMode || args?.mode === 'symbols'
110
+ ? (args?.files != null ? 'files' : args?.file != null ? 'file' : args?.symbols != null ? 'symbols' : 'symbol')
111
+ : null].filter(Boolean);
112
+ }
113
+ return [tool === 'read' || tool === 'list' ? 'path' : tool === 'find' || tool === 'explore' ? 'query' : null].filter(Boolean);
114
+ }
115
+ function compatibleBatchCalls(tool, left, right) {
116
+ if (tool === 'read') {
117
+ const nonBatch = Object.keys({ ...left, ...right }).filter((key) => !['path', 'offset', 'limit'].includes(key));
118
+ return nonBatch.every((key) => JSON.stringify(left?.[key]) === JSON.stringify(right?.[key]));
119
+ }
120
+ const fields = batchFields(tool, left);
121
+ if (!fields.length) return false;
122
+ const differing = fields.filter((key) => JSON.stringify(left?.[key]) !== JSON.stringify(right?.[key]));
123
+ if (differing.length !== 1) return false;
124
+ const batchKey = differing[0];
125
+ if ((Array.isArray(left?.[batchKey]) && left[batchKey].length !== 1) || (Array.isArray(right?.[batchKey]) && right[batchKey].length !== 1)) return false;
126
+ return Object.keys({ ...left, ...right }).filter((key) => !fields.includes(key))
127
+ .every((key) => JSON.stringify(left?.[key]) === JSON.stringify(right?.[key]));
128
+ }
129
+ function readTargets(args) {
130
+ if (!args || typeof args !== 'object') return null;
131
+ const values = Array.isArray(args.path) ? args.path : [args.path];
132
+ return values.map((value) => {
133
+ if (typeof value === 'string') return { path: value, offset: args.offset ?? null, limit: args.limit ?? null };
134
+ if (value && typeof value === 'object' && typeof value.path === 'string') {
135
+ return { path: value.path, offset: value.offset ?? null, limit: value.limit ?? null };
136
+ }
137
+ return null;
138
+ }).filter(Boolean);
139
+ }
140
+ function batchSpec(tool, args, forcedField = null) {
141
+ if (!args || typeof args !== 'object') return null;
142
+ if (tool === 'code_graph') {
143
+ if (!['symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees', 'overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode)) return null;
144
+ const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode);
145
+ const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args.mode);
146
+ if (symbolMode && (args.files != null || args.file != null)) return null;
147
+ const field = fileMode || (args.mode === 'symbols' && (args.files != null || args.file != null))
148
+ ? (args.files != null ? 'files' : 'file')
149
+ : (args.symbols != null ? 'symbols' : 'symbol');
150
+ return { field, values: targetValues(tool, args) };
151
+ }
152
+ const fieldName = forcedField || (tool === 'read' || tool === 'list' ? 'path' : tool === 'grep' || tool === 'glob' ? 'pattern' : tool === 'find' || tool === 'explore' ? 'query' : null);
153
+ if (!fieldName) return null;
154
+ const value = args[fieldName];
155
+ return { field: fieldName, values: Array.isArray(value) ? value : value == null ? [] : [value] };
156
+ }
157
+ function sameIterationBatchObservations(sequence) {
158
+ let found = false;
159
+ for (let i = 0; i < sequence.length; i += 1) {
160
+ const first = sequence[i];
161
+ if (isMutation(first.tool) || first.it == null) continue;
162
+ const group = [first];
163
+ for (let j = i + 1; j < sequence.length; j += 1) {
164
+ const next = sequence[j];
165
+ if (next.it !== first.it) break;
166
+ if (isMutation(next.tool)) break;
167
+ group.push(next);
168
+ }
169
+ for (const candidate of ['read', 'grep', 'find', 'glob', 'list', 'explore', 'code_graph']) {
170
+ if (candidate === 'read') {
171
+ const calls = group.filter((entry) => entry.tool === 'read' && !entry.failed).map((entry) => ({ entry, targets: readTargets(entry.rawArgs) })).filter(({ targets }) => targets?.length);
172
+ if (calls.some(({ entry, targets }, index) => calls.some(({ entry: other, targets: otherTargets }, otherIndex) => (
173
+ index < otherIndex
174
+ && compatibleBatchCalls('read', entry.rawArgs, other.rawArgs)
175
+ && targets.some((target) => otherTargets.some((otherTarget) => JSON.stringify(target) !== JSON.stringify(otherTarget)))
176
+ )))) found = true;
177
+ continue;
178
+ }
179
+ const firstArgs = group.find((entry) => entry.tool === candidate && !entry.failed)?.rawArgs;
180
+ for (const field of batchFields(candidate, firstArgs || {})) {
181
+ const calls = group.filter((entry) => entry.tool === candidate && !entry.failed).map((entry) => ({ entry, spec: batchSpec(candidate, entry.rawArgs, field) })).filter(({ spec }) => spec && spec.values.length === 1);
182
+ if (calls.length < 2) continue;
183
+ const distinct = new Set(calls.map(({ spec }) => JSON.stringify(spec.values[0])));
184
+ const compatiblePair = calls.some(({ entry, spec }, index) => calls.some(({ entry: other, spec: otherSpec }, otherIndex) => (
185
+ index < otherIndex
186
+ && JSON.stringify(spec.values[0]) !== JSON.stringify(otherSpec.values[0])
187
+ && compatibleBatchCalls(candidate, entry.rawArgs, other.rawArgs)
188
+ )));
189
+ if (distinct.size >= 2 && compatiblePair) found = true;
190
+ }
191
+ }
192
+ }
193
+ return found ? ['same_turn_batch_opportunity'] : [];
81
194
  }
82
195
 
83
196
  // shell used to INSPECT the filesystem (read/list/search/exists) instead of a
@@ -104,32 +217,98 @@ function buildCase(sid, toolRows) {
104
217
  const it = field(r, 'iteration');
105
218
  if (it != null) iters.add(it);
106
219
  const tool = field(r, 'tool_name') || '?';
107
- sequence.push({ it: it ?? null, tool, args: argsSummary(tool, field(r, 'tool_args')), rawArgs: field(r, 'tool_args') });
220
+ const rawArgs = field(r, 'tool_args');
221
+ const summaryArgs = rawArgs;
222
+ sequence.push({
223
+ it: it ?? null,
224
+ tool,
225
+ args: argsSummary(tool, rawArgs),
226
+ rawArgs,
227
+ inspectArgs: summaryArgs,
228
+ argsHash: field(r, 'tool_args_hash'),
229
+ resultKind: field(r, 'result_kind'),
230
+ resultLines: Number(field(r, 'result_lines_est') || 0),
231
+ coverage: field(r, 'grep_coverage'),
232
+ cwd: field(r, 'cwd') || '',
233
+ failed: failed(r),
234
+ });
108
235
  }
109
- // per-iteration tool counts for serial detection
110
- const perIt = new Map();
111
- for (const s of sequence) { if (s.it == null) continue; perIt.set(s.it, (perIt.get(s.it) || 0) + 1); }
112
- const itOrder = [...perIt.keys()].sort((a, b) => a - b);
113
- let longestSingleRun = 0, run = 0;
114
- for (const it of itOrder) { if (perIt.get(it) === 1) { run += 1; longestSingleRun = Math.max(longestSingleRun, run); } else run = 0; }
115
-
116
236
  const names = sequence.map((s) => s.tool);
117
- const hasAnchor = names.some((n) => n === 'grep' || n === 'code_graph' || n === 'find' || n === 'read');
118
- const exploreCalls = sequence.filter((s) => s.tool === 'explore');
119
237
  const flags = [];
120
- if (exploreCalls.length && hasAnchor) flags.push('explore_overuse');
121
- if (exploreCalls.some((s) => Array.isArray(s.rawArgs?.query))) flags.push('explore_multiquery');
122
- if (names[0] === 'explore') flags.push('explore_first');
123
- if (sequence.some((s) => s.tool === 'code_graph' && s.rawArgs?.mode === 'find_symbol' && !s.rawArgs?.file)) flags.push('find_symbol_noscope');
124
- if (longestSingleRun >= 4) flags.push('serial_single_tool');
125
- // read fragmentation
126
- const readCounts = new Map();
127
- for (const s of sequence) { if (s.tool !== 'read') continue; const k = String(s.rawArgs?.path ?? s.args); readCounts.set(k, (readCounts.get(k) || 0) + 1); }
128
- if ([...readCounts.values()].some((c) => c >= 3)) flags.push('read_fragmentation');
129
- // grep retry
130
- const grepCounts = new Map();
131
- for (const s of sequence) { if (s.tool !== 'grep') continue; const k = normGrep(s.rawArgs); if (!k) continue; grepCounts.set(k, (grepCounts.get(k) || 0) + 1); }
132
- if ([...grepCounts.values()].some((c) => c >= 2)) flags.push('grep_retry');
238
+ const observations = [];
239
+ if (sequence.some((s) => s.tool === 'code_graph' && s.rawArgs?.mode === 'find_symbol' && !s.rawArgs?.file && !s.rawArgs?.files)) flags.push('find_symbol_noscope');
240
+ // Exact duplicate requests are the only relookup signal available in tool
241
+ // traces. Do not infer waste from counts, roles, turns, or explore→inspection:
242
+ // exploration followed by inspection can be the intended route.
243
+ const seenRequests = new Set();
244
+ const readWindows = [];
245
+ let pendingContextGrep = null;
246
+ for (const s of sequence) {
247
+ if (isMutation(s.tool)) {
248
+ seenRequests.clear();
249
+ readWindows.length = 0;
250
+ pendingContextGrep = null;
251
+ continue;
252
+ }
253
+ if (s.failed) continue;
254
+ if (['grep', 'read'].includes(s.tool)) {
255
+ const key = s.argsHash ? `${s.tool}:${s.argsHash}` : null;
256
+ const duplicate = key && seenRequests.has(key);
257
+ if (duplicate) flags.push(`${s.tool}_relookup`);
258
+ if (key) seenRequests.add(key);
259
+ if (s.tool === 'read' && !duplicate) {
260
+ const normalize = (value, cwd) => {
261
+ const raw = String(value);
262
+ const base = String(cwd || process.cwd());
263
+ const winStyle = /^[A-Za-z]:[\\/]/.test(raw) || /^[A-Za-z]:[\\/]/.test(base) || raw.includes('\\');
264
+ const resolved = (winStyle ? win32.resolve(base, raw) : resolve(base, raw)).replace(/\\/g, '/');
265
+ return winStyle ? resolved.toLowerCase() : resolved;
266
+ };
267
+ const bounded = (offset, limit) => Number.isFinite(Number(offset)) && Number(offset) >= 0
268
+ && Number.isFinite(Number(limit)) && Number(limit) > 0;
269
+ for (const target of readTargets(s.rawArgs) || []) {
270
+ const start = bounded(target.offset, target.limit) ? Number(target.offset) : 0;
271
+ const end = bounded(target.offset, target.limit) ? start + Number(target.limit) : Infinity;
272
+ const path = normalize(target.path, s.cwd);
273
+ if (!flags.includes('read_overlap') && readWindows.some((prior) => prior.path === path && start < prior.end && prior.start < end)) flags.push('read_overlap');
274
+ readWindows.push({ path, start, end });
275
+ }
276
+ }
277
+ }
278
+ if (s.tool === 'grep' && Array.isArray(s.coverage) && s.coverage.length) {
279
+ pendingContextGrep = s;
280
+ } else if (s.tool === 'read' && pendingContextGrep) {
281
+ // Coverage is emitted by the trace formatter from actual path:line
282
+ // output. Missing/boundedness-unknown coverage intentionally stays
283
+ // unclassified; whole-file reads are a documented residual limitation.
284
+ const pathArg = s.rawArgs?.path;
285
+ const bounded = (offset, limit) => Number.isFinite(Number(offset)) && Number(offset) >= 0
286
+ && Number.isFinite(Number(limit)) && Number(limit) > 0;
287
+ const region = typeof pathArg === 'string' && bounded(s.rawArgs.offset, s.rawArgs.limit)
288
+ ? { path: pathArg, start: Number(s.rawArgs.offset) + 1, end: Number(s.rawArgs.offset) + Number(s.rawArgs.limit) }
289
+ : pathArg && typeof pathArg === 'object' && pathArg.path && bounded(pathArg.offset, pathArg.limit)
290
+ ? { path: pathArg.path, start: Number(pathArg.offset) + 1, end: Number(pathArg.offset) + Number(pathArg.limit) }
291
+ : null;
292
+ if (region) {
293
+ const normalize = (value, cwd) => {
294
+ const raw = String(value);
295
+ const base = String(cwd || process.cwd());
296
+ const winStyle = /^[A-Za-z]:[\\/]/.test(raw) || /^[A-Za-z]:[\\/]/.test(base) || raw.includes('\\');
297
+ const resolved = resolve(base, raw).replace(/\\/g, '/');
298
+ return winStyle ? resolved.toLowerCase() : resolved;
299
+ };
300
+ const wanted = normalize(region.path, s.cwd || pendingContextGrep.cwd);
301
+ const covered = new Set((pendingContextGrep.coverage || [])
302
+ .filter((item) => normalize(item.path, pendingContextGrep.cwd) === wanted)
303
+ .map((item) => Number(item.line)));
304
+ let complete = true;
305
+ for (let line = region.start; line <= region.end; line += 1) if (!covered.has(line)) { complete = false; break; }
306
+ if (complete) flags.push('grep_context_then_read');
307
+ }
308
+ pendingContextGrep = null;
309
+ }
310
+ }
311
+ observations.push(...sameIterationBatchObservations(sequence));
133
312
  // shell used for filesystem inspection instead of dedicated tools
134
313
  if (sequence.some((s) => s.tool === 'shell' && isShellInspect(s.rawArgs?.command))) flags.push('shell_inspect');
135
314
 
@@ -137,17 +316,17 @@ function buildCase(sid, toolRows) {
137
316
  session_id: sid,
138
317
  short_id: shortId(sid),
139
318
  agent, model,
140
- turns: iters.size || itOrder.length,
319
+ turns: iters.size,
141
320
  tools: sequence.length,
142
321
  first_tool: names[0] || null,
143
- longest_single_run: longestSingleRun,
144
322
  flags,
323
+ observations,
145
324
  max_ts: Math.max(...sorted.map((r) => Number(r.ts || 0))),
146
- sequence: sequence.map((s) => ({ it: s.it, tool: s.tool, args: s.args })),
325
+ sequence: sequence.map((s) => ({ it: s.it, tool: s.tool, args: s.args, result_kind: s.resultKind, result_lines_est: s.resultLines, grep_coverage: s.coverage })),
147
326
  };
148
327
  }
149
328
 
150
- const FLAG_KEYS = ['explore_overuse', 'explore_multiquery', 'explore_first', 'find_symbol_noscope', 'serial_single_tool', 'read_fragmentation', 'grep_retry', 'shell_inspect'];
329
+ const FLAG_KEYS = ['find_symbol_noscope', 'read_relookup', 'read_overlap', 'grep_relookup', 'grep_context_then_read', 'missed_array_batch', 'shell_inspect'];
151
330
 
152
331
  function buildCorpus(rows, { limit, sinceTs, agentFilter }) {
153
332
  let filtered = rows.filter((r) => r.kind === 'tool' && sessionId(r));
@@ -0,0 +1,83 @@
1
+ /**
2
+ * session-orphan-sweep-test.mjs — proves the periodic sweep reclaims mature
3
+ * closed tombstones that exist ON DISK but are ABSENT from the summary index.
4
+ *
5
+ * Regression guard for the pre-fix bug where sweepStaleSessions iterated only
6
+ * listStoredSessionSummaries() rows, so on-disk session files missing from a
7
+ * stale index were never visited and their tombstones accumulated forever.
8
+ */
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { tmpdir } from 'node:os';
14
+
15
+ test('mature closed on-disk orphan absent from summary index gets tombstone-swept', async () => {
16
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-orphan-sweep-'));
17
+ process.env.MIXDOG_DATA_DIR = dataDir;
18
+ try {
19
+ const store = await import('../src/runtime/agent/orchestrator/session/store.mjs');
20
+ const { sweepStaleSessions, _sessionSummary, _writeSummaryIndex } = store;
21
+
22
+ const sessionsDir = join(dataDir, 'sessions');
23
+ mkdirSync(sessionsDir, { recursive: true });
24
+
25
+ const now = Date.now();
26
+ const TWO_HOURS = 2 * 60 * 60 * 1000;
27
+ const ONE_HOUR = 60 * 60 * 1000;
28
+
29
+ // (1) An INDEXED, fresh, open agent session — present in the summary
30
+ // index so the index is non-empty (blocking any rebuild) and must
31
+ // survive the sweep.
32
+ const indexed = {
33
+ id: 'sess_indexed_keep',
34
+ closed: false,
35
+ status: 'idle',
36
+ owner: 'agent:test',
37
+ updatedAt: now,
38
+ createdAt: now,
39
+ lastHeartbeatAt: now,
40
+ messages: [],
41
+ };
42
+ writeFileSync(join(sessionsDir, `${indexed.id}.json`), JSON.stringify(indexed));
43
+
44
+ // (2) An ORPHAN closed+mature tombstone — on disk, NOT in the index,
45
+ // closed >1h ago. Only reachable if the sweep reconciles the index
46
+ // candidate set with a direct directory scan.
47
+ const orphan = {
48
+ id: 'sess_orphan_tombstone',
49
+ closed: true,
50
+ status: 'closed',
51
+ owner: 'agent:test',
52
+ updatedAt: now - TWO_HOURS,
53
+ createdAt: now - TWO_HOURS,
54
+ messages: [],
55
+ };
56
+ writeFileSync(join(sessionsDir, `${orphan.id}.json`), JSON.stringify(orphan));
57
+
58
+ // Index contains ONLY the indexed session — the orphan is absent.
59
+ const rows = _writeSummaryIndex([_sessionSummary(indexed)]);
60
+ assert.equal(rows.length, 1, 'index seeded with exactly one row');
61
+ assert.ok(!rows.some((r) => r.id === orphan.id), 'orphan is absent from the summary index');
62
+
63
+ // Tombstone-only pass (no idle sweep) with a 1h maturity threshold.
64
+ const result = sweepStaleSessions({ sweepIdle: false, tombstoneMaxAgeMs: ONE_HOUR });
65
+
66
+ assert.equal(result.tombstonesCleaned, 1, 'the orphan tombstone was reclaimed');
67
+ assert.ok(
68
+ result.tombstoneDetails.some((d) => d.id === orphan.id),
69
+ 'sweep reports the orphan id among tombstone deletions',
70
+ );
71
+ assert.ok(
72
+ !existsSync(join(sessionsDir, `${orphan.id}.json`)),
73
+ 'orphan session file was unlinked from disk',
74
+ );
75
+ assert.ok(
76
+ existsSync(join(sessionsDir, `${indexed.id}.json`)),
77
+ 'fresh open indexed session was preserved',
78
+ );
79
+ } finally {
80
+ try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
81
+ delete process.env.MIXDOG_DATA_DIR;
82
+ }
83
+ });
@@ -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();