mixdog 0.9.16 → 0.9.18

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 (43) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/provider-toolcall-test.mjs +79 -2
  4. package/src/mixdog-session-runtime.mjs +12 -0
  5. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  6. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
  7. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  8. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
  9. package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
  10. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
  11. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
  12. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
  14. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  15. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  16. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
  18. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  19. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  20. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  21. package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
  22. package/src/runtime/channels/backends/discord.mjs +97 -7
  23. package/src/runtime/channels/index.mjs +150 -23
  24. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  25. package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
  26. package/src/runtime/channels/lib/runtime-paths.mjs +21 -19
  27. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  28. package/src/runtime/memory/index.mjs +37 -0
  29. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  30. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  31. package/src/runtime/shared/atomic-file.mjs +110 -0
  32. package/src/runtime/shared/transcript-writer.mjs +46 -4
  33. package/src/session-runtime/provider-models.mjs +47 -8
  34. package/src/standalone/channel-worker.mjs +14 -2
  35. package/src/tui/app/transcript-window.mjs +137 -6
  36. package/src/tui/app/use-transcript-window.mjs +67 -10
  37. package/src/tui/components/StatusLine.jsx +1 -1
  38. package/src/tui/dist/index.mjs +436 -93
  39. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  40. package/src/tui/engine.mjs +66 -14
  41. package/src/tui/index.jsx +97 -6
  42. package/src/ui/statusline-segments.mjs +54 -36
  43. package/src/ui/statusline.mjs +141 -95
@@ -6,12 +6,131 @@
6
6
  // _runtimeState map (owned by manager.mjs) to read the in-memory session and
7
7
  // flag usageMetricsTurnIncremental. manager.mjs wires it via configureUsageMetricsRuntime().
8
8
  import { providerInputExcludesCache } from '../../providers/registry.mjs';
9
- import { loadSession, saveSessionAsync } from '../store.mjs';
9
+ import { loadSession, saveSessionAsync, _saveSessionSync } from '../store.mjs';
10
10
 
11
11
  // Per-session idempotency tracking: sessionId → Set of seen
12
12
  // turn:epoch:iteration:source keys.
13
13
  const _metricSeenIter = new Map();
14
14
 
