mixdog 0.8.0 → 0.8.1

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.
@@ -38,13 +38,9 @@ function _stripMcpPrefix(name) {
38
38
  function _isReadTool(name) {
39
39
  return _stripMcpPrefix(name) === 'read';
40
40
  }
41
- function _isScalarWriteEditTool(name) {
42
- const n = _stripMcpPrefix(name);
43
- return n === 'write' || n === 'edit';
44
- }
45
41
  function _isMutationTool(name) {
46
42
  const n = _stripMcpPrefix(name);
47
- return n === 'apply_patch' || n === 'write' || n === 'edit';
43
+ return n === 'apply_patch';
48
44
  }
49
45
  const SCOPED_CACHEABLE_TOOLS = new Set([
50
46
  'code_graph',
@@ -375,8 +371,6 @@ function _ensureTranscriptPairing(msgs, sessionId) {
375
371
  // is the fail-safe reject at call time.
376
372
  const READ_BLOCKED_TOOLS = new Set([
377
373
  'shell', 'bash_session',
378
- 'write',
379
- 'edit',
380
374
  'apply_patch',
381
375
  ]);
382
376
  const MCP_ONLY_ALLOWED_KINDS = new Set(['mcp', 'internal', 'skill']);
@@ -1848,9 +1842,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1848
1842
  //
1849
1843
  // Intra-turn duplicate suppression: when an LLM emits two tool_use
1850
1844
  // blocks with identical (name, args) inside the SAME assistant turn,
1851
- // re-executing wastes tokens. Restricted to tools with
1852
- // `readOnlyHint:true` (= isEagerDispatchable) — bash/write/edit/
1853
- // apply_patch may be intentional repeats with distinct side effects.
1845
+ // re-executing wastes tokens. Restricted to tools with
1846
+ // `readOnlyHint:true` (= isEagerDispatchable) — bash/apply_patch
1847
+ // may be intentional repeats with distinct side effects.
1854
1848
  // Pre-pass identifies duplicates BEFORE startEagerRun so eager
1855
1849
  // dispatch also skips them, not just the for-body.
1856
1850
  const _duplicateCallIds = new Set();
@@ -2038,11 +2032,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2038
2032
  }
2039
2033
  }
2040
2034
  // A failed executed call keeps its FULL argument body in history so the
2041
- // model can retry against the original (a large apply_patch `patch` /
2042
- // edit `old_string` would otherwise be hidden behind a
2035
+ // model can retry against the original (a large apply_patch `patch`
2036
+ // would otherwise be hidden behind a
2043
2037
  // `[mixdog compacted …]` placeholder). Restored IMMEDIATELY — not at end
2044
2038
  // of loop — so an abort or post-processing throw after this point cannot
2045
- // leave a failed edit compacted. Cache-safe: _assistantTurnMsg is not
2039
+ // leave a failed patch compacted. Cache-safe: _assistantTurnMsg is not
2046
2040
  // transmitted until the next provider.send. Early-continue paths (dedup /
2047
2041
  // repeat-failure-guard) never reach here and stay compacted.
2048
2042
  if ((!_executeOk || _resultKind === 'error') && call?.id) {
@@ -2059,10 +2053,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2059
2053
  if (sessionId && _executeOk && _resultKind === 'normal') {
2060
2054
  const _toolBare = _stripMcpPrefix(call.name);
2061
2055
  if (_readCacheHit === null && _isReadTool(call.name)) {
2062
- // Post-edit advisory: handle BOTH scalar and array forms
2056
+ // Post-patch advisory: handle BOTH scalar and array forms
2063
2057
  // of args.path. The array form (path:[a,b,c] or
2064
2058
  // path:[{path:a},{path:b}]) was a coverage gap in R1 —
2065
- // an LLM that edits X then reads [X,Y] should still see
2059
+ // an LLM that patches X then reads [X,Y] should still see
2066
2060
  // the advisory for X.
2067
2061
  const _argsPath = call.arguments?.path;
2068
2062
  const _pathList = [];
@@ -2116,59 +2110,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2116
2110
  } else {
2117
2111
  clearScopedToolsForSession(sessionId);
2118
2112
  }
2119
- } else if (_isScalarWriteEditTool(call.name)) {
2120
- // Scalar `args.path` only: precise invalidate + advisory mark.
2121
- // Array-form (`edits[]`/`writes[]`): the tool may have partial-
2122
- // failed across paths and the result string aggregates;
2123
- // full-clear instead of falsely marking every path.
2124
- const _scalarPath = call.arguments?.path || call.arguments?.file_path;
2125
- const _hasArrayForm = Array.isArray(call.arguments?.edits)
2126
- || Array.isArray(call.arguments?.writes);
2127
- if (_hasArrayForm) {
2128
- clearReadDedupSession(sessionId);
2129
- clearScopedToolsForSession(sessionId);
2130
- // R20: array-form — walk each entry, extract its path,
2131
- // and invalidate the prefetch cache + mark post-edit for
2132
- // every distinct touched path. Falls back to the top-
2133
- // level `path` (or `file_path`) when an entry omits its
2134
- // own path. This covers both edit edits[] and write
2135
- // writes[] forms; entries without a resolvable path are
2136
- // silently skipped (their stat-validation safety net at
2137
- // next lookup still applies).
2138
- const _topPath = call.arguments?.path || call.arguments?.file_path;
2139
- const _entries = call.arguments?.edits || call.arguments?.writes || [];
2140
- const _seenPaths = new Set();
2141
- for (const _e of _entries) {
2142
- const _ep = _e?.path || _e?.file_path || _topPath;
2143
- if (typeof _ep === 'string' && _ep && !_seenPaths.has(_ep)) {
2144
- _seenPaths.add(_ep);
2145
- invalidatePathForSession(sessionId, _ep, cwd);
2146
- markPostEdit({ sessionId, path: _ep, cwd, toolName: _toolBare });
2147
- invalidatePrefetchCache(_ep, cwd);
2148
- }
2149
- }
2150
- if (_seenPaths.size > 0) {
2151
- clearScopedToolsForSessionPaths(sessionId, [..._seenPaths], cwd);
2152
- }
2153
- } else if (typeof _scalarPath === 'string') {
2154
- invalidatePathForSession(sessionId, _scalarPath, cwd);
2155
- markPostEdit({ sessionId, path: _scalarPath, cwd, toolName: _toolBare });
2156
- // R20: cross-dispatch prefetch cache invalidation.
2157
- invalidatePrefetchCache(_scalarPath, cwd);
2158
- // Targeted scoped-cache invalidation for the single touched path (D).
2159
- clearScopedToolsForSessionPaths(sessionId, [_scalarPath], cwd);
2160
- } else {
2161
- // No path extractable — full wipe fallback.
2162
- clearScopedToolsForSession(sessionId);
2163
- }
2164
2113
  }
2165
2114
  } // end _executeOk+_resultKind gate (scoped tool cache set)
2166
- // E: mutation tools (apply_patch / write / edit) must invalidate caches
2115
+ // E: mutation tools (apply_patch) must invalidate caches
2167
2116
  // even on returned-error/partial-fail — the file state is unknown after
2168
2117
  // an error exit, and some tools report failure as an Error: result string
2169
2118
  // rather than throwing.
2170
2119
  // This block runs unconditionally (not gated on _executeOk or _resultKind).
2171
- if (sessionId && (!_executeOk || _resultKind === 'error') && (_stripMcpPrefix(call.name) === 'apply_patch' || _isScalarWriteEditTool(call.name))) {
2120
+ if (sessionId && (!_executeOk || _resultKind === 'error') && _stripMcpPrefix(call.name) === 'apply_patch') {
2172
2121
  clearReadDedupSession(sessionId);
2173
2122
  }
2174
2123
  if (_isMutationTool(call.name)) {
@@ -23,7 +23,7 @@ import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
23
23
  import { executeCodeGraphTool } from '../tools/code-graph.mjs';
24
24
  import { closeBashSession } from '../tools/bash-session.mjs';
25
25
  import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectMixdogMd } from '../context/collect.mjs';
26
- import { saveSession, saveSessionAsync, loadSession, listStoredSessions, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
26
+ import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
27
27
  import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
28
28
  import { clearOffloadSession } from './tool-result-offload.mjs';
29
29
  import { classifyResultKind } from './result-classification.mjs';
@@ -251,7 +251,6 @@ const SESSION_ROUTE_TOOL_ORDER = [
251
251
  'task',
252
252
  ];
253
253
  const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
254
- const MODEL_HIDDEN_FILE_MUTATION_TOOLS = new Set(['edit', 'write']);
255
254
  const FILESYSTEM_TOOL_NAMES = new Set([
256
255
  'code_graph',
257
256
  'glob',
@@ -280,7 +279,7 @@ function orderSessionTools(tools) {
280
279
  }
281
280
 
282
281
  const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
283
- ...BUILTIN_TOOLS.filter(t => !MODEL_HIDDEN_FILE_MUTATION_TOOLS.has(t?.name)),
282
+ ...BUILTIN_TOOLS,
284
283
  ...PATCH_TOOL_DEFS,
285
284
  ...CODE_GRAPH_TOOL_DEFS,
286
285
  ]));
@@ -732,6 +731,7 @@ async function runSessionCompaction(session, opts = {}) {
732
731
  const changed = beforeEncoded && afterEncoded
733
732
  ? beforeEncoded !== afterEncoded
734
733
  : (compacted.length !== messages.length || afterMessageTokens !== beforeMessageTokens);
734
+ const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
735
735
  const now = Date.now();
736
736
  session.messages = compacted;
737
737
  session.providerState = undefined;
@@ -766,6 +766,7 @@ async function runSessionCompaction(session, opts = {}) {
766
766
  if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
767
767
  return {
768
768
  changed,
769
+ reason: unchangedReason,
769
770
  beforeMessages: messages.length,
770
771
  afterMessages: compacted.length,
771
772
  beforeTokens,
@@ -2572,12 +2573,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2572
2573
  // scope session when the caller passes --scope (agent/<name>).
2573
2574
  export function findSessionByScopeKey(scopeKey) {
2574
2575
  if (!scopeKey) return null;
2575
- const sessions = listStoredSessions();
2576
+ const summaries = listStoredSessionSummaries();
2576
2577
  // Exclude tombstoned sessions (`closed === true`) so callers never receive
2577
2578
  // a session whose controller was aborted by closeSession(). The `closed`
2578
2579
  // bit is the authoritative tombstone flag; `status === 'error'` is not,
2579
2580
  // since transient-error sessions remain resumable.
2580
- return sessions.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
2581
+ const summary = summaries.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
2582
+ return summary?.id ? loadSession(summary.id) : null;
2581
2583
  }
2582
2584
  // --- resume (reload tools for a stored session) ---
2583
2585
  export async function resumeSession(sessionId, preset) {
@@ -2617,7 +2619,7 @@ export function getSession(id) {
2617
2619
  }
2618
2620
  export function listSessions(opts = {}) {
2619
2621
  const includeClosed = opts.includeClosed === true;
2620
- const sessions = listStoredSessions();
2622
+ const sessions = listStoredSessionSummaries();
2621
2623
  const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
2622
2624
  // Tombstoned sessions (closed===true) are excluded unless the caller opts in
2623
2625
  // (e.g. bridge list includeClosed:true).
@@ -2818,7 +2820,7 @@ export function abortSessionTurn(id, reason = 'turn-abort') {
2818
2820
 
2819
2821
  // --- Periodic idle session cleanup ---
2820
2822
  const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
2821
- const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', 60 * 1000);
2823
+ const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
2822
2824
  const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
2823
2825
  const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
2824
2826
  let _cleanupTimer = null;
@@ -2922,7 +2924,17 @@ export function sweepTombstones() {
2922
2924
  }
2923
2925
  }
2924
2926
 
2927
+ function hasActiveRuntimeWork() {
2928
+ for (const [, entry] of _runtimeState) {
2929
+ if (!entry || entry.closed === true) continue;
2930
+ if (entry.controller && !entry.controller.signal?.aborted) return true;
2931
+ if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
2932
+ }
2933
+ return false;
2934
+ }
2935
+
2925
2936
  function _runCleanupCycle() {
2937
+ if (hasActiveRuntimeWork()) return;
2926
2938
  sweepIdleSessions({ includeTombstones: true });
2927
2939
  }
2928
2940
 
@@ -9,9 +9,10 @@ import { randomBytes } from 'crypto';
9
9
  import { join } from 'path';
10
10
  import { Worker } from 'worker_threads';
11
11
  import { getPluginData } from '../config.mjs';
12
- import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
12
+ import { renameWithRetrySync, updateJsonAtomicSync, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
13
13
 
14
14
  const _lastSaveError = new Map(); // id -> { message, at }
15
+ const SESSION_SUMMARY_INDEX_VERSION = 1;
15
16
 
16
17
  function _renameWithRetrySync(tmp, target) {
17
18
  return renameWithRetrySync(tmp, target);
@@ -32,6 +33,11 @@ function sessionPath(id) {
32
33
  }
33
34
  return join(getStoreDir(), `${id}.json`);
34
35
  }
36
+ function summaryIndexPath() {
37
+ const dir = getPluginData();
38
+ mkdirSync(dir, { recursive: true });
39
+ return join(dir, 'session-summaries.json');
40
+ }
35
41
  /**
36
42
  * Ensure generation/closed defaults on every session object.
37
43
  * Older persisted sessions predate these fields; we normalise at load and save.
@@ -44,6 +50,177 @@ function _ensureLifecycleFields(session) {
44
50
  return session;
45
51
  }
46
52
 
53
+ function _messageText(content) {
54
+ if (typeof content === 'string') return content;
55
+ if (Array.isArray(content)) {
56
+ return content.map((part) => {
57
+ if (typeof part === 'string') return part;
58
+ if (part?.type === 'text') return part.text || '';
59
+ if (typeof part?.text === 'string') return part.text;
60
+ try { return JSON.stringify(part); } catch { return ''; }
61
+ }).filter(Boolean).join('\n');
62
+ }
63
+ if (content && typeof content === 'object') {
64
+ if (typeof content.text === 'string') return content.text;
65
+ try { return JSON.stringify(content); } catch { return ''; }
66
+ }
67
+ return '';
68
+ }
69
+
70
+ function _cleanPreview(text, max = 240) {
71
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
72
+ return value.length > max ? value.slice(0, max).replace(/\s+\S*$/, '').trim() : value;
73
+ }
74
+
75
+ function _isPreviewNoise(text) {
76
+ const value = String(text || '').trim();
77
+ if (!value) return true;
78
+ if (value.startsWith('<system-reminder>')) return true;
79
+ if (/^Reference files:/i.test(value)) return true;
80
+ if (value.includes('<!-- bp3-sentinel -->')) return true;
81
+ return false;
82
+ }
83
+
84
+ function _sessionPreview(session) {
85
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
86
+ let first = '';
87
+ for (const m of messages) {
88
+ if (m?.role !== 'user') continue;
89
+ const text = _cleanPreview(_messageText(m.content));
90
+ if (_isPreviewNoise(text)) continue;
91
+ if (!first) first = text;
92
+ }
93
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
94
+ const m = messages[i];
95
+ if (m?.role !== 'user') continue;
96
+ const text = _cleanPreview(_messageText(m.content));
97
+ if (_isPreviewNoise(text)) continue;
98
+ return text || first;
99
+ }
100
+ return first;
101
+ }
102
+
103
+ function _sessionMessageCount(session) {
104
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
105
+ let count = 0;
106
+ for (const m of messages) {
107
+ if (m && (m.role === 'user' || m.role === 'assistant')) count++;
108
+ }
109
+ return count;
110
+ }
111
+
112
+ function _positiveNumber(value, fallback = 0) {
113
+ const n = Number(value);
114
+ return Number.isFinite(n) && n > 0 ? n : fallback;
115
+ }
116
+
117
+ function _sessionSummary(session) {
118
+ if (!session?.id) return null;
119
+ return {
120
+ id: String(session.id),
121
+ updatedAt: _positiveNumber(session.updatedAt, Date.now()),
122
+ createdAt: _positiveNumber(session.createdAt, 0),
123
+ lastHeartbeatAt: _positiveNumber(session.lastHeartbeatAt, 0),
124
+ closed: session.closed === true,
125
+ status: String(session.status || (session.closed === true ? 'closed' : 'idle')),
126
+ owner: session.owner || 'user',
127
+ role: session.role || null,
128
+ sourceType: session.sourceType || null,
129
+ sourceName: session.sourceName || null,
130
+ scopeKey: session.scopeKey || null,
131
+ ownerSessionId: session.ownerSessionId || null,
132
+ clientHostPid: _positiveNumber(session.clientHostPid, 0) || null,
133
+ cwd: session.cwd || '',
134
+ provider: session.provider || null,
135
+ model: session.model || null,
136
+ bridgeTag: session.bridgeTag || null,
137
+ task_id: session.task_id || session.taskId || null,
138
+ permission: session.permission || null,
139
+ toolPermission: session.toolPermission || null,
140
+ messageCount: _sessionMessageCount(session),
141
+ preview: _sessionPreview(session),
142
+ generation: typeof session.generation === 'number' ? session.generation : 0,
143
+ implicitBashSessionId: session.implicitBashSessionId || null,
144
+ };
145
+ }
146
+
147
+ function _normalizeSummaryRow(row) {
148
+ if (!row?.id || typeof row.id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(row.id)) return null;
149
+ return {
150
+ id: row.id,
151
+ updatedAt: _positiveNumber(row.updatedAt, 0),
152
+ createdAt: _positiveNumber(row.createdAt, 0),
153
+ lastHeartbeatAt: _positiveNumber(row.lastHeartbeatAt, 0),
154
+ closed: row.closed === true,
155
+ status: String(row.status || (row.closed === true ? 'closed' : 'idle')),
156
+ owner: row.owner || 'user',
157
+ role: row.role || null,
158
+ sourceType: row.sourceType || null,
159
+ sourceName: row.sourceName || null,
160
+ scopeKey: row.scopeKey || null,
161
+ ownerSessionId: row.ownerSessionId || null,
162
+ clientHostPid: _positiveNumber(row.clientHostPid, 0) || null,
163
+ cwd: row.cwd || '',
164
+ provider: row.provider || null,
165
+ model: row.model || null,
166
+ bridgeTag: row.bridgeTag || null,
167
+ task_id: row.task_id || null,
168
+ permission: row.permission || null,
169
+ toolPermission: row.toolPermission || null,
170
+ messageCount: Math.max(0, Math.floor(Number(row.messageCount) || 0)),
171
+ preview: _cleanPreview(row.preview || ''),
172
+ generation: typeof row.generation === 'number' ? row.generation : 0,
173
+ implicitBashSessionId: row.implicitBashSessionId || null,
174
+ };
175
+ }
176
+
177
+ function _normalizeSummaryIndex(raw) {
178
+ const rows = Array.isArray(raw?.rows) ? raw.rows.map(_normalizeSummaryRow).filter(Boolean) : [];
179
+ rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
180
+ return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: _positiveNumber(raw?.updatedAt, 0), rows };
181
+ }
182
+
183
+ function _writeSummaryIndex(rows) {
184
+ const cleanRows = (rows || []).map(_normalizeSummaryRow).filter(Boolean)
185
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
186
+ writeJsonAtomicSync(summaryIndexPath(), {
187
+ version: SESSION_SUMMARY_INDEX_VERSION,
188
+ updatedAt: Date.now(),
189
+ rows: cleanRows,
190
+ }, { compact: true, lock: true });
191
+ return cleanRows;
192
+ }
193
+
194
+ function _upsertSessionSummary(session) {
195
+ const row = _sessionSummary(session);
196
+ if (!row) return;
197
+ try {
198
+ updateJsonAtomicSync(summaryIndexPath(), (cur) => {
199
+ const index = _normalizeSummaryIndex(cur);
200
+ const rows = index.rows.filter((r) => r.id !== row.id);
201
+ const cleanRow = _normalizeSummaryRow(row);
202
+ if (!cleanRow) return undefined;
203
+ const existing = index.rows.find((r) => r.id === row.id) || null;
204
+ if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) return undefined;
205
+ rows.push(cleanRow);
206
+ rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
207
+ return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
208
+ }, { compact: true, lock: true });
209
+ } catch { /* summary index is best-effort */ }
210
+ }
211
+
212
+ function _removeSessionSummary(id) {
213
+ if (!id) return;
214
+ try {
215
+ updateJsonAtomicSync(summaryIndexPath(), (cur) => {
216
+ const index = _normalizeSummaryIndex(cur);
217
+ const rows = index.rows.filter((r) => r.id !== id);
218
+ if (rows.length === index.rows.length) return undefined;
219
+ return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
220
+ }, { compact: true, lock: true });
221
+ } catch { /* summary index is best-effort */ }
222
+ }
223
+
47
224
  /** Module-level map tracking in-flight saves per session ID to prevent concurrent write corruption. */
48
225
  const _savePending = new Map();
49
226
 
@@ -297,6 +474,7 @@ function _doSaveSync(payload) {
297
474
  return;
298
475
  }
299
476
  _renameWithRetrySync(tmp, target);
477
+ _upsertSessionSummary(session);
300
478
  } catch (err) {
301
479
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
302
480
  throw err;
@@ -390,6 +568,7 @@ async function _doSave(payload) {
390
568
  return;
391
569
  }
392
570
  _renameWithRetrySync(tmp, target);
571
+ _upsertSessionSummary(session);
393
572
  } catch (err) {
394
573
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
395
574
  _savePending.delete(id);
@@ -426,6 +605,7 @@ export function markSessionClosed(id, reason = 'manual') {
426
605
  _savePending.delete(id);
427
606
  _clearLiveSession(id);
428
607
  _deleteHeartbeat(id);
608
+ _upsertSessionSummary(tombstone);
429
609
  // Structured close metric. Single emission point because every close
430
610
  // path funnels through markSessionClosed. lifeMs = updatedAt-createdAt
431
611
  // straddles the tombstone (updatedAt was just set to Date.now()), so
@@ -488,6 +668,7 @@ export function deleteSession(id) {
488
668
  catch { /* fall through to .hb cleanup */ }
489
669
  }
490
670
  _deleteHeartbeat(id);
671
+ if (removed) _removeSessionSummary(id);
491
672
  return removed;
492
673
  }
493
674
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
@@ -518,6 +699,28 @@ export function listStoredSessions() {
518
699
  return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
519
700
  }
520
701
 
702
+ export function rebuildSessionSummaryIndex() {
703
+ const rows = listStoredSessions().map(_sessionSummary).filter(Boolean);
704
+ return _writeSummaryIndex(rows);
705
+ }
706
+
707
+ export function listStoredSessionSummaries(options = {}) {
708
+ const rebuildIfMissing = options.rebuildIfMissing !== false;
709
+ const p = summaryIndexPath();
710
+ if (!existsSync(p)) {
711
+ return rebuildIfMissing ? rebuildSessionSummaryIndex() : [];
712
+ }
713
+ try {
714
+ const index = _normalizeSummaryIndex(JSON.parse(readFileSync(p, 'utf-8')));
715
+ if (index.rows.length > 0) return index.rows;
716
+ const dir = getStoreDir();
717
+ const hasSessionFiles = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith('.json'));
718
+ return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : index.rows;
719
+ } catch {
720
+ return rebuildIfMissing ? rebuildSessionSummaryIndex() : [];
721
+ }
722
+ }
723
+
521
724
  /**
522
725
  * Raw directory scan — returns every parseable session file without any
523
726
  * TTL-based inline deletion. Callers (e.g. sweepTombstones) need to own the
@@ -552,7 +755,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
552
755
  const dir = getStoreDir();
553
756
  if (!existsSync(dir))
554
757
  return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
555
- const files = readdirSync(dir).filter(f => f.endsWith('.json'));
758
+ const summaries = listStoredSessionSummaries();
556
759
  const now = Date.now();
557
760
  let cleaned = 0;
558
761
  let remaining = 0;
@@ -560,38 +763,42 @@ export function sweepStaleSessions(ttlMs, options = {}) {
560
763
  const details = [];
561
764
  const tombstoneDetails = [];
562
765
  const tombstoneErrors = [];
563
- for (const f of files) {
766
+ for (const row of summaries) {
564
767
  try {
565
- const jsonPath = join(dir, f);
768
+ if (!row?.id) continue;
769
+ const jsonPath = sessionPath(row.id);
770
+ if (!existsSync(jsonPath)) {
771
+ _removeSessionSummary(row.id);
772
+ continue;
773
+ }
566
774
  let jsonMtime = 0;
567
775
  let heartbeatMtime = 0;
568
776
  try { jsonMtime = statSync(jsonPath).mtimeMs || 0; } catch {}
569
777
  try {
570
- const hbPath = join(dir, f.replace(/\.json$/, '.hb'));
778
+ const hbPath = join(dir, `${row.id}.hb`);
571
779
  if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
572
780
  } catch { /* .hb unavailable — fall back to JSON fields */ }
573
781
  const freshnessGateMs = sweepIdle ? maxAge : (sweepTombstones ? tombstoneMaxAgeMs : 0);
574
- const newestMtime = Math.max(jsonMtime, heartbeatMtime);
575
- if (freshnessGateMs > 0 && newestMtime > 0 && now - newestMtime <= freshnessGateMs) {
782
+ const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
783
+ if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
576
784
  remaining++;
577
785
  continue;
578
786
  }
579
- const session = JSON.parse(readFileSync(jsonPath, 'utf-8'));
580
787
  // Already-closed tombstones are handled here before heartbeat
581
788
  // sidecar checks; they do not need liveness probing.
582
- if (session.closed === true || session.status === 'closed') {
789
+ if (row.closed === true || row.status === 'closed') {
583
790
  if (sweepTombstones) {
584
- const updated = Number(session.updatedAt);
791
+ const updated = Number(row.updatedAt);
585
792
  const age = now - updated;
586
793
  if (Number.isFinite(updated) && age >= tombstoneMaxAgeMs) {
587
794
  try {
588
- if (deleteSession(session.id)) {
795
+ if (deleteSession(row.id)) {
589
796
  tombstonesCleaned++;
590
- tombstoneDetails.push({ id: session.id, ageSeconds: Math.floor(age / 1000) });
797
+ tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
591
798
  continue;
592
799
  }
593
800
  } catch (err) {
594
- tombstoneErrors.push({ id: session.id, message: err?.message || String(err) });
801
+ tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
595
802
  remaining++;
596
803
  continue;
597
804
  }
@@ -602,7 +809,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
602
809
  }
603
810
  // Sweep bridge-owned and ownerless (legacy) sessions; skip explicit
604
811
  // user sessions before touching heartbeat sidecars.
605
- if (typeof session.owner === 'string' && session.owner.length > 0 && session.owner !== 'bridge') {
812
+ if (typeof row.owner === 'string' && row.owner.length > 0 && row.owner !== 'bridge') {
606
813
  remaining++;
607
814
  continue;
608
815
  }
@@ -613,30 +820,30 @@ export function sweepStaleSessions(ttlMs, options = {}) {
613
820
  // Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
614
821
  // serialising the full JSON, so it reflects true liveness more
615
822
  // accurately than the JSON timestamp fields.
616
- let lastActive = session.lastHeartbeatAt || session.updatedAt || session.createdAt || 0;
823
+ let lastActive = row.lastHeartbeatAt || row.updatedAt || row.createdAt || 0;
617
824
  if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
618
825
  // Running sessions are normally reaped by the stream-watchdog
619
826
  // within ~120s. Skip them here unless they've been silent past
620
827
  // RUNNING_STALL_MS, at which point they are treated as zombies.
621
- if (session.status === 'running' && now - lastActive <= RUNNING_STALL_MS) {
828
+ if (row.status === 'running' && now - lastActive <= RUNNING_STALL_MS) {
622
829
  remaining++;
623
830
  continue;
624
831
  }
625
- const isCompletedBridge = session.owner === 'bridge'
626
- && BRIDGE_TERMINAL_STATUSES.has(session.status);
832
+ const isCompletedBridge = row.owner === 'bridge'
833
+ && BRIDGE_TERMINAL_STATUSES.has(row.status);
627
834
  const sessionMaxAge = isCompletedBridge ? BRIDGE_TERMINAL_TTL_MS : maxAge;
628
835
  if (now - lastActive > sessionMaxAge) {
629
- try { markSessionClosed(session.id, 'idle-sweep'); }
836
+ try { markSessionClosed(row.id, 'idle-sweep'); }
630
837
  catch (err) {
631
- process.stderr.write(`[session-store] idle-sweep close failed for ${session.id}: ${err?.message}\n`);
838
+ process.stderr.write(`[session-store] idle-sweep close failed for ${row.id}: ${err?.message}\n`);
632
839
  continue;
633
840
  }
634
841
  cleaned++;
635
842
  details.push({
636
- id: session.id,
637
- owner: session.owner || 'unknown',
843
+ id: row.id,
844
+ owner: row.owner || 'unknown',
638
845
  idleMinutes: Math.round((now - lastActive) / 60000),
639
- bashSessionId: session.implicitBashSessionId || null,
846
+ bashSessionId: row.implicitBashSessionId || null,
640
847
  });
641
848
  } else {
642
849
  remaining++;
@@ -1,4 +1,4 @@
1
- // Cross-process advisory lock for write/edit operations. Uses a sibling
1
+ // Cross-process advisory lock for patch/write operations. Uses a sibling
2
2
  // lockfile written exclusively (`wx`) and a process.kill(pid, 0) liveness
3
3
  // check to clean up after crashed holders. Pair with the in-process
4
4
  // withPathLock for the same target: in-process serialises async callers
@@ -3,7 +3,7 @@ import { join } from 'path';
3
3
  import { writeJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
4
4
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
5
5
 
6
- // Mixdog read/write/edit share a scoped snapshot store. Value stores the
6
+ // Mixdog read/patch paths share a scoped snapshot store. Value stores the
7
7
  // mtime + size at read-time. A missing scope is fail-closed: snapshots without
8
8
  // a real scope id are NOT recorded under a shared default bucket (would let
9
9
  // worker A's read satisfy worker B's edit-gate across sessions via a persisted
@@ -80,8 +80,6 @@ function buildToolLine(name, input, hiddenCheck = isHidden) {
80
80
  detail = (input?.command || input?.cmd || desc || "").substring(0, 500);
81
81
  break;
82
82
  }
83
- case "edit":
84
- case "write":
85
83
  case "apply_patch":
86
84
  detail = input?.file_path || "";
87
85
  break;