mixdog 0.9.14 → 0.9.16

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 (125) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench/cache-probe-tasks.json +1 -1
  3. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  4. package/scripts/bench/r4-mixed-tasks.json +1 -1
  5. package/scripts/bench/review-tasks.json +1 -1
  6. package/scripts/build-runtime-windows.ps1 +242 -242
  7. package/scripts/explore-bench.mjs +5 -4
  8. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  9. package/scripts/output-style-smoke.mjs +3 -3
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/termio-input-smoke.mjs +199 -0
  13. package/scripts/tool-efficiency-diag.mjs +88 -0
  14. package/scripts/tool-smoke.mjs +40 -24
  15. package/scripts/tui-frame-harness-shim.mjs +32 -0
  16. package/scripts/tui-frame-harness.mjs +306 -0
  17. package/src/agents/heavy-worker/AGENT.md +6 -7
  18. package/src/agents/worker/AGENT.md +6 -7
  19. package/src/lib/keychain-cjs.cjs +28 -11
  20. package/src/lib/rules-builder.cjs +6 -10
  21. package/src/mixdog-session-runtime.mjs +90 -20
  22. package/src/output-styles/simple.md +22 -24
  23. package/src/rules/agent/00-core.md +12 -11
  24. package/src/rules/agent/30-explorer.md +22 -21
  25. package/src/rules/lead/01-general.md +8 -8
  26. package/src/rules/lead/02-channels.md +3 -3
  27. package/src/rules/lead/lead-brief.md +11 -12
  28. package/src/rules/shared/01-tool.md +25 -14
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
  33. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  34. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  40. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  41. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  44. package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
  45. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  46. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  47. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  48. package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  50. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  51. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  54. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
  56. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  74. package/src/runtime/memory/index.mjs +95 -8
  75. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  76. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  77. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  78. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  79. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  80. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  81. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  82. package/src/runtime/memory/lib/memory.mjs +88 -1
  83. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  84. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  86. package/src/runtime/shared/child-guardian.mjs +4 -2
  87. package/src/runtime/shared/open-url.mjs +2 -1
  88. package/src/runtime/shared/singleton-owner.mjs +26 -0
  89. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  90. package/src/runtime/shared/transcript-writer.mjs +3 -1
  91. package/src/session-runtime/config-helpers.mjs +7 -2
  92. package/src/session-runtime/cwd-plugins.mjs +6 -1
  93. package/src/session-runtime/effort.mjs +6 -1
  94. package/src/session-runtime/plugin-mcp.mjs +116 -12
  95. package/src/session-runtime/settings-api.mjs +7 -1
  96. package/src/standalone/channel-worker.mjs +25 -3
  97. package/src/standalone/explore-tool.mjs +5 -5
  98. package/src/standalone/hook-bus/config.mjs +38 -2
  99. package/src/standalone/hook-bus/handlers.mjs +103 -11
  100. package/src/standalone/hook-bus.mjs +20 -6
  101. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  102. package/src/tui/App.jsx +214 -30
  103. package/src/tui/app/core-memory-picker.mjs +6 -6
  104. package/src/tui/app/model-options.mjs +10 -4
  105. package/src/tui/app/settings-picker.mjs +5 -30
  106. package/src/tui/app/slash-commands.mjs +0 -1
  107. package/src/tui/app/slash-dispatch.mjs +0 -16
  108. package/src/tui/app/text-layout.mjs +57 -0
  109. package/src/tui/app/transcript-window.mjs +53 -2
  110. package/src/tui/app/use-mouse-input.mjs +60 -47
  111. package/src/tui/app/use-transcript-window.mjs +38 -10
  112. package/src/tui/components/PromptInput.jsx +18 -1
  113. package/src/tui/components/TextEntryPanel.jsx +97 -33
  114. package/src/tui/dist/index.mjs +567 -229
  115. package/src/tui/engine/tool-card-results.mjs +22 -5
  116. package/src/tui/engine.mjs +126 -7
  117. package/src/tui/index.jsx +4 -1
  118. package/src/workflows/default/WORKFLOW.md +27 -31
  119. package/vendor/ink/build/components/App.js +62 -17
  120. package/vendor/ink/build/ink.js +78 -3
  121. package/vendor/ink/build/input-parser.d.ts +9 -4
  122. package/vendor/ink/build/input-parser.js +45 -176
  123. package/vendor/ink/build/log-update.js +47 -2
  124. package/vendor/ink/build/termio-keypress.js +240 -0
  125. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -2,7 +2,8 @@
