mixdog 0.9.38 → 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 (114) 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/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +179 -0
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/src/agents/heavy-worker/AGENT.md +10 -7
  28. package/src/agents/reviewer/AGENT.md +6 -4
  29. package/src/agents/worker/AGENT.md +7 -5
  30. package/src/rules/agent/00-common.md +4 -4
  31. package/src/rules/agent/00-core.md +11 -14
  32. package/src/rules/agent/20-skip-protocol.md +3 -3
  33. package/src/rules/agent/30-explorer.md +50 -60
  34. package/src/rules/agent/40-cycle1-agent.md +15 -24
  35. package/src/rules/agent/41-cycle2-agent.md +33 -57
  36. package/src/rules/agent/42-cycle3-agent.md +28 -42
  37. package/src/rules/lead/01-general.md +7 -10
  38. package/src/rules/lead/lead-brief.md +11 -14
  39. package/src/rules/lead/lead-tool.md +6 -5
  40. package/src/rules/shared/01-tool.md +44 -45
  41. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  42. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  43. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  46. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  48. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/session/store.mjs +223 -42
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  55. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  71. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  72. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  73. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  74. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  75. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  76. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  77. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  78. package/src/runtime/shared/tool-primitives.mjs +4 -1
  79. package/src/runtime/shared/tool-status.mjs +27 -0
  80. package/src/runtime/shared/tool-surface.mjs +6 -3
  81. package/src/session-runtime/config-helpers.mjs +14 -0
  82. package/src/session-runtime/context-status.mjs +1 -0
  83. package/src/session-runtime/effort.mjs +6 -2
  84. package/src/session-runtime/model-recency.mjs +5 -2
  85. package/src/session-runtime/runtime-core.mjs +35 -2
  86. package/src/session-runtime/tool-catalog.mjs +34 -0
  87. package/src/standalone/agent-tool/notify.mjs +13 -0
  88. package/src/standalone/agent-tool.mjs +45 -69
  89. package/src/standalone/explore-tool.mjs +6 -7
  90. package/src/tui/App.jsx +31 -0
  91. package/src/tui/app/model-options.mjs +5 -3
  92. package/src/tui/app/model-picker.mjs +12 -24
  93. package/src/tui/app/transcript-window.mjs +1 -0
  94. package/src/tui/components/ToolExecution.jsx +11 -6
  95. package/src/tui/components/TranscriptItem.jsx +1 -1
  96. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  97. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  98. package/src/tui/dist/index.mjs +517 -142
  99. package/src/tui/engine/agent-job-feed.mjs +144 -17
  100. package/src/tui/engine/agent-response-tail.mjs +68 -0
  101. package/src/tui/engine/notification-plan.mjs +16 -0
  102. package/src/tui/engine/session-api.mjs +8 -2
  103. package/src/tui/engine/session-flow.mjs +19 -1
  104. package/src/tui/engine/tool-card-results.mjs +54 -32
  105. package/src/tui/engine/tool-result-status.mjs +75 -21
  106. package/src/tui/engine/turn.mjs +77 -42
  107. package/src/tui/engine.mjs +63 -2
  108. package/src/workflows/bench/WORKFLOW.md +25 -35
  109. package/src/workflows/default/WORKFLOW.md +38 -32
  110. package/src/workflows/solo/WORKFLOW.md +19 -22
  111. package/scripts/_jitter-fuzz.mjs +0 -44410
  112. package/scripts/_jitter-fuzz2.mjs +0 -44400
  113. package/scripts/_jitter-probe.mjs +0 -44397
  114. package/scripts/_jp2.mjs +0 -45614
@@ -99,6 +99,212 @@ export function estimateMessagesTokens(messages) {
99
99
  return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
100
100
  }
101
101
 