15
+ // ── Mid-turn save coalescing ──────────────────────────────────────────────
16
+ // saveSessionAsync structured-clones the ENTIRE session on the main thread
17
+ // per postMessage call (store.mjs saveSessionAsync). Calling it after EVERY
18
+ // provider.send iteration is the cost this coalesces away: in-memory session
19
+ // mutation above still happens every iteration (durability of the data is
20
+ // unaffected — only how often it hits saveSessionAsync's postMessage clone).
21
+ // sessionId → { inFlight, dirty, lastFlushAt, timer }
22
+ const _metricSaveState = new Map();
23
+ // sessionId → live session ref awaiting a still-deferred (timer-pending)
24
+ // flush. Only populated for the window between "delta applied, save not yet
25
+ // posted to the worker" and the actual saveSessionAsync call — once a save
26
+ // is posted, store.mjs's own _saveWorkerPending covers process-exit drain.
27
+ const _pendingMetricsFlush = new Map();
28
+ const METRICS_SAVE_THROTTLE_MS = 500;
29
+
30
+ function _metricsSaveState(sessionId) {
31
+ let state = _metricSaveState.get(sessionId);
32
+ if (!state) {
33
+ state = { inFlight: false, dirty: false, lastFlushAt: 0, timer: null, closed: false, failCount: 0 };
34
+ _metricSaveState.set(sessionId, state);
35
+ }
36
+ return state;
37
+ }
38
+
39
+ function _flushMetricsSave(session, sessionId) {
40
+ const state = _metricsSaveState(sessionId);
41
+ if (state.closed) return;
42
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
43
+ _pendingMetricsFlush.delete(sessionId);
44
+ state.dirty = false;
45
+ state.inFlight = true;
46
+ state.lastFlushAt = Date.now();
47
+ saveSessionAsync(session, { expectedGeneration: session.generation })
48
+ .then(() => {
49
+ state.failCount = 0;
50
+ })
51
+ .catch((err) => {
52
+ process.stderr.write(`[usage-metrics] iteration save failed: ${err?.message ?? err}\n`);
53
+ // A rejected save must not silently drop the delta already
54
+ // applied in-memory — re-park it so the process-exit drain
55
+ // and/or the retry below still see it.
56
+ state.failCount += 1;
57
+ state.dirty = true;
58
+ _pendingMetricsFlush.set(sessionId, session);
59
+ })
60
+ .finally(() => {
61
+ state.inFlight = false;
62
+ if (state.closed) {
63
+ // dropMetricSeenState ran while this save was in flight —
64
+ // do not resurrect per-session state/timers post-close.
65
+ _metricSaveState.delete(sessionId);
66
+ _pendingMetricsFlush.delete(sessionId);
67
+ return;
68
+ }
69
+ // A newer delta (or the rejection re-park above) landed while
70
+ // this save was in flight — flush once more so it is never
71
+ // stranded. Always take the LATEST parked session, not the
72
+ // (possibly stale/pre-detach-resume) closure `session` — a
73
+ // newer delta may have parked a fresher session/generation.
74
+ // failCount cap: a persistently failing save (broken dir/disk)
75
+ // must not spin reject→re-park→retry forever with no backoff.
76
+ // The session stays parked in _pendingMetricsFlush, so a later
77
+ // _scheduleMetricsSave (next iteration) or the process-exit
78
+ // drain still gets a shot at persisting it.
79
+ if (state.dirty && state.failCount <= 3) {
80
+ const latest = _pendingMetricsFlush.get(sessionId) ?? session;
81
+ _flushMetricsSave(latest, sessionId);
82
+ }
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Coalesce mid-turn metric saves: an in-flight guard collapses overlapping
88
+ * saves into one trailing flush, and a >=500ms per-session throttle caps
89
+ * how often a completed save re-triggers another. The idempotency Set above
90
+ * still updates per-iteration regardless of whether this call ends up
91
+ * actually posting to the worker this tick.
92
+ */
93
+ function _scheduleMetricsSave(session, sessionId) {
94
+ const state = _metricsSaveState(sessionId);
95
+ if (state.closed) return;
96
+ if (state.inFlight) {
97
+ state.dirty = true;
98
+ _pendingMetricsFlush.set(sessionId, session);
99
+ return;
100
+ }
101
+ const elapsed = Date.now() - state.lastFlushAt;
102
+ if (elapsed >= METRICS_SAVE_THROTTLE_MS) {
103
+ _flushMetricsSave(session, sessionId);
104
+ return;
105
+ }
106
+ state.dirty = true;
107
+ _pendingMetricsFlush.set(sessionId, session);
108
+ if (!state.timer) {
109
+ const t = setTimeout(() => {
110
+ state.timer = null;
111
+ if (!state.inFlight && !state.closed) {
112
+ const latest = _pendingMetricsFlush.get(sessionId) ?? session;
113
+ _flushMetricsSave(latest, sessionId);
114
+ }
115
+ }, METRICS_SAVE_THROTTLE_MS - elapsed);
116
+ if (t.unref) t.unref();
117
+ state.timer = t;
118
+ }
119
+ }
120
+
121
+ // Process exit can land inside the throttle window, before the trailing
122
+ // timer above ever calls saveSessionAsync (which would otherwise register
123
+ // with store.mjs's own drainSessionStore-covered worker queue). Sync-flush
124
+ // any such still-deferred session here so a normal-completion turn that
125
+ // happens to exit mid-throttle never loses the latest iteration delta.
126
+ process.on('exit', () => {
127
+ for (const [sessionId, session] of _pendingMetricsFlush) {
128
+ try { _saveSessionSync(session, { expectedGeneration: session.generation }); }
129
+ catch { /* best-effort: process is exiting */ }
130
+ }
131
+ _pendingMetricsFlush.clear();
132
+ });
133
+
15
134
  // Injected accessor for manager's _runtimeState. Defaults to a no-op lookup so
16
135
  // the pure helpers remain usable (and unit-testable) before wiring.
17
136
  let _getRuntimeEntry = () => null;
@@ -23,7 +142,20 @@ export function configureUsageMetricsRuntime({ getRuntimeEntry } = {}) {
23
142
 
24
143
  /** Drop the per-session metric-idempotency Set (called on session close). */
25
144
  export function dropMetricSeenState(sessionId) {
26
- if (sessionId) _metricSeenIter.delete(sessionId);
145
+ if (!sessionId) return;
146
+ _metricSeenIter.delete(sessionId);
147
+ const state = _metricSaveState.get(sessionId);
148
+ if (state) {
149
+ state.closed = true;
150
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
151
+ // Only delete the map entry when nothing is in flight — an in-flight
152
+ // save's own .finally (guarded by state.closed above) performs the
153
+ // cleanup itself once it settles, so a stray concurrent call cannot
154
+ // recreate a fresh (non-closed) entry via _metricsSaveState() while
155
+ // that write is still outstanding.
156
+ if (!state.inFlight) _metricSaveState.delete(sessionId);
157
+ }
158
+ _pendingMetricsFlush.delete(sessionId);
27
159
  }
28
160
 
29
161
  /** Monotonic per-session ask/turn id for incremental usage idempotency. */
@@ -206,5 +338,12 @@ export async function persistIterationMetrics(delta) {
206
338
  }
207
339
  session.lastIterationIndex = iterationIndex;
208
340
  session.updatedAt = ts || Date.now();
209
- await saveSessionAsync(session, { expectedGeneration: session.generation });
341
+ // Coalesced mid-turn persistence (see _scheduleMetricsSave above): an
342
+ // in-flight guard + >=500ms per-session throttle collapse the per-
343
+ // iteration saveSessionAsync postMessage/clone cost. The idempotency Set
344
+ // update above already happened unconditionally this call, so a
345
+ // throttled/skipped flush here never loses delta accounting — only the
346
+ // disk-write cadence changes. Terminal save at turn end (askSession) and
347
+ // the exit drain above both still cover the last pending delta.
348
+ _scheduleMetricsSave(session, sessionId);
210
349
  }
@@ -8,7 +8,7 @@
8
8
  import { mkdirSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { getPluginData } from '../config.mjs';
11
- import { updateJsonAtomicSync, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
11
+ import { updateJsonAtomicSync, updateJsonAtomic, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
12
12
 
13
13
  export const SESSION_SUMMARY_INDEX_VERSION = 1;
14
14
 
@@ -158,34 +158,115 @@ export function _writeSummaryIndex(rows) {
158
158
  return cleanRows;
159
159
  }
160
160
 
161
- export function _upsertSessionSummary(session) {
162
- const row = _sessionSummary(session);
163
- if (!row) return;
164
- try {
165
- updateJsonAtomicSync(summaryIndexPath(), (cur) => {
161
+ // ── Non-blocking upsert/remove ──────────────────────────────────────────────
162
+ // The summary index is a best-effort sidecar, but its writers used to take
163
+ // the cross-process file lock with the DEFAULT 8s timeout — and
164
+ // withFileLockSync waits with Atomics.wait, which BLOCKS the main thread.
165
+ // Every session save calls _upsertSessionSummary, so with several mixdog
166
+ // processes (TUI + agent workers + memory service) contending on a multi-MB
167
+ // index, a busy lock froze typing/rendering for seconds at a time.
168
+ // Now: callers only QUEUE the mutation (O(1), no I/O) and the flush runs on
169
+ // a deferred tick with a ZERO-WAIT try-lock — if another process holds the
170
+ // lock we never sleep on it, we re-queue and retry on an unref'd timer. The
171
+ // caller's thread never blocks on this lock at all.
172
+ const SUMMARY_LOCK_TIMEOUT_MS = 0; // try-lock: acquire-or-fail, never wait
173
+ const SUMMARY_RETRY_DELAY_MS = 1000;
174
+ const _pendingUpserts = new Map(); // id → summary row (latest wins)
175
+ const _pendingRemovals = new Set(); // ids to drop (upsert/removal are mutually exclusive per id)
176
+ let _summaryRetryTimer = null;
177
+ let _summaryFlushScheduled = false;
178
+
179
+ function _scheduleSummaryRetry() {
180
+ if (_summaryRetryTimer) return;
181
+ _summaryRetryTimer = setTimeout(() => {
182
+ _summaryRetryTimer = null;
183
+ _flushPendingSummaryOps();
184
+ }, SUMMARY_RETRY_DELAY_MS);
185
+ _summaryRetryTimer.unref?.();
186
+ }
187
+
188
+ // Defer the flush off the caller's stack: the 1MB+ parse/stringify inside
189
+ // updateJsonAtomicSync (~10ms on a warm cache) has no business on the
190
+ // keystroke/session-save path.
191
+ function _scheduleSummaryFlush() {
192
+ if (_summaryFlushScheduled) return;
193
+ _summaryFlushScheduled = true;
194
+ setImmediate(() => {
195
+ _summaryFlushScheduled = false;
196
+ _flushPendingSummaryOps();
197
+ });
198
+ }
199
+
200
+ export function _flushPendingSummaryOps({ sync = false } = {}) {
201
+ if (_pendingUpserts.size === 0 && _pendingRemovals.size === 0) return;
202
+ // Snapshot + clear first: ops queued by other callers DURING the flush
203
+ // must not be lost, and a failed flush re-queues only what it took
204
+ // (without clobbering anything newer queued meanwhile).
205
+ const upserts = new Map(_pendingUpserts);
206
+ const removals = new Set(_pendingRemovals);
207
+ _pendingUpserts.clear();
208
+ _pendingRemovals.clear();
209
+ const mutate = (cur) => {
166
210
  const index = _normalizeSummaryIndex(cur);
167
- const rows = index.rows.filter((r) => r.id !== row.id);
168
- const cleanRow = _normalizeSummaryRow(row);
169
- if (!cleanRow) return undefined;
170
- const existing = index.rows.find((r) => r.id === row.id) || null;
171
- if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) return undefined;
172
- rows.push(cleanRow);
211
+ let changed = false;
212
+ const rows = index.rows.filter((r) => {
213
+ if (removals.has(r.id) || upserts.has(r.id)) { changed = true; return false; }
214
+ return true;
215
+ });
216
+ for (const row of upserts.values()) {
217
+ const cleanRow = _normalizeSummaryRow(row);
218
+ if (!cleanRow) continue;
219
+ const existing = index.rows.find((r) => r.id === cleanRow.id) || null;
220
+ if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) {
221
+ rows.push(existing);
222
+ continue;
223
+ }
224
+ rows.push(cleanRow);
225
+ changed = true;
226
+ }
227
+ if (!changed) return undefined;
173
228
  rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
174
229
  return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
175
- }, { compact: true, lock: true });
176
- } catch { /* summary index is best-effort */ }
230
+ };
231
+ const requeue = () => {
232
+ // Lock busy (or transient I/O failure) — re-queue what we took unless
233
+ // a newer op for the same id arrived during the flush, then retry
234
+ // asynchronously.
235
+ for (const [id, row] of upserts) {
236
+ if (!_pendingUpserts.has(id) && !_pendingRemovals.has(id)) _pendingUpserts.set(id, row);
237
+ }
238
+ for (const id of removals) {
239
+ if (!_pendingUpserts.has(id)) _pendingRemovals.add(id);
240
+ }
241
+ _scheduleSummaryRetry();
242
+ };
243
+ // Exit-drain path needs the synchronous write (the loop may not pump
244
+ // after 'exit'). Normal scheduled flushes on the lead/TUI main process
245
+ // use the async try-lock: the lock WAIT is off the event loop, so a
246
+ // busy multi-MB index never freezes typing/rendering.
247
+ if (sync) {
248
+ try {
249
+ updateJsonAtomicSync(summaryIndexPath(), mutate, { compact: true, lock: true, timeoutMs: SUMMARY_LOCK_TIMEOUT_MS });
250
+ } catch { requeue(); }
251
+ return;
252
+ }
253
+ updateJsonAtomic(summaryIndexPath(), mutate, { compact: true, lock: true, timeoutMs: SUMMARY_LOCK_TIMEOUT_MS })
254
+ .catch(() => { requeue(); });
255
+ }
256
+
257
+ export function _upsertSessionSummary(session) {
258
+ const row = _sessionSummary(session);
259
+ if (!row) return;
260
+ _pendingRemovals.delete(row.id);
261
+ _pendingUpserts.set(row.id, row);
262
+ _scheduleSummaryFlush();
177
263
  }
178
264
 
179
265
  export function _removeSessionSummary(id) {
180
266
  if (!id) return;
181
- try {
182
- updateJsonAtomicSync(summaryIndexPath(), (cur) => {
183
- const index = _normalizeSummaryIndex(cur);
184
- const rows = index.rows.filter((r) => r.id !== id);
185
- if (rows.length === index.rows.length) return undefined;
186
- return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
187
- }, { compact: true, lock: true });
188
- } catch { /* summary index is best-effort */ }
267
+ _pendingUpserts.delete(id);
268
+ _pendingRemovals.add(id);
269
+ _scheduleSummaryFlush();
189
270
  }
190
271
 
191
272
  /**
@@ -196,12 +277,9 @@ export function _removeSessionSummary(id) {
196
277
  */
197
278
  export function _pruneSummaryIndexIds(ids) {
198
279
  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 */ }
280
+ for (const id of ids) {
281
+ _pendingUpserts.delete(id);
282
+ _pendingRemovals.add(id);
283
+ }
284
+ _scheduleSummaryFlush();
207
285
  }
@@ -21,6 +21,7 @@ import {
21
21
  _upsertSessionSummary,
22
22
  _removeSessionSummary,
23
23
  _pruneSummaryIndexIds,
24
+ _flushPendingSummaryOps,
24
25
  } from './store-summary-index.mjs';
25
26
  // Facade re-export: summary-index API moved to store-summary-index.mjs; keep
26
27
  // prior importers of store.mjs unchanged.
@@ -404,6 +405,10 @@ export function drainSessionStore() {
404
405
  }
405
406
  }
406
407
  _savePending.clear();
408
+ // Summary-index ops queued by the deferred/no-wait flush path: give them
409
+ // one last best-effort flush before exit (still zero-wait; losing them is
410
+ // acceptable — the index self-heals on next rebuild).
411
+ try { _flushPendingSummaryOps({ sync: true }); } catch { /* best-effort */ }
407
412
  // Outstanding worker-queue writes: process exit may interrupt the worker
408
413
  // thread before it processes its message queue, so each pending payload
409
414
  // is sync-flushed directly here. The Promise is then rejected so the
@@ -68,13 +68,17 @@ export const DEFAULT_ACTIVITY_HEARTBEAT_MS = resolveTimeoutMs(
68
68
 
69
69
  export const PROVIDER_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
70
70
  'MIXDOG_PROVIDER_FIRST_BYTE_TIMEOUT_MS',
71
- // 2026-07 trace audit: heavy-worker claude-sonnet-5 xhigh TTFT distribution
72
- // showed avg ~25s, max ~120s (15/75 requests over 60s) — the old 120s
73
- // window sat exactly on the observed tail and killed/retried healthy slow
74
- // first bytes (9 first-byte retries in one 75-iteration session, plus one
75
- // full turn lost). 180s clears the tail while the mid-stream retry ladder
76
- // and the agent stall watchdog still bound a truly dead socket.
77
- Math.min(180_000, PROVIDER_MAX_BEFORE_WARN_MS),
71
+ // 2026-07-05 trace audit (24h): worker claude-sonnet-5 successful TTFT
72
+ // p90 43s / p99 164s / max 171s — the distribution hugs the previous 180s
73
+ // window, and 16/178 fetches "died" in repeated back-to-back 183s
74
+ // fetch→fetch cycles (3+ consecutive in the worst session). That pattern
75
+ // is queued-but-alive first bytes being axed AT the window and re-queued
76
+ // from scratch, not dead sockets: each trip doubled the wait (one worker
77
+ // session burned ~40min purely on 180s-abort→retry loops). Raise to the
78
+ // policy ceiling (STALL_WARN - tick, ~285s): models with fast first bytes
79
+ // never reach the timer, and a truly wedged socket is still bounded by
80
+ // the agent stall first-byte abort (300s, DEFAULT_STALL_FIRST_BYTE_ABORT_S).
81
+ PROVIDER_MAX_BEFORE_WARN_MS,
78
82
  { minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
79
83
  );
80
84
 
@@ -140,11 +144,17 @@ export const PROVIDER_SSE_IDLE_TIMEOUT_MS = resolveTimeoutMs(
140
144
  // env-overridable and disablable via MIXDOG_ENABLE_STREAM_WATCHDOG=0.
141
145
  export const PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS = resolveTimeoutMs(
142
146
  ['MIXDOG_PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS', 'MIXDOG_PROVIDER_SSE_IDLE_TIMEOUT_MS'],
143
- // Raised 120s→180s alongside PROVIDER_FIRST_BYTE_TIMEOUT_MS (see comment
144
- // there): observed healthy inter-event gaps on xhigh reasoning streams
145
- // brushed the old 120s window; stalls after a tool emit are unretryable,
146
- // so a false trip there costs the whole turn.
147
- 180_000,
147
+ // 2026-07-05 trace audit: effort-mode (output_config.effort) sonnet-5
148
+ // streams deliver NO deltas during the thinking phase — the whole
149
+ // thinking+text body flushes at the end (44/47 slow turns had
150
+ // stream_total-ttft < 2s; silent-window token rate a steady ~92 tok/s,
151
+ // i.e. live generation, not a wedge). Successful turns topped out at
152
+ // ttft 171s while 13 kills sat at exactly ~183s fetch→fetch — the old
153
+ // 180s window was beheading every turn whose silent thinking ran past
154
+ // it, then retrying the whole thinking run from zero. Raised to the
155
+ // policy ceiling (STALL_WARN - tick ≈ 285s); the agent stall watchdog
156
+ // (worker 300s) remains the true-wedge backstop just above it.
157
+ PROVIDER_MAX_BEFORE_WARN_MS,
148
158
  { minMs: 10_000, maxMs: STALL_WARN_MS },
149
159
  );
150
160
 
@@ -117,7 +117,9 @@ export const BUILTIN_TOOLS = [
117
117
  offset: { type: 'number', minimum: 0, description: 'Skip output lines for paging.' },
118
118
  '-A': { type: 'number', minimum: 0, description: 'Lines after each match.' },
119
119
  '-B': { type: 'number', minimum: 0, description: 'Lines before each match.' },
120
- '-C': { type: 'number', minimum: 0, description: 'Lines before/after each match.' },
120
+ // Description nudges -C 5 on content modes measured fix for
121
+ // grep→read same-file re-open round-trips (2026-07 diag).
122
+ '-C': { type: 'number', minimum: 0, description: 'Lines before/after each match. Content modes: pass -C 5+ so the hit is edit-ready; skipping it forces a follow-up read.' },
121
123
  },
122
124
  required: [],
123
125
  },
@@ -1,4 +1,4 @@
1
- import { closeSync, lstatSync, openSync, readFileSync, readSync, realpathSync, readdirSync, statSync } from 'fs';
1
+ import { lstatSync, realpathSync, readdirSync, statSync } from 'fs';
2
2
  import * as fsPromises from 'fs/promises';
3
3
  import { readFile } from 'fs/promises';
4
4
  import { extname } from 'path';
@@ -26,12 +26,12 @@ function withSymbolReadNote(text, args) {
26
26
  // decoding. utf8-with-BOM (EF BB BF) keeps the utf-8 decoder; its leading
27
27
  // U+FEFF is stripped for display downstream, so bomLen is reported but not
28
28
  // applied for utf8.
29
- function detectReadEncoding(fullPath) {
30
- let fd;
29
+ async function detectReadEncoding(fullPath) {
30
+ let fh;
31
31
  try {
32
- fd = openSync(fullPath, 'r');
32
+ fh = await fsPromises.open(fullPath, 'r');
33
33
  const head = Buffer.alloc(3);
34
- const n = readSync(fd, head, 0, 3, 0);
34
+ const { bytesRead: n } = await fh.read(head, 0, 3, 0);
35
35
  if (n >= 2 && head[0] === 0xff && head[1] === 0xfe) {
36
36
  return { encoding: 'utf16le', bomLen: 2 };
37
37
  }
@@ -45,7 +45,7 @@ function detectReadEncoding(fullPath) {
45
45
  } catch {
46
46
  return { encoding: 'utf8', bomLen: 0 };
47
47
  } finally {
48
- if (fd !== undefined) { try { closeSync(fd); } catch {} }
48
+ if (fh !== undefined) { try { await fh.close(); } catch {} }
49
49
  }
50
50
  }
51
51
 
@@ -260,17 +260,17 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
260
260
  // entry stores a contentPrefixHash, recompute the current prefix and bail
261
261
  // to a fresh read on mismatch. Helper kept local (not hoisted) so it can
262
262
  // close over fullPath and st without an extra arg.
263
- const _readPrefixHashForCacheGuard = () => {
263
+ const _readPrefixHashForCacheGuard = async () => {
264
264
  try {
265
265
  if (st.size <= 65536) {
266
- return _hashText(readFileSync(fullPath, 'utf-8'));
266
+ return _hashText(await readFile(fullPath, 'utf-8'));
267
267
  }
268
- const _fd = openSync(fullPath, 'r');
268
+ const _fh = await fsPromises.open(fullPath, 'r');
269
269
  try {
270
270
  const _buf = Buffer.allocUnsafe(65536);
271
- const _n = readSync(_fd, _buf, 0, 65536, 0);
271
+ const { bytesRead: _n } = await _fh.read(_buf, 0, 65536, 0);
272
272
  return _hashText(_buf.subarray(0, _n));
273
- } finally { try { closeSync(_fd); } catch {} }
273
+ } finally { try { await _fh.close(); } catch {} }
274
274
  } catch { return ''; }
275
275
  };
276
276
  const cachedEntry = _cacheGetEntry(cacheKey);
@@ -295,7 +295,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
295
295
  // exact full contentHash when present, else the prefix hash
296
296
  // (also full-body here). A read failure drops to fresh read.
297
297
  try {
298
- const _freshHash = _hashText(readFileSync(fullPath, 'utf-8'));
298
+ const _freshHash = _hashText(await readFile(fullPath, 'utf-8'));
299
299
  const _expect = _snapHash || _prefixHash;
300
300
  if (!_freshHash || _freshHash !== _expect) _entryStillValid = false;
301
301
  } catch { _entryStillValid = false; }
@@ -310,7 +310,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
310
310
  // writes through edit/apply_patch/write invalidate by path,
311
311
  // and shell mutationMode='global' wipes both builtin +
312
312
  // code-graph caches, bounding stale risk past the head.
313
- const _curHash = _readPrefixHashForCacheGuard();
313
+ const _curHash = await _readPrefixHashForCacheGuard();
314
314
  if (!_curHash || _curHash !== _prefixHash) _entryStillValid = false;
315
315
  }
316
316
  }
@@ -377,7 +377,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
377
377
  && typeof _snap.contentHash === 'string'
378
378
  && _snap.contentHash) {
379
379
  let _diskHash = '';
380
- try { _diskHash = _hashText(readFileSync(fullPath, 'utf-8')); } catch {}
380
+ try { _diskHash = _hashText(await readFile(fullPath, 'utf-8')); } catch {}
381
381
  if (_diskHash && _diskHash === _snap.contentHash) {
382
382
  if (options?.suppressReadUnchangedStub !== true) {
383
383
  return withSymbolReadNote(`[file unchanged: ${normalizeOutputPath(filePath)}]`, args);
@@ -389,7 +389,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
389
389
  // large UTF-16LE+BOM file is recognized as text up front and routed to
390
390
  // the bounded in-memory utf16le path below, never mis-decoded as utf-8
391
391
  // or rejected by the utf-8 streaming/binary branch (Bug 1).
392
- const _readEnc = detectReadEncoding(fullPath);
392
+ const _readEnc = await detectReadEncoding(fullPath);
393
393
  // UTF-16 (LE or BE) reads share one constraint: the streaming/binary
394
394
  // paths decode chunks as utf-8, so a BOM-flagged UTF-16 file must route
395
395
  // to the bounded in-memory decode below regardless of byte order.
@@ -126,7 +126,7 @@ function _reviveInfinitySentinels(value) {
126
126
  return value;
127
127
  }
128
128
 
129
- function persistScopeSync(scopeKey) {
129
+ function persistScopeSync(scopeKey, { exitDrain = false } = {}) {
130
130
  const path = snapshotScopeFilePath(scopeKey);
131
131
  if (!path) return;
132
132
  const readFiles = readFilesByScope.get(scopeKey);
@@ -141,6 +141,14 @@ function persistScopeSync(scopeKey) {
141
141
  writeJsonAtomicSync(path, _withInfinitySentinels(obj), {
142
142
  compact: true,
143
143
  lock: true,
144
+ // Try-once (timeoutMs:0): scheduleScopePersist fires this from the
145
+ // debounced timer on the lead/TUI main process after every read/grep
146
+ // tool. withFileLockSync's Atomics.wait would block the loop on
147
+ // cross-process contention; best-effort — a busy lock just drops
148
+ // this flush (the next read re-schedules, exit-drain still writes).
149
+ // Exit drain uses a short blocking wait instead so the final flush
150
+ // is not silently dropped under contention (loop no longer matters).
151
+ timeoutMs: exitDrain ? 1000 : 0,
144
152
  mode: 0o600,
145
153
  fsync: false,
146
154
  fsyncDir: false,
@@ -191,7 +199,7 @@ export function scheduleScopePersist(scopeKey) {
191
199
  function flushAllScopesSync() {
192
200
  for (const [key, timer] of persistPending) {
193
201
  try { clearTimeout(timer); } catch {}
194
- try { persistScopeSync(key); } catch {}
202
+ try { persistScopeSync(key, { exitDrain: true }); } catch {}
195
203
  }
196
204
  persistPending.clear();
197
205
  }
@@ -301,7 +301,13 @@ function _persistDiskCodeGraphCacheNow() {
301
301
  for (const [cwd, entry] of _diskCodeGraphCache) {
302
302
  const hash = _hashCwd(cwd);
303
303
  const file = join(dir, `${hash}.json`);
304
- writeJsonAtomicSync(file, entry, { compact: true, lock: true });
304
+ // Try-once (timeoutMs:0): the debounced flush runs on the lead/TUI main
305
+ // process (codeGraph tool + prewarm). withFileLockSync's Atomics.wait
306
+ // would freeze the renderer on cross-process cache contention. Cache is
307
+ // rebuildable — a busy lock just skips this flush; _scheduleDiskCodeGraphCacheFlush
308
+ // re-schedules on the next build. Exit drain still runs (unref'd timer
309
+ // cancelled + direct call).
310
+ writeJsonAtomicSync(file, entry, { compact: true, lock: true, timeoutMs: 0 });
305
311
  manifest[cwd] = { hash, builtAt: entry.builtAt || Date.now() };
306
312
  }
307
313
  const pruned = _pruneCodeGraphManifestForBudget(manifest, dir);
@@ -314,7 +320,7 @@ function _persistDiskCodeGraphCacheNow() {
314
320
  }
315
321
 
316
322
  const manifestFile = join(dir, 'manifest.json');
317
- writeJsonAtomicSync(manifestFile, manifest, { compact: true, lock: true });
323
+ writeJsonAtomicSync(manifestFile, manifest, { compact: true, lock: true, timeoutMs: 0 });
318
324
  _diskManifest = manifest;
319
325
 
320
326
  // Sweep orphan per-cwd files. validHashes now includes every hash in
@@ -5,13 +5,13 @@ import { join, sep } from "path";
5
5
 
6
6
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
7
7
 
8
- async function downloadSingleAttachment(att, inboxDir) {
8
+ async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } = {}) {
9
9
  if (att.size > MAX_ATTACHMENT_BYTES) {
10
10
  throw new Error(
11
11
  `attachment too large: ${(att.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
12
12
  );
13
13
  }
14
- const res = await fetch(att.url, { signal: AbortSignal.timeout(180_000) });
14
+ const res = await fetch(att.url, { signal: AbortSignal.timeout(timeoutMs) });
15
15
  if (!res.ok) {
16
16
  throw new Error(`attachment download failed: HTTP ${res.status}`);
17
17
  }
@@ -212,13 +212,23 @@ async function awaitLogin(self) {
212
212
  resolve();
213
213
  });
214
214
  });
215
+ // Observe the ready timer from the start: login() can hang past the 30s
216
+ // timer (gateway reconnect), and a rejection on an un-awaited readyPromise
217
+ // becomes a process-global unhandledRejection → fatal crash path
218
+ // (channels/index.mjs unhandledRejection → _fatalCrash → exit(1)). Racing
219
+ // login with readyPromise keeps the rejection inside this connect() chain,
220
+ // which every caller already catches non-fatally.
221
+ const loginPromise = self.client.login(self.token);
222
+ // A late login settlement after the timeout raced ahead must not itself
223
+ // become a second unhandled rejection.
224
+ loginPromise.catch(() => {});
215
225
  try {
216
- await self.client.login(self.token);
226
+ await Promise.race([loginPromise, readyPromise]);
227
+ await readyPromise;
217
228
  } catch (err) {
218
229
  clearTimeout(readyTimeout);
219
230
  throw err;
220
231
  }
221
- await readyPromise;
222
232
  }
223
233
 
224
234
  export {