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
@@ -3,12 +3,25 @@
3
3
  import { randomBytes } from 'crypto';
4
4
  import { join } from 'path';
5
5
  import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
6
- import { updateJsonAtomicSync } from '../../runtime/shared/atomic-file.mjs';
6
+ import { updateJsonAtomic } from '../../runtime/shared/atomic-file.mjs';
7
7
  import { promptContentText } from './queue-helpers.mjs';
8
8
 
9
9
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
10
10
  const PENDING_MESSAGES_MODE = 0o600;
11
11
 
12
+ // Serialize this UI process's own steering writes so append→drop→drain keep
13
+ // their issue order even though each now waits on the lock asynchronously.
14
+ // Cross-process mutual exclusion still comes from the shared lock protocol;
15
+ // this chain only orders same-process operations that were previously
16
+ // naturally ordered by the synchronous call site.
17
+ let _persistChain = Promise.resolve();
18
+ function _serialize(task) {
19
+ const run = _persistChain.then(task, task);
20
+ // Never let a rejection poison the chain; each op logs its own error.
21
+ _persistChain = run.catch(() => {});
22
+ return run;
23
+ }
24
+
12
25
  function pendingMessagesPath() {
13
26
  return join(resolvePluginData(), PENDING_MESSAGES_FILE);
14
27
  }
@@ -93,15 +106,20 @@ function removePersistRow(q, entry) {
93
106
  if (idx >= 0) q.splice(idx, 1);
94
107
  }
95
108
 