102
+ // Context status is polled while the agent loop mutates and replaces message
103
+ // arrays. Keep the accumulated summary on that array, but cheaply validate
104
+ // every entry before reusing its contribution. The fingerprint deliberately
105
+ // avoids serializing content/blocks on the warm path; it compares the
106
+ // references of every estimator-visible string instead.
107
+ // Producer invariant: compaction copies message/call objects, transcript repair
108
+ // replaces array entries, stored-tool-args replaces `arguments`, and MCP reload
109
+ // replaces tool descriptors; settled nested non-string payloads are not mutated
110
+ // in place without replacing their containing reference.
111
+ const contextMessageMemo = new WeakMap();
112
+ const contextTranscriptMemo = new WeakMap();
113
+
114
+ function contextValueFingerprint(value) {
115
+ if (typeof value === 'string') return { value, entries: null };
116
+ if (Array.isArray(value)) return {
117
+ value,
118
+ entries: value.map(contextBlockFingerprint),
119
+ };
120
+ return value && typeof value === 'object'
121
+ ? { value, entries: [contextBlockFingerprint(value)] }
122
+ : { value, entries: null };
123
+ }
124
+
125
+ function contextBlockFingerprint(block) {
126
+ if (!block || typeof block !== 'object') return { value: block };
127
+ const fn = block.function;
128
+ return {
129
+ value: block,
130
+ text: typeof block.text === 'string' ? block.text : null,
131
+ content: typeof block.content === 'string' ? block.content : null,
132
+ args: typeof block.args === 'string' ? block.args : null,
133
+ arguments: block.arguments,
134
+ input: typeof block.input === 'string' ? block.input : null,
135
+ function: fn,
136
+ functionArguments: typeof fn?.arguments === 'string' ? fn.arguments : null,
137
+ };
138
+ }
139
+
140
+ function contextMessageFingerprint(message) {
141
+ if (!message || typeof message !== 'object') {
142
+ return {
143
+ role: undefined,
144
+ content: contextValueFingerprint(''),
145
+ toolCalls: contextValueFingerprint(null),
146
+ thinkingBlocks: contextValueFingerprint(null),
147
+ assistantBlocks: contextValueFingerprint(null),
148
+ toolCallId: null,
149
+ };
150
+ }
151
+ return {
152
+ role: message.role,
153
+ content: contextValueFingerprint(message.content),
154
+ toolCalls: contextValueFingerprint(message.toolCalls),
155
+ thinkingBlocks: contextValueFingerprint(message.thinkingBlocks),
156
+ assistantBlocks: contextValueFingerprint(message.assistantBlocks),
157
+ toolCallId: message?.toolCallId || null,
158
+ };
159
+ }
160
+
161
+ function sameContextValueFingerprint(a, b) {
162
+ if (!a || !b || a.value !== b.value) return false;
163
+ if (a.entries === null || b.entries === null) return a.entries === b.entries;
164
+ if (a.entries.length !== b.entries.length) return false;
165
+ for (let index = 0; index < a.entries.length; index += 1) {
166
+ if (!sameContextBlockFingerprint(a.entries[index], b.entries[index])) return false;
167
+ }
168
+ return true;
169
+ }
170
+
171
+ function sameContextBlockFingerprint(a, b) {
172
+ return !!a && !!b
173
+ && a.value === b.value
174
+ && a.text === b.text
175
+ && a.content === b.content
176
+ && a.args === b.args
177
+ && Object.is(a.arguments, b.arguments)
178
+ && a.input === b.input
179
+ && a.function === b.function
180
+ && a.functionArguments === b.functionArguments;
181
+ }
182
+
183
+ function sameContextMessageFingerprint(a, b) {
184
+ return !!a && a.role === b.role
185
+ && sameContextValueFingerprint(a.content, b.content)
186
+ && sameContextValueFingerprint(a.toolCalls, b.toolCalls)
187
+ && sameContextValueFingerprint(a.thinkingBlocks, b.thinkingBlocks)
188
+ && sameContextValueFingerprint(a.assistantBlocks, b.assistantBlocks)
189
+ && a.toolCallId === b.toolCallId;
190
+ }
191
+
192
+ function contextMessageContribution(message) {
193
+ const fingerprint = contextMessageFingerprint(message);
194
+ if (message && typeof message === 'object') {
195
+ const cached = contextMessageMemo.get(message);
196
+ if (cached && sameContextMessageFingerprint(cached.fingerprint, fingerprint)) return cached.contribution;
197
+ }
198
+ const role = ['system', 'user', 'assistant', 'tool'].includes(fingerprint.role) ? fingerprint.role : 'other';
199
+ const text = messageEstimateText(message);
200
+ const tokens = estimateTokens(text) + 4;
201
+ const contribution = {
202
+ role,
203
+ tokens,
204
+ reminderBuckets: null,
205
+ toolCallCount: 0,
206
+ toolCallTokens: 0,
207
+ toolResultCount: role === 'tool' ? 1 : 0,
208
+ toolResultTokens: role === 'tool' ? tokens : 0,
209
+ };
210
+ if (role === 'user' && String(text || '').trim().startsWith('<system-reminder>')) {
211
+ const buckets = { tokens: contribution.tokens, otherTokens: contribution.tokens };
212
+ let sectionTokens = 0;
213
+ for (const section of splitMarkdownSections(stripSystemReminder(text))) {
214
+ const bucket = reminderSectionBucket(section);
215
+ const sectionTokenCount = estimateTokens(section);
216
+ buckets[bucket] = (buckets[bucket] || 0) + sectionTokenCount;
217
+ sectionTokens += sectionTokenCount;
218
+ }
219
+ buckets.otherTokens = Math.max(0, contribution.tokens - sectionTokens);
220
+ contribution.reminderBuckets = buckets;
221
+ }
222
+ if (fingerprint.role === 'assistant' && Array.isArray(message?.toolCalls) && message.toolCalls.length) {
223
+ contribution.toolCallCount = message.toolCalls.length;
224
+ try { contribution.toolCallTokens = estimateTokens(JSON.stringify(message.toolCalls)); }
225
+ catch { contribution.toolCallTokens = estimateTokens(`[${message.toolCalls.length} tool calls]`); }
226
+ }
227
+ if (message && typeof message === 'object') {
228
+ contextMessageMemo.set(message, { fingerprint, contribution });
229
+ }
230
+ return contribution;
231
+ }
232
+
233
+ function emptyContextSummaryState() {
234
+ return {
235
+ rows: {
236
+ system: { count: 0, tokens: 0 },
237
+ user: { count: 0, tokens: 0 },
238
+ assistant: { count: 0, tokens: 0 },
239
+ tool: { count: 0, tokens: 0 },
240
+ other: { count: 0, tokens: 0 },
241
+ },
242
+ semantic: {
243
+ system: { count: 0, tokens: 0 },
244
+ chat: { count: 0, tokens: 0 },
245
+ assistant: { count: 0, tokens: 0 },
246
+ toolResults: { count: 0, tokens: 0 },
247
+ reminders: { count: 0, tokens: 0, otherTokens: 0 },
248
+ workflow: { tokens: 0 },
249
+ memory: { tokens: 0 },
250
+ workspace: { tokens: 0 },
251
+ environment: { tokens: 0 },
252
+ other: { tokens: 0 },
253
+ },
254
+ estimatedTokens: 0,
255
+ toolCallCount: 0,
256
+ toolCallTokens: 0,
257
+ toolResultCount: 0,
258
+ toolResultTokens: 0,
259
+ };
260
+ }
261
+
262
+ function applyContextMessageContribution(state, contribution, direction) {
263
+ const { role, tokens } = contribution;
264
+ state.estimatedTokens += direction * tokens;
265
+ state.rows[role].count += direction;
266
+ state.rows[role].tokens += direction * tokens;
267
+ if (role === 'system') {
268
+ state.semantic.system.count += direction;
269
+ state.semantic.system.tokens += direction * tokens;
270
+ } else if (role === 'user') {
271
+ if (contribution.reminderBuckets) {
272
+ state.semantic.reminders.count += direction;
273
+ state.semantic.reminders.tokens += direction * contribution.reminderBuckets.tokens;
274
+ state.semantic.reminders.otherTokens += direction * contribution.reminderBuckets.otherTokens;
275
+ for (const bucket of ['workflow', 'memory', 'workspace', 'environment', 'other']) {
276
+ state.semantic[bucket].tokens += direction * (contribution.reminderBuckets[bucket] || 0);
277
+ }
278
+ } else {
279
+ state.semantic.chat.count += direction;
280
+ state.semantic.chat.tokens += direction * tokens;
281
+ }
282
+ } else if (role === 'assistant') {
283
+ state.semantic.assistant.count += direction;
284
+ state.semantic.assistant.tokens += direction * tokens;
285
+ } else if (role === 'tool') {
286
+ state.semantic.toolResults.count += direction;
287
+ state.semantic.toolResults.tokens += direction * tokens;
288
+ }
289
+ state.toolCallCount += direction * contribution.toolCallCount;
290
+ state.toolCallTokens += direction * contribution.toolCallTokens;
291
+ state.toolResultCount += direction * contribution.toolResultCount;
292
+ state.toolResultTokens += direction * contribution.toolResultTokens;
293
+ }
294
+
295
+ function contextSummaryResult(state, count) {
296
+ return {
297
+ count,
298
+ estimatedTokens: state.estimatedTokens,
299
+ roles: Object.fromEntries(Object.entries(state.rows).map(([role, row]) => [role, { ...row }])),
300
+ semantic: Object.fromEntries(Object.entries(state.semantic).map(([name, row]) => [name, { ...row }])),
301
+ toolCallCount: state.toolCallCount,
302
+ toolCallTokens: state.toolCallTokens,
303
+ toolResultCount: state.toolResultCount,
304
+ toolResultTokens: state.toolResultTokens,
305
+ };
306
+ }
307
+
102
308
  export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