2
2
  // resolution. Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { getModelMetadataSync } from '../../providers/model-catalog.mjs';
4
4
  import { resolveCompactTriggerTokens } from '../context-utils.mjs';
5
- import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
5
+ import { COMPACT_TYPE_SEMANTIC, COMPACT_TYPE_RECALL_FASTTRACK, CONTEXT_SHARE_RATIO } from '../compact.mjs';
6
+ import { isAgentOwner } from '../../agent-owner.mjs';
6
7
 
7
8
  // Known context windows for the current-generation models this plugin
8
9
  // routes to. Anything not listed falls through to guessContextWindow() —
@@ -88,9 +89,8 @@ export function preserveBufferConfigFields(cfg = {}) {
88
89
  }
89
90
  return out;
90
91
  }
91
- const COMPACT_TARGET_RATIO = 0.02;
92
+ const COMPACT_TARGET_RATIO = CONTEXT_SHARE_RATIO;
92
93
  const COMPACT_TARGET_MIN_TOKENS = 4_000;
93
- const COMPACT_TARGET_MAX_TOKENS = 16_000;
94
94
  function compactTargetRatio() {
95
95
  const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
96
96
  ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
@@ -108,9 +108,8 @@ function compactTargetTokensForBoundary(boundaryTokens) {
108
108
  );
109
109
  if (explicit) return Math.max(1, Math.min(boundary, explicit));
110
110
  const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
111
- const maxTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MAX_TOKENS) || COMPACT_TARGET_MAX_TOKENS);
112
111
  const byRatio = Math.max(1, Math.floor(boundary * compactTargetRatio()));
113
- return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
112
+ return Math.max(1, Math.min(boundary, Math.max(minTarget, byRatio)));
114
113
  }
115
114
  function defaultEffectiveContextWindowPercent(provider) {
116
115
  // Gateway/statusline route metadata reserves a universal 10% headroom from
@@ -211,20 +210,16 @@ export function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens
211
210
  const targetEffective = compactTargetTokensForBoundary(boundary) || boundary;
212
211
  return Math.max(1, Math.min(boundary, targetEffective + reserve));
213
212
  }