109
+ // Consistency-required: a dropped append loses a user's queued steering
110
+ // message, so we async-wait for the lock (never try-once). Returns a promise;
111
+ // fire-and-forget at call sites is fine — _serialize keeps append/drop/drain
112
+ // in issue order and the write never blocks the render loop.
96
113
  export function appendTuiSteeringPersist(leadSessionId, entry) {
97
114
  const text = entryPersistText(entry);
98
- if (!text) return;
115
+ if (!text) return Promise.resolve();
99
116
  const key = tuiSteeringSessionKey(leadSessionId);
100
- if (!key) return;
117
+ if (!key) return Promise.resolve();
101
118
  if (!entry.steeringPersistId) entry.steeringPersistId = newSteeringPersistId();
102
119
  const record = { id: entry.steeringPersistId, text };
103
- try {
104
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
120
+ return _serialize(async () => {
121
+ try {
122
+ await updateJsonAtomic(pendingMessagesPath(), (raw) => {
105
123
  const next = normalizePendingStore(raw);
106
124
  const q = Array.isArray(next.sessions[key]) ? next.sessions[key] : [];
107
125
  q.push(record);
@@ -110,19 +128,21 @@ export function appendTuiSteeringPersist(leadSessionId, entry) {
110
128
  next.updatedAt = now;
111
129
  touchPendingSessionEntry(next, key, now);
112
130
  return next;
113
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
114
- } catch (err) {
115
- try { process.stderr.write(`[tui] steering-queue append failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
116
- }
131
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
132
+ } catch (err) {
133
+ try { process.stderr.write(`[tui] steering-queue append failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
134
+ }
135
+ });
117
136
  }
118
137
 
119
138
  export function dropTuiSteeringPersist(leadSessionId, entries) {
120
139
  const key = tuiSteeringSessionKey(leadSessionId);
121
- if (!key) return;
140
+ if (!key) return Promise.resolve();
122
141
  const batch = Array.isArray(entries) ? entries : [];
123
- if (batch.length === 0) return;
124
- try {
125
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
142
+ if (batch.length === 0) return Promise.resolve();
143
+ return _serialize(async () => {
144
+ try {
145
+ await updateJsonAtomic(pendingMessagesPath(), (raw) => {
126
146
  const next = normalizePendingStore(raw);
127
147
  const q = Array.isArray(next.sessions[key]) ? next.sessions[key].slice() : [];
128
148
  if (q.length === 0) return undefined;
@@ -137,10 +157,15 @@ export function dropTuiSteeringPersist(leadSessionId, entries) {
137
157
  }
138
158
  next.updatedAt = Date.now();
139
159
  return next;
140
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
141
- } catch (err) {
142
- try { process.stderr.write(`[tui] steering-queue drop failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
143
- }
160
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
161
+ } catch (err) {
162
+ try { process.stderr.write(`[tui] steering-queue drop failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
163
+ }
164
+ });
165
+ }
166
+
167
+ export function flushTuiSteeringPersist() {
168
+ return _persistChain.catch(() => {});
144
169
  }
145
170
 
146
171
  function drainedRowToRestore(row) {
@@ -153,23 +178,29 @@ function drainedRowToRestore(row) {
153
178
  return null;
154
179
  }
155
180
 
181
+ // Consistency-required (restores queued messages after boot/command). Async
182
+ // lock wait, serialized on _persistChain so it never reorders against a
183
+ // pending append/drop and never blocks the render loop. Returns a promise of
184
+ // the drained rows; call sites await it.
156
185
  export function drainTuiSteeringPersist(leadSessionId) {
157
186
  const key = tuiSteeringSessionKey(leadSessionId);
158
- if (!key) return [];
159
- let drained = [];
160
- try {
161
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
162
- const next = normalizePendingStore(raw);
163
- const q = Array.isArray(next.sessions[key]) ? next.sessions[key] : [];
164
- drained = q.map(drainedRowToRestore).filter(Boolean);
165
- if (drained.length === 0) return undefined;
166
- delete next.sessions[key];
167
- if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
168
- next.updatedAt = Date.now();
169
- return next;
170
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
171
- } catch (err) {
172
- try { process.stderr.write(`[tui] steering-queue drain failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
173
- }
174
- return drained;
175
- }
187
+ if (!key) return Promise.resolve([]);
188
+ return _serialize(async () => {
189
+ let drained = [];
190
+ try {
191
+ await updateJsonAtomic(pendingMessagesPath(), (raw) => {
192
+ const next = normalizePendingStore(raw);
193
+ const q = Array.isArray(next.sessions[key]) ? next.sessions[key] : [];
194
+ drained = q.map(drainedRowToRestore).filter(Boolean);
195
+ if (drained.length === 0) return undefined;
196
+ delete next.sessions[key];
197
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
198
+ next.updatedAt = Date.now();
199
+ return next;
200
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
201
+ } catch (err) {
202
+ try { process.stderr.write(`[tui] steering-queue drain failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
203
+ }
204
+ return drained;
205
+ });
206
+ }
@@ -84,6 +84,7 @@ import {
84
84
  appendTuiSteeringPersist,
85
85
  dropTuiSteeringPersist,
86
86
  drainTuiSteeringPersist,
87
+ flushTuiSteeringPersist,
87
88
  } from './engine/tui-steering-persist.mjs';
88
89
 
89
90
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
@@ -774,12 +775,19 @@ export async function createEngineSession({
774
775
  seq: deferredSeqCounter++,
775
776
  pushed: false,
776
777
  timer: null,
777
- push: () => {
778
+ // Mark the card visible and return its spec WITHOUT emitting, so a
779
+ // batched turn-close flush can commit many specs in one set().
780
+ materialize: () => {
778
781
  card.pushed = true;
779
- if (!card.spec) return;
782
+ if (!card.spec) return null;
780
783
  card.spec.deferredDisplayReady = true;
784
+ return card.spec;
785
+ },
786
+ push: () => {
787
+ const spec = entry.materialize();
788
+ if (!spec) return;
781
789
  pushingFromDeferredEntry = true;
782
- try { pushItem(card.spec); } finally { pushingFromDeferredEntry = false; }
790
+ try { pushItem(spec); } finally { pushingFromDeferredEntry = false; }
783
791
  },
784
792
  };
785
793
  card.deferred = entry;
@@ -797,12 +805,17 @@ export async function createEngineSession({
797
805
  seq: deferredSeqCounter++,
798
806
  pushed: false,
799
807
  timer: null,
800
- push: () => {
808
+ materialize: () => {
801
809
  aggregate.pushed = true;
802
- if (!aggregate.pendingSpec) return;
810
+ if (!aggregate.pendingSpec) return null;
803
811
  aggregate.pendingSpec.deferredDisplayReady = true;
812
+ return aggregate.pendingSpec;
813
+ },
814
+ push: () => {
815
+ const spec = entry.materialize();
816
+ if (!spec) return;
804
817
  pushingFromDeferredEntry = true;
805
- try { pushItem(aggregate.pendingSpec); } finally { pushingFromDeferredEntry = false; }
818
+ try { pushItem(spec); } finally { pushingFromDeferredEntry = false; }
806
819
  },
807
820
  };
808
821
  aggregate.deferred = entry;
@@ -820,6 +833,35 @@ export async function createEngineSession({
820
833
  if (e.timer) { clearTimeout(e.timer); e.timer = null; }
821
834
  }
822
835
  };
836
+ // Collect (mark pushed + cancel timers) every still-deferred entry up to
837
+ // `entry` in creation order, returning their specs WITHOUT emitting — the
838
+ // caller commits them (optionally alongside a trailing turndone item) in a
839
+ // single set() so turn-close writes land as ONE visible commit.
840
+ const collectDeferredUpTo = (entry) => {
841
+ const specs = [];
842
+ if (!entry) return specs;
843
+ for (const e of deferredEntries) {
844
+ if (e.seq > entry.seq) break;
845
+ if (e.pushed) continue;
846
+ e.pushed = true;
847
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
848
+ const spec = e.materialize?.();
849
+ if (spec) specs.push(spec);
850
+ }
851
+ return specs;
852
+ };
853
+ // Append pre-built items (deferred cards + turndone) in ONE set(). None are
854
+ // 'user' kind, so no promptHistory rebuild is needed.
855
+ const appendItemsBatch = (newItems, extra = {}) => {
856
+ if (!newItems || !newItems.length) { set(extra); return; }
857
+ const base = state.items.length;
858
+ const items = [...state.items, ...newItems];
859
+ for (let i = 0; i < newItems.length; i++) {
860
+ const it = newItems[i];
861
+ if (it?.id != null) itemIndexById.set(it.id, base + i);
862
+ }
863
+ set({ items, ...extra });
864
+ };
823
865
 
824
866
  const markPromptCommitted = () => {
825
867
  if (activePromptRestore) {
@@ -1541,13 +1583,18 @@ export async function createEngineSession({
1541
1583
  // pending push timers so nothing fires (or leaks) after the turn ends. The
1542
1584
  // finalize path above already patches results onto visible cards; this just
1543
1585
  // guarantees every registered card is materialized before the turn closes.
1586
+ // Collect (don't emit) the still-deferred cards so the turn-close flush and
1587
+ // the turndone item append in ONE set() below instead of one render bounce
1588
+ // per row. Order/ids are preserved (creation order, then turndone last).
1589
+ let closingItems = [];
1544
1590
  if (deferredEntries.length) {
1545
1591
  const last = deferredEntries[deferredEntries.length - 1];
1546
- if (last) flushDeferredUpTo(last);
1592
+ closingItems = collectDeferredUpTo(last);
1547
1593
  clearDeferredTimers();
1548
1594
  }
1549
1595
  flushDeferredBeforeImmediatePush = null;
1550
- const producedTranscriptItem = state.items.length > itemsAtTurnStart;
1596
+ const producedTranscriptItem =
1597
+ state.items.length + closingItems.length > itemsAtTurnStart;
1551
1598
  const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
1552
1599
  if (!isStaleUnwind) activePromptRestore = null;
1553
1600
  closeThinkingSegment();
@@ -1573,6 +1620,9 @@ export async function createEngineSession({
1573
1620
  && !assistantOutput
1574
1621
  && !producedTranscriptItem;
1575
1622
  if (isStaleUnwind) {
1623
+ // Preserve prior behavior: a stale unwind still materializes its own
1624
+ // deferred cards (turn-local teardown) but writes no other shared state.
1625
+ if (closingItems.length) appendItemsBatch(closingItems);
1576
1626
  tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-state writes`);
1577
1627
  } else {
1578
1628
  if (!isNoOpTurn) {
@@ -1583,9 +1633,10 @@ export async function createEngineSession({
1583
1633
  // in scrollback. (Previously TurnDone rendered only in the
1584
1634
  // bottom-fixed live-status slot and vanished on the next turn.)
1585
1635
  if (!reclaimed && !isNoOpTurn) {
1586
- pushItem({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1636
+ closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1587
1637
  }
1588
- set({
1638
+ // Deferred cards + turndone + status all land in ONE set() (one commit).
1639
+ appendItemsBatch(closingItems, {
1589
1640
  busy: false,
1590
1641
  spinner: null,
1591
1642
  thinking: null,
@@ -1814,8 +1865,8 @@ export async function createEngineSession({
1814
1865
  return out;
1815
1866
  }
1816
1867
 
1817
- function restoreLeadSteeringFromDisk() {
1818
- const rows = drainTuiSteeringPersist(leadSessionId());
1868
+ async function restoreLeadSteeringFromDisk() {
1869
+ const rows = await drainTuiSteeringPersist(leadSessionId());
1819
1870
  if (!rows.length) return;
1820
1871
  const restored = [];
1821
1872
  for (const row of rows) {
@@ -2017,7 +2068,7 @@ export async function createEngineSession({
2017
2068
  return state.stats;
2018
2069
  };
2019
2070
 
2020
- restoreLeadSteeringFromDisk();
2071
+ void restoreLeadSteeringFromDisk();
2021
2072
 
2022
2073
  return {
2023
2074
  getState: () => state,
@@ -2987,7 +3038,7 @@ export async function createEngineSession({
2987
3038
  ...routeState(),
2988
3039
  stats: { ...state.stats },
2989
3040
  });
2990
- restoreLeadSteeringFromDisk();
3041
+ void restoreLeadSteeringFromDisk();
2991
3042
  return true;
2992
3043
  } finally {
2993
3044
  set({ commandBusy: false, commandStatus: null });
@@ -3004,6 +3055,7 @@ export async function createEngineSession({
3004
3055
  try { unsubscribeRemoteState?.(); } catch {}
3005
3056
  unsubscribeRemoteState = null;
3006
3057
  denyAllToolApprovals('runtime closing');
3058
+ await flushTuiSteeringPersist();
3007
3059
  await runtime.close(reason, options);
3008
3060
  listeners.clear();
3009
3061
  },
package/src/tui/index.jsx CHANGED
@@ -32,38 +32,123 @@ const EXIT_HARD_DELAY_MS = positiveIntEnv('MIXDOG_TUI_HARD_EXIT_DELAY_MS', 500);
32
32
  const EXIT_HARD_ENABLED = !/^(0|false|no|off)$/i.test(String(process.env.MIXDOG_TUI_HARD_EXIT || '1'));
33
33
  const EXIT_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_EXIT_DEBUG || ''));
34
34
  const PERF_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_PERF || ''));
35
+ const LOOP_PROBE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_LOOP_PROBE || ''));
36
+ const LOOP_PROBE_INTERVAL_MS = 1000;
37
+ const LOOP_PROBE_DRIFT_THRESHOLD_MS = 200;
35
38
 
36
39
  function positiveIntEnv(name, fallback) {
37
40
  const value = Number(process.env[name]);
38
41
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
39
42
  }
40
43
 
44
+ // Opt-in event-loop stall probe (MIXDOG_TUI_LOOP_PROBE=1). A setInterval that
45
+ // measures its own scheduling drift: if the loop is blocked (sync file lock,
46
+ // heavy render, GC), the callback fires late and drift = actual - expected.
47
+ // Logs `[mixdog-loop] stall=NNNms` via the same stderr surface the TUI guard
48
+ // captures. unref'd so it never keeps the process alive.
49
+ function installTuiLoopProbe() {
50
+ if (!LOOP_PROBE_ENABLED) return () => {};
51
+ let last = performance.now();
52
+ const timer = setInterval(() => {
53
+ const now = performance.now();
54
+ const drift = (now - last) - LOOP_PROBE_INTERVAL_MS;
55
+ last = now;
56
+ if (drift > LOOP_PROBE_DRIFT_THRESHOLD_MS) {
57
+ try { process.stderr.write(`[mixdog-loop] stall=${drift.toFixed(0)}ms\n`); } catch { /* ignore */ }
58
+ }
59
+ }, LOOP_PROBE_INTERVAL_MS);
60
+ timer.unref?.();
61
+ return () => { try { clearInterval(timer); } catch { /* ignore */ } };
62
+ }
63
+
41
64
  // Lightweight render-frame profiler. Forked ink calls options.onRender with the
42
65
  // per-frame render() wall time (renderNodeToOutput serialization). We aggregate
43
66
  // and emit a rolling summary every PERF_REPORT_EVERY frames so typing latency
44
67
  // can be measured without flooding output. Entirely no-op unless
45
68
  // MIXDOG_TUI_PERF=1, so it costs nothing in normal runs.
46
69
  const PERF_REPORT_EVERY = positiveIntEnv('MIXDOG_TUI_PERF_EVERY', 60);
70
+ const PERF_STALL_INTERVAL_MS = positiveIntEnv('MIXDOG_TUI_PERF_STALL_INTERVAL_MS', 100);
71
+ const PERF_STALL_THRESHOLD_MS = positiveIntEnv('MIXDOG_TUI_PERF_STALL_MS', 80);
72
+ const PERF_RENDER_GAP_MS = positiveIntEnv('MIXDOG_TUI_PERF_RENDER_GAP_MS', 120);
73
+
74
+ function perfLog(event, fields = {}) {
75
+ if (!PERF_ENABLED) return;
76
+ const elapsedMs = performance.now() - BOOT_PROFILE_START;
77
+ const parts = [`[mixdog-perf] +${elapsedMs.toFixed(1)}ms`, event];
78
+ for (const [key, value] of Object.entries(fields || {})) {
79
+ if (value === undefined || value === null || value === '') continue;
80
+ parts.push(`${key}=${String(value).replace(/\s+/g, '_')}`);
81
+ }
82
+ try { process.stderr.write(`${parts.join(' ')}\n`); } catch { /* ignore */ }
83
+ }
84
+
85
+ function installTuiPerfProbe() {
86
+ if (!PERF_ENABLED) return () => {};
87
+ let last = performance.now();
88
+ let lastCpu = process.cpuUsage();
89
+ perfLog('probe:start', {
90
+ intervalMs: PERF_STALL_INTERVAL_MS,
91
+ stallMs: PERF_STALL_THRESHOLD_MS,
92
+ renderGapMs: PERF_RENDER_GAP_MS,
93
+ });
94
+ const timer = setInterval(() => {
95
+ const now = performance.now();
96
+ const elapsed = now - last;
97
+ const lagMs = elapsed - PERF_STALL_INTERVAL_MS;
98
+ const cpu = process.cpuUsage(lastCpu);
99
+ last = now;
100
+ lastCpu = process.cpuUsage();
101
+ if (lagMs < PERF_STALL_THRESHOLD_MS) return;
102
+ const mem = process.memoryUsage();
103
+ perfLog('event-loop-stall', {
104
+ lagMs: lagMs.toFixed(1),
105
+ elapsedMs: elapsed.toFixed(1),
106
+ cpuUserMs: (cpu.user / 1000).toFixed(1),
107
+ cpuSystemMs: (cpu.system / 1000).toFixed(1),
108
+ rssMb: (mem.rss / 1048576).toFixed(1),
109
+ heapMb: (mem.heapUsed / 1048576).toFixed(1),
110
+ });
111
+ }, PERF_STALL_INTERVAL_MS);
112
+ timer.unref?.();
113
+ return () => {
114
+ try { clearInterval(timer); } catch { /* ignore */ }
115
+ };
116
+ }
117
+
47
118
  function makeRenderProfiler() {
48
119
  if (!PERF_ENABLED) return undefined;
49
120
  let count = 0;
50
121
  let sum = 0;
51
122
  let max = 0;
52
123
  let slow = 0;
124
+ let maxGap = 0;
125
+ let lastFrameAt = 0;
53
126
  return ({ renderTime } = {}) => {
127
+ const now = performance.now();
54
128
  const ms = Number(renderTime) || 0;
129
+ const gap = lastFrameAt ? now - lastFrameAt : 0;
130
+ lastFrameAt = now;
55
131
  count += 1;
56
132
  sum += ms;
57
133
  if (ms > max) max = ms;
134
+ if (gap > maxGap) maxGap = gap;
58
135
  if (ms >= 16) slow += 1;
136
+ if (gap >= PERF_RENDER_GAP_MS) {
137
+ perfLog('render-gap', {
138
+ gapMs: gap.toFixed(1),
139
+ renderMs: ms.toFixed(2),
140
+ });
141
+ }
59
142
  if (count >= PERF_REPORT_EVERY) {
60
143
  const avg = sum / count;
61
- try {
62
- process.stderr.write(
63
- `[mixdog-perf] frames=${count} avg=${avg.toFixed(2)}ms max=${max.toFixed(2)}ms slow16+=${slow}\n`,
64
- );
65
- } catch { /* ignore */ }
66
- count = 0; sum = 0; max = 0; slow = 0;
144
+ perfLog('render-summary', {
145
+ frames: count,
146
+ avgMs: avg.toFixed(2),
147
+ maxMs: max.toFixed(2),
148
+ maxGapMs: maxGap.toFixed(1),
149
+ slow16: slow,
150
+ });
151
+ count = 0; sum = 0; max = 0; slow = 0; maxGap = 0;
67
152
  }
68
153
  };
69
154
  }
@@ -291,6 +376,8 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
291
376
  }
292
377
 
293
378
  const restoreStderr = installTuiStderrGuard();
379
+ const stopPerfProbe = installTuiPerfProbe();
380
+ const stopLoopProbe = installTuiLoopProbe();
294
381
  const restorePrimedInput = () => drainStdin(process.stdin);
295
382
  let restored = false;
296
383
  const restoreTerminal = () => {
@@ -343,6 +430,8 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
343
430
  bootProfile('store:ready', { ms: (performance.now() - startedAt).toFixed(1) });
344
431
  } catch (error) {
345
432
  splash.stop();
433
+ stopPerfProbe();
434
+ stopLoopProbe();
346
435
  restoreTerminal();
347
436
  try { process.off('exit', restoreTerminal); } catch { /* ignore */ }
348
437
  restoreStderr();
@@ -456,6 +545,8 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
456
545
  }
457
546
  await waitUntilExit();
458
547
  } finally {
548
+ stopPerfProbe();
549
+ stopLoopProbe();
459
550
  clearInterval(uiHeartbeatTimer);
460
551
  for (const [stream, event, handler] of stdioDeathListeners.splice(0)) {
461
552
  try { stream.off(event, handler); } catch { /* ignore */ }
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * src/ui/statusline-segments.mjs — filesystem-backed L2 segment sources.
3
3
  *
4
- * Extracted verbatim from statusline.mjs: the shell-jobs segment (owner-scoped
5
- * job scan + liveness) and the memory-cycle segment (daemon state file). Both
6
- * keep their own 1s process-local caches. No behavior change.
4
+ * The shell-jobs segment (owner-scoped job scan + liveness) and the
5
+ * memory-cycle segment (daemon state file) each keep a 1s process-local
6
+ * cache. Render calls (`shellJobsStatus`/`memoryCycleStatus`) are pure cache
7
+ * reads and NEVER touch the filesystem synchronously: on cache expiry they
8
+ * return the last known value immediately and kick a background
9
+ * fs/promises refresh (guarded against overlap) that updates the cache for
10
+ * the next render tick. This keeps the 500ms render tick off sync fs I/O.
7
11
  */
8
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
+ import { readdir, readFile, stat } from 'node:fs/promises';
9
13
  import { homedir } from 'node:os';
10
14
  import { join } from 'node:path';
11
15
  import { formatElapsed } from './statusline-format.mjs';
@@ -15,13 +19,25 @@ const DEFAULT_STANDALONE_DATA_DIR = join(DEFAULT_MIXDOG_HOME, 'data');
15
19
  const SHELL_JOBS_SEGMENT_CACHE_MS = 1000;
16
20
 
17
21
  let _shellJobsSegmentCache = { ownerPid: 0, at: 0, value: { count: 0, elapsedLabel: '' } };
22
+ let _shellJobsRefreshInFlight = false;
18
23
 
19
24
  function dataDir() {
20
25
  return process.env.MIXDOG_DATA_DIR || DEFAULT_STANDALONE_DATA_DIR;
21
26
  }
22
27
 
28
+ // Render-path entry point: synchronous, cache-only. Never blocks on fs.
23
29
  export function shellJobsStatus({ clientHostPid } = {}) {
24
- return _shellJobsStatusImpl({ clientHostPid });
30
+ const ownerPid = positiveInt(clientHostPid);
31
+ const empty = { count: 0, elapsedLabel: '' };
32
+ if (!ownerPid) return empty;
33
+ const now = Date.now();
34
+ const sameOwner = _shellJobsSegmentCache.ownerPid === ownerPid;
35
+ const fresh = sameOwner && now - _shellJobsSegmentCache.at < SHELL_JOBS_SEGMENT_CACHE_MS;
36
+ if (!fresh && !_shellJobsRefreshInFlight) {
37
+ _shellJobsRefreshInFlight = true;
38
+ refreshShellJobsStatus(ownerPid).finally(() => { _shellJobsRefreshInFlight = false; });
39
+ }
40
+ return sameOwner ? (_shellJobsSegmentCache.value || empty) : empty;
25
41
  }
26
42
 
27
43
  // Memory cycle L2 segment source. The memory daemon writes
@@ -30,23 +46,32 @@ export function shellJobsStatus({ clientHostPid } = {}) {
30
46
  // the same 1s cadence as the shell segment. Shows a single unified "Memory"
31
47
  // segment: running (spinner + elapsed) or backlog warning (yellow count).
32
48
  let _memoryCycleSegmentCache = { at: 0, value: null };
49
+ let _memoryCycleRefreshInFlight = false;
33
50
  const MEMORY_CYCLE_SEGMENT_CACHE_MS = 1000;
34
51
  const MEMORY_CYCLE_BACKLOG_WARN = 500;
52
+
53
+ // Render-path entry point: synchronous, cache-only. Never blocks on fs.
35
54
  export function memoryCycleStatus() {
36
55
  const now = Date.now();
37
- if (now - _memoryCycleSegmentCache.at < MEMORY_CYCLE_SEGMENT_CACHE_MS) {
38
- return _memoryCycleSegmentCache.value;
56
+ if (now - _memoryCycleSegmentCache.at >= MEMORY_CYCLE_SEGMENT_CACHE_MS && !_memoryCycleRefreshInFlight) {
57
+ _memoryCycleRefreshInFlight = true;
58
+ refreshMemoryCycleStatus().finally(() => { _memoryCycleRefreshInFlight = false; });
39
59
  }
60
+ return _memoryCycleSegmentCache.value;
61
+ }
62
+
63
+ async function refreshMemoryCycleStatus() {
40
64
  let value = null;
41
65
  try {
42
66
  const p = join(dataDir(), 'memory-cycle-state.json');
43
- if (existsSync(p)) {
44
- const state = JSON.parse(readFileSync(p, 'utf-8'));
67
+ const raw = await readFile(p, 'utf-8').catch(() => null);
68
+ if (raw != null) {
69
+ const state = JSON.parse(raw);
45
70
  const running = state?.running || null;
46
71
  const backlog = state?.backlog || {};
47
72
  // Stale-file guard: a daemon that died mid-run leaves running set —
48
73
  // ignore anything not refreshed in the last 10 minutes.
49
- const fresh = Number(state?.updatedAt) > now - 10 * 60_000;
74
+ const fresh = Number(state?.updatedAt) > Date.now() - 10 * 60_000;
50
75
  if (fresh && running?.cycle && Number(running.started_at) > 0) {
51
76
  value = { kind: 'running', startedAt: Number(running.started_at) };
52
77
  } else if (fresh) {
@@ -55,26 +80,21 @@ export function memoryCycleStatus() {
55
80
  }
56
81
  }
57
82
  } catch { value = null; }
58
- _memoryCycleSegmentCache = { at: now, value };
59
- return value;
83
+ _memoryCycleSegmentCache = { at: Date.now(), value };
60
84
  }
61
85
 
62
- function _shellJobsStatusImpl({ clientHostPid } = {}) {
63
- const ownerPid = positiveInt(clientHostPid);
86
+ async function refreshShellJobsStatus(ownerPid) {
64
87
  const empty = { count: 0, elapsedLabel: '' };
65
- if (!ownerPid) return empty;
66
- const now = Date.now();
67
- if (_shellJobsSegmentCache.ownerPid === ownerPid && now - _shellJobsSegmentCache.at < SHELL_JOBS_SEGMENT_CACHE_MS) {
68
- return _shellJobsSegmentCache.value || empty;
69
- }
70
88
  let value = empty;
71
89
  try {
72
90
  const dir = join(dataDir(), 'shell-jobs');
73
- if (!existsSync(dir)) {
74
- _shellJobsSegmentCache = { ownerPid, at: now, value };
75
- return value;
91
+ let names;
92
+ try {
93
+ names = await readdir(dir);
94
+ } catch {
95
+ _shellJobsSegmentCache = { ownerPid, at: Date.now(), value };
96
+ return;
76
97
  }
77
- const names = readdirSync(dir);
78
98
  const done = new Set(names.filter((n) => n.endsWith('.done')).map((n) => n.slice(0, -5)));
79
99
  const ownerByJob = new Map();
80
100
  for (const n of names) {
@@ -95,34 +115,32 @@ function _shellJobsStatusImpl({ clientHostPid } = {}) {
95
115
  for (const id of ids) {
96
116
  const p = join(dir, `${id}.json`);
97
117
  let detail;
98
- try { detail = JSON.parse(readFileSync(p, 'utf-8')); } catch { continue; }
99
- if (!isShellJobAlive(detail, p, dir, id)) continue;
118
+ try { detail = JSON.parse(await readFile(p, 'utf-8')); } catch { continue; }
119
+ if (!(await isShellJobAlive(detail, p, dir, id))) continue;
100
120
  count++;
101
121
  try {
102
- const st = statSync(p);
122
+ const st = await stat(p);
103
123
  if (st.mtimeMs < oldestMs) oldestMs = st.mtimeMs;
104
124
  } catch {}
105
125
  }
106
- if (!count) {
107
- _shellJobsSegmentCache = { ownerPid, at: now, value };
108
- return value;
126
+ if (count) {
127
+ const elapsedLabel = Number.isFinite(oldestMs) ? formatElapsed(Date.now() - oldestMs) : '';
128
+ value = { count, elapsedLabel };
109
129
  }
110
- const elapsedLabel = Number.isFinite(oldestMs) ? formatElapsed(Date.now() - oldestMs) : '';
111
- value = { count, elapsedLabel };
112
130
  } catch {
113
131
  value = empty;
114
132
  }
115
- _shellJobsSegmentCache = { ownerPid, at: now, value };
116
- return value;
133
+ _shellJobsSegmentCache = { ownerPid, at: Date.now(), value };
117
134
  }
118
135
 
119
- function isShellJobAlive(detail, detailPath, dir, id) {
136
+ async function isShellJobAlive(detail, detailPath, dir, id) {
120
137
  const pid = positiveInt(detail?.pid);
121
138
  if (!pid) return false;
122
139
  try {
123
- const st = statSync(detailPath);
140
+ const st = await stat(detailPath);
124
141
  const timeoutMs = Number(detail?.timeoutMs);
125
- const enforced = detail?.timeoutEnforced === true || existsSync(join(dir, `${id}.enforced`));
142
+ const enforced = detail?.timeoutEnforced === true
143
+ || await stat(join(dir, `${id}.enforced`)).then(() => true, () => false);
126
144
  if (enforced && Number.isFinite(timeoutMs) && timeoutMs > 0 && Date.now() - st.mtimeMs > timeoutMs + 30 * 60_000) {
127
145
  return false;
128
146
  }