103
309
  export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
104
310
  export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
@@ -301,81 +507,23 @@ function reminderSectionBucket(section) {
301
507
  }
302
508
 
303
509
  export function summarizeContextMessages(messages) {
304
- const rows = {
305
- system: { count: 0, tokens: 0 },
306
- user: { count: 0, tokens: 0 },
307
- assistant: { count: 0, tokens: 0 },
308
- tool: { count: 0, tokens: 0 },
309
- other: { count: 0, tokens: 0 },
310
- };
311
- const semantic = {
312
- system: { count: 0, tokens: 0 },
313
- chat: { count: 0, tokens: 0 },
314
- assistant: { count: 0, tokens: 0 },
315
- toolResults: { count: 0, tokens: 0 },
316
- reminders: { count: 0, tokens: 0, otherTokens: 0 },
317
- workflow: { tokens: 0 },
318
- memory: { tokens: 0 },
319
- workspace: { tokens: 0 },
320
- environment: { tokens: 0 },
321
- other: { tokens: 0 },
322
- };
323
- let toolCallCount = 0;
324
- let toolCallTokens = 0;
325
- let toolResultCount = 0;
326
- let toolResultTokens = 0;
327
- for (const message of messages || []) {
328
- const role = rows[message?.role] ? message.role : 'other';
329
- const text = messageEstimateText(message);
330
- const tokens = estimateMessageTokens(message);
331
- rows[role].count += 1;
332
- rows[role].tokens += tokens;
333
- if (role === 'system') {
334
- semantic.system.count += 1;
335
- semantic.system.tokens += tokens;
336
- } else if (role === 'user') {
337
- if (String(text || '').trim().startsWith('<system-reminder>')) {
338
- semantic.reminders.count += 1;
339
- semantic.reminders.tokens += tokens;
340
- let sectionTokens = 0;
341
- for (const section of splitMarkdownSections(stripSystemReminder(text))) {
342
- const bucket = reminderSectionBucket(section);
343
- const sectionTokenCount = estimateTokens(section);
344
- semantic[bucket].tokens += sectionTokenCount;
345
- sectionTokens += sectionTokenCount;
346
- }
347
- semantic.reminders.otherTokens += Math.max(0, tokens - sectionTokens);
348
- } else {
349
- semantic.chat.count += 1;
350
- semantic.chat.tokens += tokens;
351
- }
352
- } else if (role === 'assistant') {
353
- semantic.assistant.count += 1;
354
- semantic.assistant.tokens += tokens;
355
- } else if (role === 'tool') {
356
- semantic.toolResults.count += 1;
357
- semantic.toolResults.tokens += tokens;
358
- }
359
- if (message?.role === 'assistant' && Array.isArray(message.toolCalls) && message.toolCalls.length) {
360
- toolCallCount += message.toolCalls.length;
361
- try { toolCallTokens += estimateTokens(JSON.stringify(message.toolCalls)); }
362
- catch { toolCallTokens += estimateTokens(`[${message.toolCalls.length} tool calls]`); }
363
- }
364
- if (message?.role === 'tool') {
365
- toolResultCount += 1;
366
- toolResultTokens += tokens;
367
- }
510
+ if (!Array.isArray(messages)) return contextSummaryResult(emptyContextSummaryState(), 0);
511
+ let cached = contextTranscriptMemo.get(messages);
512
+ if (!cached || messages.length < cached.count) {
513
+ cached = { count: 0, contributions: [], state: emptyContextSummaryState() };
514
+ contextTranscriptMemo.set(messages, cached);
368
515
  }
369
- return {
370
- count: Array.isArray(messages) ? messages.length : 0,
371
- estimatedTokens: Array.isArray(messages) ? estimateMessagesTokens(messages) : 0,
372
- roles: rows,
373
- semantic,
374
- toolCallCount,
375
- toolCallTokens,
376
- toolResultCount,
377
- toolResultTokens,
378
- };
516
+ for (let index = 0; index < messages.length; index += 1) {
517
+ const previous = cached.contributions[index];
518
+ const contribution = contextMessageContribution(messages[index]);
519
+ if (previous === contribution) continue;
520
+ if (previous) applyContextMessageContribution(cached.state, previous, -1);
521
+ cached.contributions[index] = contribution;
522
+ applyContextMessageContribution(cached.state, contribution, 1);
523
+ }
524
+ cached.contributions.length = messages.length;
525
+ cached.count = messages.length;
526
+ return contextSummaryResult(cached.state, messages.length);
379
527
  }
380
528
 
381
529
  // Per-request overhead the provider injects that never appears in the
@@ -383,6 +531,39 @@ export function summarizeContextMessages(messages) {
383
531
  // provider wraps around the request. The chars/4 message estimate misses all
384
532
  // of it, so a "fits" verdict computed from messages alone is optimistic.
385
533
  const REQUEST_OVERHEAD_TOKENS = 512;
534
+ const toolSchemaTokenMemo = new WeakMap();
535
+ const requestReserveTokenMemo = new WeakMap();
536
+
537
+ function sameToolArrayEntries(cached, tools) {
538
+ if (!cached || cached.entries.length !== tools.length) return false;
539
+ for (let index = 0; index < tools.length; index += 1) {
540
+ const entry = cached.entries[index];
541
+ const tool = tools[index];
542
+ if (entry.tool !== tool
543
+ || entry.name !== tool?.name
544
+ || entry.description !== tool?.description
545
+ || entry.inputSchema !== tool?.inputSchema
546
+ || entry.input_schema !== tool?.input_schema
547
+ || entry.parameters !== tool?.parameters
548
+ || entry.schema !== tool?.schema) return false;
549
+ }
550
+ return true;
551
+ }
552
+
553
+ function cacheToolArrayValue(tools, value) {
554
+ return {
555
+ entries: tools.map((tool) => ({
556
+ tool,
557
+ name: tool?.name,
558
+ description: tool?.description,
559
+ inputSchema: tool?.inputSchema,
560
+ input_schema: tool?.input_schema,
561
+ parameters: tool?.parameters,
562
+ schema: tool?.schema,
563
+ })),
564
+ value,
565
+ };
566
+ }
386
567
 
387
568
  /**
388
569
  * Estimate the token cost of the tool/function schemas a provider appends to
@@ -394,10 +575,14 @@ const REQUEST_OVERHEAD_TOKENS = 512;
394
575
  */
395
576
  export function estimateToolSchemaTokens(tools) {
396
577
  if (!Array.isArray(tools) || tools.length === 0) return 0;
578
+ const cached = toolSchemaTokenMemo.get(tools);
579
+ if (sameToolArrayEntries(cached, tools)) return cached.value;
397
580
  let text = '';
398
581
  try { text = JSON.stringify(tools); }
399
582
  catch { text = tools.map(t => String(t?.name ?? '')).join(''); }
400
- return estimateTokens(text);
583
+ const tokens = estimateTokens(text);
584
+ toolSchemaTokenMemo.set(tools, cacheToolArrayValue(tools, tokens));
585
+ return tokens;
401
586
  }
402
587
 
403
588
  /**
@@ -407,7 +592,12 @@ export function estimateToolSchemaTokens(tools) {
407
592
  * request-side bytes the message estimate cannot see.
408
593
  */
409
594
  export function estimateRequestReserveTokens(tools) {
410
- return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
595
+ if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
596
+ const cached = requestReserveTokenMemo.get(tools);
597
+ if (sameToolArrayEntries(cached, tools)) return cached.value;
598
+ const reserve = estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
599
+ requestReserveTokenMemo.set(tools, cacheToolArrayValue(tools, reserve));
600
+ return reserve;
411
601
  }
412
602
 
413
603
  /**
@@ -418,13 +608,15 @@ export function estimateRequestReserveTokens(tools) {
418
608
  *
419
609
  * @param {unknown[]} messages
420
610
  * @param {unknown[]|number} toolsOrReserve tool list or precomputed reserve tokens
421
- * @param {{ messageCount?: number }} [opts]
611
+ * @param {{ messageCount?: number, estimatedMessageTokens?: number }} [opts]
422
612
  */
423
613
  export function estimateTranscriptContextUsage(messages, toolsOrReserve, opts = {}) {
424
614
  const list = Array.isArray(messages) ? messages : [];
425
615
  const count = Number.isFinite(Number(opts.messageCount)) ? Number(opts.messageCount) : list.length;
426
616
  if (count <= 0 || list.length === 0) return 0;
427
- const messageTokens = estimateMessagesTokens(list);
617
+ const messageTokens = Number.isFinite(Number(opts.estimatedMessageTokens))
618
+ ? Number(opts.estimatedMessageTokens)
619
+ : summarizeContextMessages(list).estimatedTokens;
428
620
  const reserve = typeof toolsOrReserve === 'number' && Number.isFinite(toolsOrReserve)
429
621
  ? Math.max(0, toolsOrReserve)
430
622
  : estimateRequestReserveTokens(toolsOrReserve);
@@ -15,7 +15,8 @@ import { isAgentOwner } from '../../agent-owner.mjs';
15
15
 
16
16
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
17
17
  // to execute during SSE parsing so tool work overlaps with the rest of the
18
- // stream. Writes, bash, MCP and skills stay serial after send() returns.
18
+ // stream. MCP follows that annotation; writes, bash, and skills stay serial
19
+ // after send() returns.
19
20
  // Memoized: the read-only name Set is built once per distinct `tools` array
20
21
  // (keyed by identity via a module-level WeakMap) so repeated per-call lookups
21
22
  // are O(1) instead of O(N) tools.find scans.
@@ -0,0 +1,123 @@
1
+ // Process-local delivered-completion registry.
2
+ //
3
+ // Purpose: a task-completion body that was already injected into the active TUI
4
+ // loop and ACKed (modelVisibleDelivered) must never ALSO be enqueued into — or
5
+ // drained from — the session pending queue one turn later. The TUI ack and the
6
+ // racing background/fallback enqueue run on different code paths within the SAME
7
+ // process, so this in-memory registry lets an enqueue/drain site recognize "this
8
+ // completion was already delivered" and skip it.
9
+ //
10
+ // Recorded keys (either one matching = already delivered):
11
+ // - meta.execution_id (when present)
12
+ // - a hash of the model-visible completion text
13
+ //
14
+ // Bounded + TTL'd so a long-lived process never accumulates unbounded entries.
15
+ // Both record and a matching lookup REFRESH the entry (sliding TTL), so an
16
+ // ACKed completion survives across long-idle turns as long as it keeps being
17
+ // checked. Headless/API sessions never ack, so the registry stays empty for
18
+ // them and marked in-memory completions drain exactly once (behavior unchanged).
19
+ //
20
+ // Residual risk: with a 6h sliding TTL the only remaining eviction path is the
21
+ // bounded-size cap — if MORE than DELIVERED_MAX_ENTRIES distinct completions are
22
+ // delivered before an older ACKed key is next checked, that oldest key is
23
+ // evicted and its (now un-refreshed) completion could enqueue/drain a second
24
+ // time. This requires >512 live completions between a delivery and its drain,
25
+ // which is far outside normal single-process interactive use.
26
+ import { createHash } from 'node:crypto';
27
+
28
+ const DELIVERED_TTL_MS = 6 * 60 * 60 * 1000; // 6h, sliding (refreshed on record + hit)
29
+ const DELIVERED_MAX_ENTRIES = 512;
30
+ // key -> expiresAt (ms). Insertion order = age order (Map guarantee), so the
31
+ // oldest key is always the first — used for bounded-size eviction.
32
+ const _delivered = new Map();
33
+
34
+ export function hashCompletionText(text) {
35
+ const value = String(text ?? '').trim();
36
+ if (!value) return null;
37
+ return createHash('sha1').update(value).digest('hex');
38
+ }
39
+
40
+ function execKey(executionId) {
41
+ const id = executionId == null ? '' : String(executionId).trim();
42
+ return id ? `id:${id}` : null;
43
+ }
44
+
45
+ function textKey(text) {
46
+ const h = hashCompletionText(text);
47
+ return h ? `tx:${h}` : null;
48
+ }
49
+
50
+ function pruneExpired(now) {
51
+ for (const [key, expiresAt] of _delivered) {
52
+ if (expiresAt <= now) _delivered.delete(key);
53
+ }
54
+ }
55
+
56
+ function enforceBound() {
57
+ while (_delivered.size > DELIVERED_MAX_ENTRIES) {
58
+ const oldest = _delivered.keys().next().value;
59
+ if (oldest === undefined) break;
60
+ _delivered.delete(oldest);
61
+ }
62
+ }
63
+
64
+ // Record a delivered completion under its execution_id (when present) and its
65
+ // model-visible text hash. Both keys point at the same expiry so a later
66
+ // enqueue/drain matching EITHER is recognized as already delivered. Re-recording
67
+ // an existing key refreshes its expiry (sliding TTL).
68
+ export function recordDeliveredCompletion({ executionId, text } = {}) {
69
+ const now = Date.now();
70
+ pruneExpired(now);
71
+ const expiresAt = now + DELIVERED_TTL_MS;
72
+ let recorded = false;
73
+ for (const key of [execKey(executionId), textKey(text)]) {
74
+ if (!key) continue;
75
+ _delivered.delete(key); // re-insert to refresh age order
76
+ _delivered.set(key, expiresAt);
77
+ recorded = true;
78
+ }
79
+ if (recorded) enforceBound();
80
+ return recorded;
81
+ }
82
+
83
+ export function isDeliveredCompletion({ executionId, text } = {}) {
84
+ const now = Date.now();
85
+ const keys = [execKey(executionId), textKey(text)].filter(Boolean);
86
+ let hit = false;
87
+ for (const key of keys) {
88
+ const expiresAt = _delivered.get(key);
89
+ if (expiresAt === undefined) continue;
90
+ if (expiresAt <= now) { _delivered.delete(key); continue; }
91
+ hit = true;
92
+ }
93
+ if (!hit) return false;
94
+ // Sliding refresh: extend every present key (and move it to the age tail) so
95
+ // a long-idle drain that checks it later still recognizes it as delivered.
96
+ const expiresAt = now + DELIVERED_TTL_MS;
97
+ for (const key of keys) {
98
+ if (!_delivered.has(key)) continue;
99
+ _delivered.delete(key);
100
+ _delivered.set(key, expiresAt);
101
+ }
102
+ return true;
103
+ }
104
+
105
+ // One diagnostic stderr line per dedup skip, naming the site
106
+ // (mirror/fallback/notify-enqueue/drain) so the actual racing edge is
107
+ // empirically confirmable in logs.
108
+ export function logDuplicateSkip(site, { executionId, text } = {}) {
109
+ try {
110
+ const id = executionId == null ? '' : String(executionId).trim();
111
+ const h = hashCompletionText(text);
112
+ process.stderr.write(
113
+ `[completion-dedup] skip site=${site}`
114
+ + `${id ? ` execution_id=${id}` : ''}`
115
+ + `${h ? ` hash=${h.slice(0, 12)}` : ''}\n`,
116
+ );
117
+ } catch { /* ignore */ }
118
+ }
119
+
120
+ // Test-only reset hook.
121
+ export function _clearDeliveredCompletions() {
122
+ _delivered.clear();
123
+ }
@@ -13,10 +13,22 @@ import { nonNegativeIntEnv } from './env-utils.mjs';
13
13
  const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
14
14
  const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
15
15
  const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
16
- const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h far longer than any realistic ask race window
16
+ // Tombstone unlink TTL. The guarded resurrection race (temp-write→rename inside
17
+ // _doSave) resolves in microseconds, so 1h is still vastly longer than any
18
+ // realistic in-flight ask race — while short enough that matured tombstones are
19
+ // reclaimed promptly instead of accumulating for a full day.
20
+ const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
17
21
  let _cleanupTimer = null;
18
22
  let _cleanupInitialTimer = null;
19
23
 
24
+ // A session is "live" when it still owns a non-closed runtime entry. Passed to
25
+ // the retention cap so the active/current and any in-flight session is never
26
+ // pruned by the open-session max-age/max-count bounds.
27
+ function _isSessionLive(id) {
28
+ const entry = _getRuntimeEntry(id);
29
+ return !!(entry && entry.closed !== true);
30
+ }
31
+
20
32
  function _previewIds(items, limit = 5) {
21
33
  const ids = (items || []).slice(0, limit).map((item) => item.id).filter(Boolean);
22
34
  if (ids.length === 0) return '';
@@ -29,6 +41,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
29
41
  try {
30
42
  const result = sweepStaleSessions({
31
43
  tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
44
+ isSessionLive: _isSessionLive,
32
45
  });
33
46
  const {
34
47
  cleaned,
@@ -81,7 +94,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
81
94
  * Rationale: closeSession() leaves the tombstone on disk as the authoritative
82
95
  * resurrection-blocker for racing saveSession() calls. That race resolves in
83
96
  * microseconds (the window inside _doSave between temp write and rename), so
84
- * 24h is vastly safe. After the TTL expires we reclaim the disk slot.
97
+ * 1h is vastly safe. After the TTL expires we reclaim the disk slot.
85
98
  *
86
99
  * Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
87
100
  * latter's inline 30-min idle cleanup would race-unlink tombstones before we
@@ -5,6 +5,7 @@ import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
5
5
  import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
6
6
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
7
7
  import { loadSession } from '../store.mjs';
8
+ import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
8
9
 
9
10
  const _sessionPendingMessages = new Map();
10
11
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
@@ -24,6 +25,31 @@ function isCompletionNotificationEntry(entry) {
24
25
  && entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
25
26
  }
26
27
 
28
+ // Pre-marker completion notifications were persisted as plain strings,
29
+ // indistinguishable from genuine user/steering messages except by their
30
+ // model-visible wrapper shape. Such stale strings must never replay into a
31
+ // resumed session, but they carry no notificationKind marker, so the marker
32
+ // check alone leaves them behind. Because this is a SILENT-drop path, the
33
+ // shared lenient wrapper detector is too broad (a user message quoting a
34
+ // completion card could be dropped), so this uses its OWN strict recognizer:
35
+ // the string must be a verbatim full-card paste and nothing else —
36
+ // (1) start with the exact instruction preamble + "\n\nResult:\n",
37
+ // (2) have EVERY non-empty body line quoted with "> " (100%),
38
+ // (3) carry no extra leading/trailing prose (whitespace-trim only).
39
+ // Conservative by design: a false negative just keeps a legacy string, but a
40
+ // false positive on genuine user text would silently drop a real message.
41
+ const LEGACY_COMPLETION_CARD_PREAMBLE_RE = /^The async \S+ task \S+ has finished \([^)]*\) - review this result in your next step\.\n\nResult:\n/;
42
+ function isLegacyUnmarkedCompletionNotification(entry) {
43
+ if (typeof entry !== 'string') return false;
44
+ const value = entry.trim();
45
+ const match = LEGACY_COMPLETION_CARD_PREAMBLE_RE.exec(value);
46
+ if (!match) return false;
47
+ const body = value.slice(match[0].length);
48
+ const lines = body.split(/\r?\n/).filter((line) => line.length > 0);
49
+ if (lines.length === 0) return false;
50
+ return lines.every((line) => line.startsWith('> '));
51
+ }
52
+
27
53
  // Canonical completion-enqueue tagger. Every deferred tool/agent completion
28
54
  // notification MUST be enqueued through this so drain can discard it on resume
29
55
  // (never replay out-of-order). Pass the model-visible completion text (or an
@@ -393,8 +419,21 @@ export function drainPendingMessages(sessionId) {
393
419
  // in-memory queue is empty, so keeping it only ever delivers live entries.
394
420
  // Genuine user/steering messages carry no marker and are kept in order in
395
421
  // both paths.
396
- const memoryKept = memory;
397
- const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m));
422
+ // Drain-time belt: drop MARKED in-memory completion entries whose text hash
423
+ // was already delivered+ACKed (TUI execution-ui) this process — those would
424
+ // double-inject next turn. Only marked completion entries are eligible;
425
+ // genuine user/steering entries carry no marker and are always kept.
426
+ const memoryKept = memory.filter((m) => {
427
+ if (!isCompletionNotificationEntry(m)) return true;
428
+ const text = pendingMessageText(m);
429
+ if (text && isDeliveredCompletion({ text })) {
430
+ logDuplicateSkip('drain', { text });
431
+ return false;
432
+ }
433
+ return true;
434
+ });
435
+ const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m)
436
+ && !isLegacyUnmarkedCompletionNotification(m));
398
437
  const memoryVisible = modelVisiblePendingMessages(memoryKept);
399
438
  const persistedVisible = modelVisiblePendingMessages(persistedKept);
400
439
  if (memoryVisible.length === 0) return persistedVisible;
@@ -24,7 +24,7 @@ import { _closeBashSessionLazy } from './runtime-loaders.mjs';
24
24
  * and recreates the session in its pre-close state.
25
25
  *
26
26
  * Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
27
- * TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
27
+ * TOMBSTONE_MAX_AGE_MS (1h — vastly longer than the microsecond in-flight race).
28
28
  */
29
29
  export function closeSession(id, reason = 'manual', opts = {}) {
30
30
  // tombstone=false: detach runtime resources (heartbeat, bash shells,