214
- export function semanticCompactionEnabledForSession(session) {
215
- const cfg = session?.compaction || {};
216
- if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
217
- if (process.env.MIXDOG_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_COMPACT_SEMANTIC', true);
218
- if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
219
- if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
213
+ export function semanticCompactionEnabledForSession(_session) {
214
+ // Compact types are hard-locked (agent=semantic, main=recall-fasttrack),
215
+ // so semantic must always be available: it is the agent path AND the
216
+ // degraded fallback when recall-fasttrack fails. Env/config off-switches
217
+ // no longer apply.
220
218
  return true;
221
219
  }
222
220
  export function compactTypeForSession(session) {
223
- const cfg = session?.compaction || {};
224
- const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
225
- ?? process.env.MIXDOG_COMPACT_TYPE
226
- ?? cfg.type
227
- ?? cfg.compactType
228
- ?? cfg.compact_type;
229
- return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
221
+ // Hard-locked: agent-owned sessions are always semantic, all other
222
+ // (main/user) sessions are always recall-fasttrack. Env/config overrides
223
+ // no longer change the type.
224
+ return isAgentOwner(session) ? COMPACT_TYPE_SEMANTIC : COMPACT_TYPE_RECALL_FASTTRACK;
230
225
  }
@@ -316,7 +316,11 @@ export function drainPendingMessages(sessionId) {
316
316
  const q = _sessionPendingMessages.get(sessionId);
317
317
  const memory = q && q.length > 0 ? q.slice() : [];
318
318
  _sessionPendingMessages.delete(sessionId);
319
- const persisted = [...takeBufferedPendingMessages(sessionId), ...drainPersistedPendingMessages(sessionId)];
319
+ // FIFO: disk-persisted entries were flushed before the not-yet-flushed
320
+ // in-memory persist buffer, so they are strictly older — drain them
321
+ // first. Reversing this order (buffer before disk) delivered newer
322
+ // buffered sends ahead of older persisted ones after a restart.
323
+ const persisted = [...drainPersistedPendingMessages(sessionId), ...takeBufferedPendingMessages(sessionId)];
320
324
  const memoryVisible = modelVisiblePendingMessages(memory);
321
325
  const persistedVisible = modelVisiblePendingMessages(persisted);
322
326
  if (memoryVisible.length === 0) return persistedVisible;
@@ -87,6 +87,10 @@ function stringToolPermissionAllowList(toolPermission) {
87
87
  return null;
88
88
  }
89
89
 
90
+ // Read-write agent bundle: edit surface (apply_patch) WITHOUT shell/task.
91
+ // Repo-local shell work (git/build/test/verification) is Lead-owned by
92
+ // workflow contract; write-role agents patch files and hand verification
93
+ // back. Only permission 'full' (no narrowing) retains shell.
90
94
  const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
91
95
  'code_graph',
92
96
  'find',
@@ -95,7 +99,6 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
95
99
  'grep',
96
100
  'read',
97
101
  'apply_patch',
98
- 'shell',
99
102
  'explore',
100
103
  'search',
101
104
  'web_fetch',
@@ -645,8 +645,12 @@ export function createSession(opts) {
645
645
  const agentRulesAgent = opts.agent || opts.role || profile?.taskType || null;
646
646
  const agentRulesProfile = isRetrievalAgent ? 'retrieval' : 'full';
647
647
  const skipAgentRules = opts.skipAgentRules === true;
648
- // Retrieval roles already inject a compact # Tool Use via BP2; skip the full BP1 shared tool policy to avoid duplicating it.
649
- const injectedRules = (skipAgentRules || isRetrievalAgent) ? '' : _buildSharedRules();
648
+ // BP1 shared tool policy ships to EVERY role (Lead, workers, retrieval,
649
+ // maintenance): its anti-spiral clauses (one anchor is enough, never
650
+ // repeat equivalent patterns/scopes, plausible hit → stop) are exactly
651
+ // what narrow retrieval roles need. Role docs (e.g. 30-explorer.md)
652
+ // override role-inapplicable entries such as the explore routing row.
653
+ const injectedRules = skipAgentRules ? '' : _buildSharedRules();
650
654
  const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
651
655
  const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
652
656
  const roleSpecific = ownerIsAgent && !skipAgentRules ? _buildAgentSpecific(agentRulesAgent) : '';
@@ -1918,6 +1922,59 @@ export async function clearSessionMessages(sessionId, options = {}) {
1918
1922
  const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
1919
1923
  const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
1920
1924
  const now = Date.now();
1925
+ // --- Fork the outgoing transcript to a separate resumable session BEFORE
1926
+ // the wipe below. Runs for every clear path (plain /clear, auto-clear,
1927
+ // compact_clear) using the ORIGINAL `messages` (post-compact-gating, i.e.
1928
+ // whatever survived the requireCompactSuccess throw above), so the
1929
+ // conversation about to be discarded stays reachable via /resume under a
1930
+ // fresh id. Skipped for scratch sessions with no real user turn — nothing
1931
+ // worth resuming. Best-effort: any failure here must never block the
1932
+ // clear itself (mirrors the pre-compact failure handling above).
1933
+ if (hasUserConversationMessage(messages)) {
1934
+ try {
1935
+ const forkId = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
1936
+ const fork = {
1937
+ ...session,
1938
+ id: forkId,
1939
+ messages: messages.map((m) => (m && typeof m === 'object' ? { ...m } : m)),
1940
+ closed: false,
1941
+ status: 'idle',
1942
+ generation: 0,
1943
+ createdAt: now,
1944
+ updatedAt: now,
1945
+ lastUsedAt: now,
1946
+ lastHeartbeatAt: null,
1947
+ mcpPid: process.pid,
1948
+ // Strip runtime/liveness/routing state — the fork is a cold
1949
+ // snapshot, not a live process-owned session.
1950
+ clientHostPid: null,
1951
+ providerState: undefined,
1952
+ totalInputTokens: 0,
1953
+ totalOutputTokens: 0,
1954
+ totalCachedReadTokens: 0,
1955
+ totalCacheWriteTokens: 0,
1956
+ lastInputTokens: 0,
1957
+ lastOutputTokens: 0,
1958
+ lastCachedReadTokens: 0,
1959
+ lastCacheWriteTokens: 0,
1960
+ lastContextTokens: 0,
1961
+ lastContextTokensUpdatedAt: now,
1962
+ lastContextTokensStaleAfterCompact: false,
1963
+ // Shell state must not alias the live session: resuming the
1964
+ // fork would otherwise reuse/close the original session's
1965
+ // persistent bash shells.
1966
+ implicitBashSessionId: null,
1967
+ allBashSessionIds: undefined,
1968
+ };
1969
+ delete fork.liveTurnMessages;
1970
+ setLiveSession(fork);
1971
+ void saveSessionAsync(fork).catch((err) => {
1972
+ try { process.stderr.write(`[session] clear-fork save failed (sess=${forkId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
1973
+ });
1974
+ } catch (err) {
1975
+ try { process.stderr.write(`[session] clear-fork failed (sess=${sessionId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
1976
+ }
1977
+ }
1921
1978
  session.messages = keep;
1922
1979
  session.sessionStartMetaInjected = false;
1923
1980
  session.totalInputTokens = 0;
@@ -187,3 +187,21 @@ export function _removeSessionSummary(id) {
187
187
  }, { compact: true, lock: true });
188
188
  } catch { /* summary index is best-effort */ }
189
189
  }
190
+
191
+ /**
192
+ * Batch removal: prune MANY ids from the summary index in a single
193
+ * read-modify-write. Bulk deleters (tombstone sweep) must use this instead
194
+ * of per-id _removeSessionSummary — the index is O(sessions) in size, so
195
+ * per-id rewrites make a large sweep quadratic in total I/O.
196
+ */
197
+ export function _pruneSummaryIndexIds(ids) {
198
+ if (!(ids instanceof Set) || ids.size === 0) return;
199
+ try {
200
+ updateJsonAtomicSync(summaryIndexPath(), (cur) => {
201
+ const index = _normalizeSummaryIndex(cur);
202
+ const rows = index.rows.filter((r) => !ids.has(r.id));
203
+ if (rows.length === index.rows.length) return undefined;
204
+ return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
205
+ }, { compact: true, lock: true });
206
+ } catch { /* summary index is best-effort */ }
207
+ }
@@ -12,6 +12,7 @@ import { getPluginData } from '../config.mjs';
12
12
  import { isAgentOwner } from '../agent-owner.mjs';
13
13
  import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
14
14
  import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
15
+ import { scanTopLevelLifecycle } from './lifecycle-scan.mjs';
15
16
  import {
16
17
  summaryIndexPath,
17
18
  _sessionSummary,
@@ -19,6 +20,7 @@ import {
19
20
  _writeSummaryIndex,
20
21
  _upsertSessionSummary,
21
22
  _removeSessionSummary,
23
+ _pruneSummaryIndexIds,
22
24
  } from './store-summary-index.mjs';
23
25
  // Facade re-export: summary-index API moved to store-summary-index.mjs; keep
24
26
  // prior importers of store.mjs unchanged.
@@ -358,28 +360,21 @@ function _shouldDrop(id, opts) {
358
360
  if (!existsSync(target)) return false;
359
361
  try {
360
362
  const raw = readFileSync(target, 'utf-8');
361
- // Tombstone check only needs `closed` and `generation`. Avoid a full
362
- // JSON.parse of the entire session (which can be MBs on long chats and
363
- // ran on every save). Fast-path a regex scan; only fall back to a full
364
- // parse if the lightweight scan is inconclusive.
365
- const closedMatch = /"closed"\s*:\s*(true|false)/.exec(raw);
366
- if (closedMatch && closedMatch[1] === 'true') {
367
- const genMatch = /"generation"\s*:\s*(-?\d+)/.exec(raw);
368
- const diskGen = genMatch ? Number(genMatch[1]) : 0;
369
- return diskGen >= expected;
370
- }
371
- if (closedMatch && closedMatch[1] === 'false') {
372
- // Not closed still check the generation-as-ownership-counter
373
- // rule (see full-parse branch below) via the same fast regex
374
- // scan, so bumpSessionGeneration()'s detach bump is honoured
375
- // without paying for a full JSON.parse on every save.
376
- const genMatch = /"generation"\s*:\s*(-?\d+)/.exec(raw);
377
- const diskGen = genMatch ? Number(genMatch[1]) : 0;
378
- return diskGen > expected;
379
- }
380
- // No `closed` key found by scan — not a tombstone, so nothing to drop.
381
- if (!closedMatch && !/"closed"/.test(raw)) return false;
382
- const onDisk = JSON.parse(raw);
363
+ // Tombstone check only needs top-level `closed`/`generation`. A plain
364
+ // substring/regex pre-check is unsafe on its own: a message body can
365
+ // contain literal text like `{"closed":true}` (tool result, pasted
366
+ // JSON) which would spoof the guard if trusted directly. Instead of
367
+ // JSON.parse'ing the whole document on every guarded save (expensive:
368
+ // allocates the entire messages array just to read two scalars),
369
+ // scanTopLevelLifecycle walks the raw text with bracket-depth +
370
+ // string-escape awareness and only *interprets* key/value pairs at
371
+ // depth 1 — nested "closed"/"generation" inside messages are skipped
372
+ // by depth counting, never parsed, so they cannot be mistaken for the
373
+ // real top-level fields. Falls back to a full parse only if the scan
374
+ // reports malformed/truncated JSON (should not happen for files we
375
+ // wrote ourselves, but stay correct over clever).
376
+ let onDisk = scanTopLevelLifecycle(raw);
377
+ if (onDisk === null) onDisk = JSON.parse(raw);
383
378
  const diskGen = typeof onDisk.generation === 'number' ? onDisk.generation : 0;
384
379
  if (onDisk.closed === true) return diskGen >= expected;
385
380
  // Not closed, but `generation` also doubles as an ownership counter:
@@ -590,7 +585,7 @@ export function clearSessionSaveError(id) {
590
585
  _lastSaveError.delete(id);
591
586
  }
592
587
 
593
- export function deleteSession(id) {
588
+ export function deleteSession(id, options = {}) {
594
589
  const path = sessionPath(id);
595
590
  let removed = false;
596
591
  if (existsSync(path)) {
@@ -601,7 +596,10 @@ export function deleteSession(id) {
601
596
  catch { /* fall through to .hb cleanup */ }
602
597
  }
603
598
  _deleteHeartbeat(id);
604
- if (removed) _removeSessionSummary(id);
599
+ // deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
600
+ // rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
601
+ // summary index once PER DELETION. They batch the index update themselves.
602
+ if (removed && options.deferSummaryUpdate !== true) _removeSessionSummary(id);
605
603
  return removed;
606
604
  }
607
605
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
@@ -725,7 +723,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
725
723
  const age = now - updated;
726
724
  if (Number.isFinite(updated) && age >= tombstoneMaxAgeMs) {
727
725
  try {
728
- if (deleteSession(row.id)) {
726
+ if (deleteSession(row.id, { deferSummaryUpdate: true })) {
729
727
  tombstonesCleaned++;
730
728
  tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
731
729
  continue;
@@ -799,5 +797,15 @@ export function sweepStaleSessions(ttlMs, options = {}) {
799
797
  }
800
798
  }
801
799
  } catch { /* dir scan failure — non-fatal */ }
800
+ // Batched summary-index prune for deferred tombstone deletions: one
801
+ // read-modify-write for the whole sweep instead of one per deleted id
802
+ // (the index is multi-MB at scale; per-id rewrites made large sweeps
803
+ // quadratic and stalled boot for seconds).
804
+ if (tombstoneDetails.length > 0) {
805
+ try {
806
+ const deletedIds = new Set(tombstoneDetails.map((d) => d.id));
807
+ _pruneSummaryIndexIds(deletedIds);
808
+ } catch { /* summary index is best-effort */ }
809
+ }
802
810
  return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors };
803
811
  }
@@ -87,7 +87,7 @@ export const BUILTIN_TOOLS = [
87
87
  name: 'grep',
88
88
  title: 'Mixdog Grep',
89
89
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
90
- description: 'Search file contents by text/regex in a known scope. files_with_matches/count for broad anchors, content_with_context for narrow answers. One concept → one grep; multiple scopes = ONE call with path[].',
90
+ description: 'Search file contents by text/regex in a known scope. files_with_matches/count for broad anchors, content_with_context for narrow answers. One concept → ONE grep: all variants in pattern[], scopes in path[]; parallel single-pattern greps = packing failure.',
91
91
  inputSchema: {
92
92
  type: 'object',
93
93
  properties: {
@@ -8,7 +8,7 @@ import {
8
8
  normalizeOutputPath,
9
9
  resolveAgainstCwd,
10
10
  } from './path-utils.mjs';
11
- import { tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
11
+ import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
12
12
  import { normalizeErrorMessage } from './path-diagnostics.mjs';
13
13
  import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component } from './device-paths.mjs';
14
14
  import {
@@ -49,7 +49,9 @@ async function readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, o
49
49
  rerun: (target, opts) => rerunTool({ ...args, path: target }, workDir, opts),
50
50
  });
51
51
  if (redirected) return redirected;
52
- return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
52
+ const msg = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
53
+ const hint = buildNotFoundHint(workDir, fullPath, 'List', err?.code);
54
+ return msg + finalizeReadFamilyEnoentTail(hint, inputPath, err?.code);
53
55
  }
54
56
 
55
57
  function normalizeListHeadLimit(raw, defaultCap) {
@@ -344,8 +346,8 @@ export async function executeTreeTool(args, workDir, options = {}) {
344
346
  export async function executeFuzzyFindTool(args, workDir, options = {}) {
345
347
  if (Array.isArray(args.query)) {
346
348
  const list = [...new Set(args.query.map((q) => (typeof q === 'string' ? q.trim() : '')).filter((q) => q.length > 0))];
347
- const capped = list.length > 10;
348
- const targets = capped ? list.slice(0, 10) : list;
349
+ const capped = list.length > 5;
350
+ const targets = capped ? list.slice(0, 5) : list;
349
351
  if (targets.length > 1) {
350
352
  const sections = [];
351
353
  for (const q of targets) {
@@ -357,7 +359,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
357
359
  }
358
360
  sections.push(`# find ${q}\n${body}`);
359
361
  }
360
- if (capped) sections.push(`... [capped at 10 of ${list.length} queries]`);
362
+ if (capped) sections.push(`... [capped at 5 of ${list.length} queries]`);
361
363
  return sections.join('\n\n');
362
364
  }
363
365
  args.query = targets[0];
@@ -111,7 +111,8 @@ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } =
111
111
  const rel = relative(searchRoot, candidate);
112
112
  if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue;
113
113
  try {
114
- if (statSync(candidate).isFile()) {
114
+ const st = statSync(candidate);
115
+ if (st.isFile() || st.isDirectory()) {
115
116
  return rel.replace(/\\/g, '/');
116
117
  }
117
118
  } catch { /* miss this tail length, keep peeling */ }
@@ -120,6 +121,42 @@ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } =
120
121
  } catch { return null; }
121
122
  }
122
123
 
124
+ // Same as findFileByBasename but for directories sharing the missing path's
125
+ // final segment name (e.g. guessed `src/shared` → the real `lib/shared`).
126
+ export function findDirectoryByBasename(searchRoot, fullPath, { limit = 3, maxDirs = 6000 } = {}) {
127
+ try {
128
+ if (typeof searchRoot !== 'string' || !searchRoot) return [];
129
+ const target = basename(fullPath).toLowerCase();
130
+ if (!target) return [];
131
+ const matches = [];
132
+ const queue = [searchRoot];
133
+ let scanned = 0;
134
+ while (queue.length && matches.length < limit && scanned < maxDirs) {
135
+ const dir = queue.shift();
136
+ scanned++;
137
+ let entries;
138
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
139
+ catch { continue; }
140
+ for (const ent of entries) {
141
+ const name = ent.name;
142
+ if (ent.isDirectory()) {
143
+ if (name.toLowerCase() === target) {
144
+ const hit = join(dir, name);
145
+ const rel = relative(searchRoot, hit);
146
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
147
+ matches.push(rel.replace(/\\/g, '/'));
148
+ if (matches.length >= limit) break;
149
+ }
150
+ }
151
+ if (name.startsWith('.') || BASENAME_SCAN_SKIP_DIRS.has(name)) continue;
152
+ queue.push(join(dir, name));
153
+ }
154
+ }
155
+ }
156
+ return matches;
157
+ } catch { return []; }
158
+ }
159
+
123
160
  // Node's native fs errors embed the failing path wrapped in single quotes
124
161
  // using OS-native separators ('C:\\Users\\foo\\bar.mjs' on Windows). Without
125
162
  // this pass, read error bodies surface backslash paths that
@@ -243,6 +243,30 @@ export function coerceShapeFlex(value) {
243
243
  // left untouched — JSON reinterpretation only runs after a stat miss.
244
244
  export function coerceReadFamilyPathArg(path, workDir = null) {
245
245
  if (path === undefined || path === null || path === '') return path;
246
+ if (typeof path === 'string' && typeof workDir === 'string' && workDir) {
247
+ const trimmed = path.trim();
248
+ if (/\s/.test(trimmed)) {
249
+ let fullPathExists = false;
250
+ try {
251
+ statSync(resolveAgainstCwd(normalizeInputPath(trimmed), workDir));
252
+ fullPathExists = true;
253
+ } catch { /* split only when the literal path is missing */ }
254
+ if (!fullPathExists) {
255
+ const segments = trimmed.split(/\s+/).filter(Boolean).map((s) => normalizeInputPath(s));
256
+ if (segments.length >= 2) {
257
+ const allExist = segments.every((seg) => {
258
+ try {
259
+ statSync(resolveAgainstCwd(seg, workDir));
260
+ return true;
261
+ } catch {
262
+ return false;
263
+ }
264
+ });
265
+ if (allExist) return segments;
266
+ }
267
+ }
268
+ }
269
+ }
246
270
  if (typeof path === 'string' && typeof workDir === 'string' && workDir) {
247
271
  const trimmed = path.trim();
248
272
  if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
@@ -3,7 +3,7 @@ import * as fsPromises from 'fs/promises';
3
3
  import { readFile } from 'fs/promises';
4
4
  import { extname } from 'path';
5
5
  import { normalizeInputPath } from './path-utils.mjs';
6
- import { buildNotFoundHint, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
6
+ import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
7
7
  import { getReadSnapshot } from './read-snapshot-runtime.mjs';
8
8
  import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
9
9
  import { formatBinaryReadPreview } from './binary-file.mjs';
@@ -222,6 +222,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
222
222
  if (!similar) {
223
223
  hint = buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
224
224
  }
225
+ hint = finalizeReadFamilyEnoentTail(hint, filePath, err?.code);
225
226
  const _rawMsg = err instanceof Error ? err.message : String(err);
226
227
  const _safeMsg = normalizeErrorMessage(_rawMsg, workDir);
227
228
  return `Error: ${_safeMsg}${hint}`;
@@ -161,6 +161,14 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
161
161
  // Single turn can touch many files or swap modes without
162
162
  // the agent iterating across multiple tool names.
163
163
  if (Array.isArray(args.path) && args.path.length > 0 && args.path[0] && typeof args.path[0] === 'object') {
164
+ // Cap batch fan-out: never error on an oversized array, just
165
+ // truncate and note it in the final output (mirrors list-tool.mjs
166
+ // path[] cap pattern).
167
+ if (args.path.length > 10) {
168
+ const _origObjLen = args.path.length;
169
+ args.path = args.path.slice(0, 10);
170
+ args._batchCapNote = `... [capped at 10 of ${_origObjLen} paths]`;
171
+ }
164
172
  // Per-file batch: each entry carries its own options.
165
173
  // Coalesce same-path entries: multiple chunks for the same
166
174
  // file are merged into a single wider read (min offset to max
@@ -216,6 +224,11 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
216
224
  args.mode = undefined; args.n = undefined; args.offset = undefined; args.limit = undefined; args.full = undefined;
217
225
  }
218
226
  if (Array.isArray(args.path)) {
227
+ if (args.path.length > 10) {
228
+ const _origStrLen = args.path.length;
229
+ args.path = args.path.slice(0, 10);
230
+ if (!args._batchCapNote) args._batchCapNote = `... [capped at 10 of ${_origStrLen} paths]`;
231
+ }
219
232
  // Schema is `path: string | string[]` — array entries are
220
233
  // strings only. Top-level mode / n / offset / limit / full
221
234
  // apply uniformly to every entry in the batch (the only
@@ -398,7 +411,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
398
411
  const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
399
412
  return `${path} [${mode}] [${status}]${suffix}\n${r.body}`;
400
413
  }).join('\n\n');
401
- return `${header}\n\n${body}`;
414
+ return `${header}\n\n${body}${args._batchCapNote ? `\n\n${args._batchCapNote}` : ''}`;
402
415
  }
403
416
  // W1 H: device-file / UNC / scope guards must run BEFORE mode
404
417
  // dispatches so head/tail/wc internal readers can't bypass the
@@ -468,6 +468,8 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
468
468
  let seenAfterOffset = 0;
469
469
  let stoppedEarly = false;
470
470
  let timedOut = false;
471
+ let capExceeded = false;
472
+ let bufferBytes = 0;
471
473
  /** @type {'timeout' | 'early' | null} */
472
474
  let killReason = null;
473
475
  let settled = false;
@@ -527,6 +529,14 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
527
529
  }
528
530
  seenAfterOffset++;
529
531
  if (seenAfterOffset <= collectLimit) {
532
+ bufferBytes += Buffer.byteLength(line);
533
+ if (bufferBytes > MAX_RG_STDOUT_BYTES) {
534
+ // Enforce the same 20MB ceiling runRg uses: a runaway
535
+ // producer must not balloon the retained-lines heap.
536
+ capExceeded = true;
537
+ stopEarly();
538
+ return;
539
+ }
530
540
  lines.push(line);
531
541
  return;
532
542
  }
@@ -537,6 +547,13 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
537
547
  proc.stdout.on('data', (chunk) => {
538
548
  if (stoppedEarly) return;
539
549
  buffer += chunk;
550
+ // Guard against an unbounded single line (no newline): cap the
551
+ // pending buffer at the same 20MB ceiling and stop early.
552
+ if (buffer.length > MAX_RG_STDOUT_BYTES) {
553
+ capExceeded = true;
554
+ stopEarly();
555
+ return;
556
+ }
540
557
  let start = 0;
541
558
  let idx = buffer.indexOf('\n', start);
542
559
  while (!stoppedEarly && idx !== -1) {
@@ -579,7 +596,14 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
579
596
  return reject(e);
580
597
  }
581
598
  if (stoppedEarly) {
582
- return resolve({ lines, complete: false, totalSeen: seenAfterOffset });
599
+ return resolve({
600
+ lines,
601
+ complete: false,
602
+ totalSeen: seenAfterOffset,
603
+ // Surface the 20MB byte-cap breach so the caller can flag
604
+ // truncation, mirroring runRg's stdoutTruncated.
605
+ ...(capExceeded ? { partial: true, truncated: true } : {}),
606
+ });
583
607
  }
584
608
  if (code === 0 || code === 1) {
585
609
  return resolve({ lines, complete: true, totalSeen: seenAfterOffset });