mixdog 0.9.52 → 0.9.53

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 (111) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench-run.mjs +2 -2
  3. package/scripts/compact-pressure-test.mjs +104 -0
  4. package/scripts/desktop-session-bridge-test.mjs +704 -0
  5. package/scripts/freevar-smoke.mjs +7 -4
  6. package/scripts/lifecycle-api-test.mjs +65 -4
  7. package/scripts/max-output-recovery-test.mjs +31 -0
  8. package/scripts/memory-core-input-test.mjs +10 -0
  9. package/scripts/openai-oauth-ws-1006-retry-test.mjs +63 -3
  10. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  11. package/scripts/parent-abort-link-test.mjs +24 -0
  12. package/scripts/process-lifecycle-test.mjs +80 -22
  13. package/scripts/provider-contract-test.mjs +257 -0
  14. package/scripts/provider-toolcall-test.mjs +172 -10
  15. package/scripts/session-orphan-sweep-test.mjs +27 -1
  16. package/src/lib/keychain-cjs.cjs +36 -23
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  18. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  19. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -300
  22. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +18 -266
  24. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +18 -1
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +15 -130
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +5 -115
  27. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
  28. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  29. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  30. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -71
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +47 -3
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +1 -7
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +72 -77
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -7
  41. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  42. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  43. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  44. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  45. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -5
  46. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  47. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  48. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  49. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  50. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  51. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  53. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  55. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  59. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  62. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  63. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  64. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  65. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  66. package/src/runtime/channels/backends/discord.mjs +6 -6
  67. package/src/runtime/channels/lib/config.mjs +15 -2
  68. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  69. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  70. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  72. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  73. package/src/runtime/memory/index.mjs +24 -198
  74. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  75. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  76. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  77. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  78. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  79. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  80. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  81. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  82. package/src/runtime/shared/config.mjs +58 -13
  83. package/src/runtime/shared/llm/cost.mjs +14 -4
  84. package/src/runtime/shared/memory-snapshot.mjs +236 -0
  85. package/src/runtime/shared/process-lifecycle.mjs +92 -19
  86. package/src/runtime/shared/process-shutdown.mjs +6 -0
  87. package/src/runtime/shared/resource-admission.mjs +7 -2
  88. package/src/session-runtime/channel-config-api.mjs +7 -7
  89. package/src/session-runtime/config-lifecycle.mjs +20 -17
  90. package/src/session-runtime/cwd-plugins.mjs +9 -7
  91. package/src/session-runtime/env.mjs +1 -2
  92. package/src/session-runtime/hitch-profile.mjs +45 -0
  93. package/src/session-runtime/lifecycle-api.mjs +36 -9
  94. package/src/session-runtime/mcp-glue.mjs +6 -11
  95. package/src/session-runtime/provider-init-key.mjs +17 -0
  96. package/src/session-runtime/runtime-core.mjs +44 -103
  97. package/src/session-runtime/runtime-paths.mjs +20 -0
  98. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  99. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  100. package/src/session-runtime/tool-catalog.mjs +15 -89
  101. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  102. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  103. package/src/standalone/agent-tool.mjs +12 -162
  104. package/src/standalone/channel-admin.mjs +29 -0
  105. package/src/tui/App.jsx +11 -5
  106. package/src/tui/dist/index.mjs +202 -65
  107. package/src/tui/engine/session-api-ext.mjs +37 -4
  108. package/src/tui/index.jsx +1 -0
  109. package/src/tui/lib/voice-setup.mjs +5 -5
  110. package/scripts/_devtools-stub.mjs +0 -1
  111. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -0,0 +1,236 @@
1
+ import { execFile } from 'node:child_process';
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ renameSync,
9
+ statSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ writeSync,
13
+ } from 'node:fs';
14
+ import { freemem, totalmem } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { promisify } from 'node:util';
17
+ import { withFileLockSync } from './atomic-file.mjs';
18
+ import { resolvePluginData } from './plugin-paths.mjs';
19
+
20
+ const execFileAsync = promisify(execFile);
21
+ export const MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES = 64 * 1024;
22
+ const SNAPSHOT_NAME = 'memory-pressure.jsonl';
23
+ const SNAPSHOT_INTERVAL_MS = 10 * 60 * 1000;
24
+ const SNAPSHOT_RATE_LIMIT_MS = 60 * 1000;
25
+ const SNAPSHOT_LOCK_TIMEOUT_MS = 2000;
26
+ const SNAPSHOT_LOCK_STALE_MS = 10000;
27
+
28
+ let periodicTimer = null;
29
+ let lastPressureSnapshotAt = 0;
30
+
31
+ function enabled() {
32
+ return process.env.MIXDOG_MEMORY_SNAPSHOT !== '0';
33
+ }
34
+
35
+ function snapshotPaths() {
36
+ const directory = join(resolvePluginData(), 'diagnostics');
37
+ const snapshot = join(directory, SNAPSHOT_NAME);
38
+ return {
39
+ directory,
40
+ snapshot,
41
+ previousSnapshot: `${snapshot}.1`,
42
+ lock: join(directory, 'memory-pressure.lock'),
43
+ };
44
+ }
45
+
46
+ function memoryUsage() {
47
+ try {
48
+ return process.memoryUsage();
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function systemMemory() {
55
+ try {
56
+ return { freeMemoryBytes: freemem(), totalMemoryBytes: totalmem() };
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ function rotateSnapshot(paths) {
63
+ const size = Number(statSync(paths.snapshot).size) || 0;
64
+ try { unlinkSync(paths.previousSnapshot); } catch {}
65
+ if (size <= MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) {
66
+ renameSync(paths.snapshot, paths.previousSnapshot);
67
+ return;
68
+ }
69
+ writeFileSync(paths.previousSnapshot, '', { mode: 0o600 });
70
+ unlinkSync(paths.snapshot);
71
+ }
72
+
73
+ function appendSnapshot(entry) {
74
+ const line = `${JSON.stringify(entry)}\n`;
75
+ if (Buffer.byteLength(line) > MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) return false;
76
+ const paths = snapshotPaths();
77
+ try {
78
+ mkdirSync(paths.directory, { recursive: true, mode: 0o700 });
79
+ return withFileLockSync(paths.lock, () => {
80
+ if (existsSync(paths.previousSnapshot)
81
+ && statSync(paths.previousSnapshot).size > MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) {
82
+ writeFileSync(paths.previousSnapshot, '', { mode: 0o600 });
83
+ }
84
+ if (existsSync(paths.snapshot)
85
+ && statSync(paths.snapshot).size + Buffer.byteLength(line) > MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) {
86
+ rotateSnapshot(paths);
87
+ }
88
+ const fd = openSync(paths.snapshot, 'a', 0o600);
89
+ try {
90
+ writeSync(fd, line, null, 'utf8');
91
+ fsyncSync(fd);
92
+ } finally {
93
+ closeSync(fd);
94
+ }
95
+ return statSync(paths.snapshot).size <= MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES;
96
+ }, {
97
+ timeoutMs: SNAPSHOT_LOCK_TIMEOUT_MS,
98
+ staleMs: SNAPSHOT_LOCK_STALE_MS,
99
+ });
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function workingSetMb(bytes) {
106
+ return Math.round((Number(bytes) / (1024 * 1024)) * 10) / 10;
107
+ }
108
+
109
+ function nullableProcessText(value) {
110
+ if (typeof value !== 'string') return null;
111
+ if (!value.trim()) return null;
112
+ return value.length > 200 ? value.slice(0, 200) : value;
113
+ }
114
+
115
+ function nullablePid(value) {
116
+ if ((typeof value !== 'number' && typeof value !== 'string')
117
+ || (typeof value === 'string' && !value.trim())) return null;
118
+ const pid = Number(value);
119
+ return Number.isInteger(pid) ? pid : null;
120
+ }
121
+
122
+ function parseWindowsProcesses(stdout) {
123
+ const rows = JSON.parse(String(stdout).trim() || '[]');
124
+ return (Array.isArray(rows) ? rows : [rows])
125
+ .map((row) => ({
126
+ name: String(row?.Name || ''),
127
+ pid: Number(row?.ProcessId),
128
+ workingSetMb: workingSetMb(row?.WorkingSetSize),
129
+ commandLine: nullableProcessText(row?.CommandLine),
130
+ parentPid: nullablePid(row?.ParentProcessId),
131
+ }))
132
+ .filter((row) => row.name && Number.isInteger(row.pid) && Number.isFinite(row.workingSetMb))
133
+ .slice(0, 8);
134
+ }
135
+
136
+ function parsePosixProcesses(stdout) {
137
+ return String(stdout).split(/\r?\n/)
138
+ .map((line) => {
139
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*?)\s+(\d+)\s*$/);
140
+ if (!match) return null;
141
+ return {
142
+ pid: Number(match[1]),
143
+ parentPid: nullablePid(match[2]),
144
+ name: match[3],
145
+ commandLine: nullableProcessText(match[4]),
146
+ workingSetMb: workingSetMb(Number(match[5]) * 1024),
147
+ };
148
+ })
149
+ .filter((row) => row && Number.isInteger(row.pid) && Number.isFinite(row.workingSetMb))
150
+ .sort((left, right) => right.workingSetMb - left.workingSetMb)
151
+ .slice(0, 8);
152
+ }
153
+
154
+ async function topProcesses() {
155
+ try {
156
+ if (process.platform === 'win32') {
157
+ const { stdout } = await execFileAsync('powershell.exe', [
158
+ '-NoProfile', '-NonInteractive', '-Command',
159
+ 'Get-CimInstance Win32_Process | Sort-Object WorkingSetSize -Descending | Select-Object -First 8 ProcessId,Name,WorkingSetSize,CommandLine,ParentProcessId | ConvertTo-Json -Compress',
160
+ ], {
161
+ encoding: 'utf8',
162
+ timeout: 3000,
163
+ windowsHide: true,
164
+ });
165
+ return parseWindowsProcesses(stdout);
166
+ }
167
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm=,args=,rss='], {
168
+ encoding: 'utf8',
169
+ timeout: 3000,
170
+ windowsHide: true,
171
+ });
172
+ return parsePosixProcesses(stdout);
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ function baseSnapshot(reason) {
179
+ return {
180
+ timestamp: new Date().toISOString(),
181
+ pid: process.pid,
182
+ reason,
183
+ memoryUsage: memoryUsage(),
184
+ systemMemory: systemMemory(),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Collect a full attribution snapshot. Process enumeration failures are
190
+ * intentionally omitted from the record, rather than surfacing to callers.
191
+ */
192
+ export async function captureMemoryPressureSnapshot(reason = 'memory-pressure') {
193
+ if (!enabled()) return false;
194
+ const entry = baseSnapshot(reason);
195
+ const processes = await topProcesses();
196
+ if (processes) entry.topProcesses = processes;
197
+ return appendSnapshot(entry);
198
+ }
199
+
200
+ function capturePeriodicMemorySnapshot() {
201
+ if (!enabled()) return false;
202
+ return appendSnapshot(baseSnapshot('periodic'));
203
+ }
204
+
205
+ /**
206
+ * Schedules one full snapshot without work on the admission path. This
207
+ * process-wide limiter deliberately reserves the interval before scheduling.
208
+ */
209
+ export function requestMemoryPressureSnapshot(reason) {
210
+ try {
211
+ if (!enabled()) return false;
212
+ const now = Date.now();
213
+ if (now - lastPressureSnapshotAt < SNAPSHOT_RATE_LIMIT_MS) return false;
214
+ lastPressureSnapshotAt = now;
215
+ const immediate = setImmediate(() => {
216
+ void captureMemoryPressureSnapshot(reason).catch(() => {});
217
+ });
218
+ immediate.unref?.();
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ export function armMemoryPressureSampling() {
226
+ if (!enabled() || periodicTimer) return false;
227
+ periodicTimer = setInterval(() => { capturePeriodicMemorySnapshot(); }, SNAPSHOT_INTERVAL_MS);
228
+ periodicTimer.unref?.();
229
+ return true;
230
+ }
231
+
232
+ export function memoryPressureSnapshotPath() {
233
+ return snapshotPaths().snapshot;
234
+ }
235
+
236
+ armMemoryPressureSampling();
@@ -28,6 +28,7 @@ const REPORT_NAME = 'mixdog-node-report.json';
28
28
  const LEDGER_LOCK_NAME = 'process-lifecycle.lock';
29
29
  const LEDGER_LOCK_TIMEOUT_MS = 2000;
30
30
  const LEDGER_LOCK_STALE_MS = 10000;
31
+ const IDENTITY_UPGRADE_DELAYS_MS = [1000, 9000, 20000];
31
32
  const STATE_KEY = Symbol.for('mixdog.processLifecycle.v1');
32
33
  const VALID_REASONS = new Set([
33
34
  'process-start',
@@ -119,9 +120,36 @@ function appendEntry(active, entry) {
119
120
  }
120
121
 
121
122
  function currentProcessIdentity() {
123
+ if (process.platform !== 'linux') {
124
+ const value = Math.floor((Date.now() - (process.uptime() * 1000)) / 1000);
125
+ return Number.isSafeInteger(value) && value > 0
126
+ ? { kind: 'start-seconds', value, method: 'uptime' }
127
+ : null;
128
+ }
122
129
  return processIdentityForPid(process.pid);
123
130
  }
124
131
 
132
+ function windowsProcessIdentities(pids) {
133
+ const identities = new Map();
134
+ if (pids.length === 0) return identities;
135
+ try {
136
+ const out = execFileSync('powershell.exe', [
137
+ '-NoProfile', '-NonInteractive', '-Command',
138
+ `$ErrorActionPreference='SilentlyContinue'; Get-Process -Id @(${pids.join(',')}) | ForEach-Object { try { "$($_.Id):$(([DateTimeOffset]$_.StartTime).ToUnixTimeSeconds())" } catch {} }`,
139
+ ], { encoding: 'utf8', timeout: 2000, windowsHide: true });
140
+ for (const line of String(out).split(/\r?\n/)) {
141
+ const match = /^(\d+):(\d+)$/.exec(line.trim());
142
+ if (!match) continue;
143
+ const pid = Number(match[1]);
144
+ const value = Number(match[2]);
145
+ if (Number.isSafeInteger(pid) && Number.isSafeInteger(value) && value > 0) {
146
+ identities.set(pid, { kind: 'start-seconds', value, method: 'powershell' });
147
+ }
148
+ }
149
+ } catch {}
150
+ return identities;
151
+ }
152
+
125
153
  function processIdentityForPid(pid) {
126
154
  if (process.platform === 'linux') {
127
155
  try {
@@ -136,13 +164,7 @@ function processIdentityForPid(pid) {
136
164
  }
137
165
  try {
138
166
  if (process.platform === 'win32') {
139
- const out = execFileSync('powershell.exe', [
140
- '-NoProfile', '-NonInteractive', '-Command',
141
- `([DateTimeOffset](Get-Process -Id ${pid} -ErrorAction Stop).StartTime).ToUnixTimeSeconds()`,
142
- ], { encoding: 'utf8', timeout: 2000, windowsHide: true });
143
- const text = String(out).trim();
144
- const value = /^\d+$/.test(text) ? Number(text) : NaN;
145
- return Number.isSafeInteger(value) ? { kind: 'start-seconds', value } : null;
167
+ return windowsProcessIdentities([pid]).get(pid) || null;
146
168
  }
147
169
  const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
148
170
  encoding: 'utf8',
@@ -150,12 +172,30 @@ function processIdentityForPid(pid) {
150
172
  windowsHide: true,
151
173
  });
152
174
  const value = Math.floor(Date.parse(String(out).trim()) / 1000);
153
- return Number.isInteger(value) ? { kind: 'start-seconds', value } : null;
175
+ return Number.isInteger(value) ? { kind: 'start-seconds', value, method: 'ps' } : null;
154
176
  } catch {
155
177
  return null;
156
178
  }
157
179
  }
158
180
 
181
+ function processIdentitiesForPids(pids) {
182
+ const unique = [...new Set(pids)];
183
+ if (process.platform !== 'win32') {
184
+ return new Map(unique.map((pid) => [pid, processIdentityForPid(pid)]));
185
+ }
186
+
187
+ const identities = new Map();
188
+ const otherPids = [];
189
+ for (const pid of unique) {
190
+ if (pid === process.pid) identities.set(pid, currentProcessIdentity());
191
+ else otherPids.push(pid);
192
+ }
193
+ for (const [pid, identity] of windowsProcessIdentities(otherPids)) {
194
+ identities.set(pid, identity);
195
+ }
196
+ return identities;
197
+ }
198
+
159
199
  function sameProcessIdentity(expected, observed) {
160
200
  if (!expected || !observed || expected.kind !== observed.kind) return null;
161
201
  if (expected.kind === 'linux-start-ticks') {
@@ -166,7 +206,10 @@ function sameProcessIdentity(expected, observed) {
166
206
  if (expected.kind === 'start-seconds') {
167
207
  if (!Number.isSafeInteger(expected.value) || expected.value < 1
168
208
  || !Number.isSafeInteger(observed.value) || observed.value < 1) return null;
169
- return expected.value === observed.value;
209
+ const crossMethod = expected.method === 'uptime' || observed.method === 'uptime';
210
+ return crossMethod
211
+ ? Math.abs(expected.value - observed.value) <= 2
212
+ : expected.value === observed.value;
170
213
  }
171
214
  return null;
172
215
  }
@@ -237,6 +280,28 @@ function writeMarker(active) {
237
280
  }
238
281
  }
239
282
 
283
+ function scheduleProcessIdentityUpgrade(active) {
284
+ if (active.processIdentity?.method !== 'uptime') return;
285
+ const scheduleAttempt = (attempt) => {
286
+ if (attempt >= IDENTITY_UPGRADE_DELAYS_MS.length) return;
287
+ const timer = setTimeout(() => {
288
+ try {
289
+ if (sharedState().active !== active || !markerOwned(active)) return;
290
+ const processIdentity = processIdentityForPid(process.pid);
291
+ if (processIdentity && processIdentity.method !== 'uptime') {
292
+ const previousIdentity = active.processIdentity;
293
+ active.processIdentity = processIdentity;
294
+ if (writeMarker(active)) return;
295
+ active.processIdentity = previousIdentity;
296
+ }
297
+ } catch {}
298
+ scheduleAttempt(attempt + 1);
299
+ }, IDENTITY_UPGRADE_DELAYS_MS[attempt]);
300
+ timer.unref?.();
301
+ };
302
+ scheduleAttempt(0);
303
+ }
304
+
240
305
  function reapVanishedMarkers(active) {
241
306
  const candidates = [];
242
307
  try {
@@ -247,20 +312,27 @@ function reapVanishedMarkers(active) {
247
312
  }
248
313
  } catch {}
249
314
  if (existsSync(active.legacyMarker)) candidates.push(active.legacyMarker);
315
+ const occupied = [];
250
316
  for (const markerPath of candidates) {
251
317
  try {
252
318
  const previous = JSON.parse(readFileSync(markerPath, 'utf8'));
253
- const liveness = pidLiveness(previous?.pid);
254
- let vanished = liveness === 'dead';
255
- if (liveness === 'occupied') {
256
- const identityMatch = sameProcessIdentity(
257
- previous.processIdentity,
258
- processIdentityForPid(previous.pid),
259
- );
260
- vanished = identityMatch === false;
319
+ if (previous?.pid === process.pid && previous?.token !== active.token) {
320
+ if (recordPriorVanished(active, previous)) unlinkSync(markerPath);
321
+ continue;
261
322
  }
262
- if (!vanished) continue;
263
- if (recordPriorVanished(active, previous)) unlinkSync(markerPath);
323
+ const liveness = pidLiveness(previous?.pid);
324
+ if (liveness === 'occupied') occupied.push({ markerPath, previous });
325
+ else if (liveness === 'dead' && recordPriorVanished(active, previous)) unlinkSync(markerPath);
326
+ } catch {}
327
+ }
328
+ const identities = processIdentitiesForPids(occupied.map(({ previous }) => previous.pid));
329
+ for (const { markerPath, previous } of occupied) {
330
+ try {
331
+ const identityMatch = sameProcessIdentity(
332
+ previous.processIdentity,
333
+ identities.get(previous.pid) || null,
334
+ );
335
+ if (identityMatch === false && recordPriorVanished(active, previous)) unlinkSync(markerPath);
264
336
  } catch {}
265
337
  }
266
338
  }
@@ -307,6 +379,7 @@ export function beginProcessLifecycle({
307
379
  reapVanishedMarkers(active);
308
380
  writeMarker(active);
309
381
  recordCurrent('process-start');
382
+ scheduleProcessIdentityUpgrade(active);
310
383
  if (configureReports && safeCommandLine) {
311
384
  try { unlinkSync(`${active.report}.1`); } catch {}
312
385
  try { renameSync(active.report, `${active.report}.1`); } catch {}
@@ -57,6 +57,7 @@ export function installProcessSignalCleanup({
57
57
  beforeCleanup,
58
58
  cleanup,
59
59
  afterCleanup,
60
+ restoreTerminal,
60
61
  log = writeStderr,
61
62
  } = {}) {
62
63
  let installed = true;
@@ -71,6 +72,7 @@ export function installProcessSignalCleanup({
71
72
  };
72
73
 
73
74
  const hardExit = (code) => {
75
+ try { restoreTerminal?.('forced-cleanup', { code }); } catch {}
74
76
  finishProcessLifecycle('forced-cleanup', code);
75
77
  try { process.exit(code); } catch {}
76
78
  };
@@ -148,9 +150,13 @@ export function installProcessSignalCleanup({
148
150
 
149
151
  if (fatal) {
150
152
  add('uncaughtException', (error) => {
153
+ // Run terminal restoration synchronously, before the async cleanup path
154
+ // or its hard-exit fallback can be interrupted by another fatal error.
155
+ try { restoreTerminal?.('uncaughtException', { code: 1, error }); } catch {}
151
156
  void run('uncaughtException', { code: 1, shouldExit: exit, error });
152
157
  });
153
158
  add('unhandledRejection', (error) => {
159
+ try { restoreTerminal?.('unhandledRejection', { code: 1, error }); } catch {}
154
160
  void run('unhandledRejection', { code: 1, shouldExit: exit, error });
155
161
  });
156
162
  }
@@ -1,5 +1,6 @@
1
1
  import { freemem, totalmem } from 'node:os';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { requestMemoryPressureSnapshot } from './memory-snapshot.mjs';
3
4
 
4
5
  const MB = 1024 * 1024;
5
6
 
@@ -100,16 +101,20 @@ export class ResourceAdmissionController {
100
101
  const rssMb = Number(sample.rssBytes) / MB;
101
102
  const freeMb = Number(sample.freeMemoryBytes) / MB;
102
103
  if (this.limits.maxRssMb > 0 && Number.isFinite(rssMb) && rssMb >= this.limits.maxRssMb) {
103
- return new ResourcePressureError(
104
+ const error = new ResourcePressureError(
104
105
  `Mixdog RSS ${Math.ceil(rssMb)} MB reached ${this.limits.maxRssMb} MB limit; retry after memory recovers`,
105
106
  { kind, rssMb, limitMb: this.limits.maxRssMb, metric: 'rss' },
106
107
  );
108
+ requestMemoryPressureSnapshot(error.message);
109
+ return error;
107
110
  }
108
111
  if (this.limits.minFreeMemoryMb > 0 && Number.isFinite(freeMb) && freeMb < this.limits.minFreeMemoryMb) {
109
- return new ResourcePressureError(
112
+ const error = new ResourcePressureError(
110
113
  `host free memory ${Math.floor(freeMb)} MB is below ${this.limits.minFreeMemoryMb} MB minimum; retry after memory recovers`,
111
114
  { kind, freeMb, limitMb: this.limits.minFreeMemoryMb, metric: 'free-memory' },
112
115
  );
116
+ requestMemoryPressureSnapshot(error.message);
117
+ return error;
113
118
  }
114
119
  return null;
115
120
  }
@@ -2,12 +2,12 @@ import {
2
2
  channelSetup,
3
3
  deleteSchedule,
4
4
  deleteWebhook,
5
- setChannel,
5
+ setChannelAsync,
6
6
  saveSchedule,
7
7
  saveWebhook,
8
8
  setScheduleEnabled,
9
9
  setWebhookEnabled,
10
- setWebhookConfig,
10
+ setWebhookConfigAsync,
11
11
  } from '../standalone/channel-admin.mjs';
12
12
 
13
13
  // Channel/webhook/schedule config surface. Extracted verbatim from the runtime
@@ -20,19 +20,19 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
20
20
  // Flush a pending debounced backend switch first so setup readers
21
21
  // (Settings → Channel Setting, remote toggles) never observe the
22
22
  // previous backend during the 150ms debounce window.
23
- try { flushBackendSave(); } catch {}
23
+ try { await flushBackendSave(); } catch {}
24
24
  return channelSetup();
25
25
  },
26
26
  getChannelWorkerStatus() {
27
27
  return channels.status();
28
28
  },
29
- setChannel(entry) {
30
- const result = setChannel(entry);
29
+ async setChannel(entry) {
30
+ const result = await setChannelAsync(entry);
31
31
  reloadChannelsSoon();
32
32
  return result;
33
33
  },
34
- setWebhookConfig(patch) {
35
- const result = setWebhookConfig(patch);
34
+ async setWebhookConfig(patch) {
35
+ const result = await setWebhookConfigAsync(patch);
36
36
  reloadChannelsSoon();
37
37
  return result;
38
38
  },
@@ -30,7 +30,6 @@ export function createConfigLifecycle({
30
30
  // shared modules / helpers
31
31
  cfgMod,
32
32
  sharedCfgMod,
33
- setBackend,
34
33
  setBackendAsync,
35
34
  setConfiguredShell,
36
35
  normalizeSystemShellConfig,
@@ -218,21 +217,6 @@ export function createConfigLifecycle({
218
217
  return p;
219
218
  }
220
219
 
221
- function flushBackendSave() {
222
- if (backendSaveTimer) {
223
- clearTimeout(backendSaveTimer);
224
- backendSaveTimer = null;
225
- }
226
- if (pendingBackendName === null) return;
227
- const name = pendingBackendName;
228
- pendingBackendName = null;
229
- try {
230
- setBackend(name);
231
- } catch (err) {
232
- process.stderr.write(`[channels] debounced setBackend failed: ${err?.message || err}\n`);
233
- }
234
- }
235
-
236
220
  function scheduleBackendSave(name) {
237
221
  pendingBackendName = name;
238
222
  if (backendSaveTimer) clearTimeout(backendSaveTimer);
@@ -335,6 +319,21 @@ export function createConfigLifecycle({
335
319
  return p;
336
320
  }
337
321
 
322
+ // Teardown barrier for every in-process writer that can hold the shared
323
+ // mixdog-config lock. Start/drain all debounce channels through their async
324
+ // variants, then resolve only when every promise tail (including skills,
325
+ // which config flushes after its whole-section write) has settled.
326
+ async function flushAllConfigSavesAsync() {
327
+ await Promise.all([
328
+ flushConfigSaveAsync(),
329
+ flushBackendSaveAsync(),
330
+ flushOutputStyleSaveAsync(),
331
+ ]);
332
+ // The shared config layer also tracks writes started directly by channel,
333
+ // webhook, voice, Discord access, and future async RMW callers.
334
+ await sharedCfgMod.pendingConfigWrites();
335
+ }
336
+
338
337
  function flushOutputStyleSave() {
339
338
  if (outputStyleSaveTimer) {
340
339
  clearTimeout(outputStyleSaveTimer);
@@ -418,12 +417,16 @@ export function createConfigLifecycle({
418
417
  adoptConfig,
419
418
  saveConfigAndAdopt,
420
419
  flushConfigSave,
421
- flushBackendSave,
420
+ // Every lifecycle flush uses the async lock path. A synchronous waiter on
421
+ // the same lock would block the event loop needed by an in-flight async
422
+ // writer, so callers must await this before a dependent read/start.
423
+ flushBackendSave: flushBackendSaveAsync,
422
424
  scheduleBackendSave,
423
425
  flushSkillsSave,
424
426
  scheduleSkillsSave,
425
427
  flushOutputStyleSave,
426
428
  scheduleOutputStyleSave,
429
+ flushAllConfigSavesAsync,
427
430
  // reload / ensure
428
431
  reloadFullConfig,
429
432
  ensureFullConfig,
@@ -96,7 +96,7 @@ export function createCwdPlugins({
96
96
  return next;
97
97
  }
98
98
 
99
- function applyResolvedCwd(nextCwd, { markRefresh = true } = {}) {
99
+ function applyResolvedCwd(nextCwd, { markRefresh = true, waitForMcpReset = false } = {}) {
100
100
  const resolved = resolve(nextCwd);
101
101
  const stat = statSync(resolved);
102
102
  if (!stat.isDirectory()) throw new Error(`cwd: not a directory: ${resolved}`);
@@ -125,16 +125,16 @@ export function createCwdPlugins({
125
125
  const delay = getCodeGraphPrewarmDelayMs();
126
126
  scheduleCodeGraphPrewarm(changed ? 0 : delay, changed ? 'cwd-change' : 'cwd');
127
127
  }
128
- // Project-local `.mcp.json` follows the cwd: when the effective project MCP
129
- // set changes, reconnect in the background (never await this stays sync,
130
- // and the session is preserved). Guarded so a no-op cwd change does not
131
- // churn connections.
128
+ // Project-local `.mcp.json` follows the cwd. Ordinary in-session cwd changes
129
+ // reconnect in the background, while desktop context replacement requests
130
+ // an awaitable reset so the next session/turn cannot observe the old registry.
131
+ let mcpReset = null;
132
132
  if (changed) {
133
133
  try {
134
134
  const nextKey = resolved + '\u0000' + JSON.stringify(readProjectMcpServers(resolved));
135
135
  if (nextKey !== getLastProjectMcpKey()) {
136
136
  setLastProjectMcpKey(nextKey);
137
- void connectConfiguredMcp({ reset: true })
137
+ mcpReset = Promise.resolve(connectConfiguredMcp({ reset: true }))
138
138
  .then(() => invalidatePreSessionToolSurface())
139
139
  .catch(() => {});
140
140
  }
@@ -145,7 +145,9 @@ export function createCwdPlugins({
145
145
  if (changed) {
146
146
  try { void hooks.dispatch('CwdChanged', hookCommonPayload({ cwd: currentCwd })); } catch {}
147
147
  }
148
- return currentCwd;
148
+ return waitForMcpReset
149
+ ? Promise.resolve(mcpReset).then(() => currentCwd)
150
+ : currentCwd;
149
151
  }
150
152
 
151
153
  async function refreshSessionForCwdIfNeeded(reason = 'cwd-change') {
@@ -1,5 +1,4 @@
1
- // Environment-variable coercion helpers shared by the session runtime.
2
- // Extracted verbatim from mixdog-session-runtime.mjs during the facade split.
1
+ // Environment-variable coercion helpers shared by session-runtime modules.
3
2
  export function envFlag(name) {
4
3
  return /^(1|true|yes|on)$/i.test(String(process.env[name] || ''));
5
4
  }
@@ -0,0 +1,45 @@
1
+ import { monitorEventLoopDelay } from 'node:perf_hooks';
2
+ import { envFlag } from './env.mjs';
3
+
4
+ const HITCH_PROFILE_ENABLED = envFlag('MIXDOG_HITCH_PROFILE');
5
+
6
+ function positiveNumberEnv(name, fallback) {
7
+ const value = Number(process.env[name]);
8
+ return Number.isFinite(value) && value > 0 ? value : fallback;
9
+ }
10
+
11
+ // The histogram and sampling timer are never constructed unless explicitly
12
+ // enabled, leaving the normal runtime with no event-loop profiling work.
13
+ export function installHitchProfiler() {
14
+ if (!HITCH_PROFILE_ENABLED) return null;
15
+
16
+ const thresholdMs = positiveNumberEnv('MIXDOG_HITCH_THRESHOLD_MS', 100);
17
+ const resolutionMs = Math.max(1, Math.min(100, positiveNumberEnv('MIXDOG_HITCH_RESOLUTION_MS', 20)));
18
+ const sampleMs = Math.max(resolutionMs * 2, positiveNumberEnv('MIXDOG_HITCH_SAMPLE_MS', 100));
19
+ const histogram = monitorEventLoopDelay({ resolution: resolutionMs });
20
+ histogram.enable();
21
+ let lastSampleAt = performance.now();
22
+
23
+ const timer = setInterval(() => {
24
+ const now = performance.now();
25
+ // Histogram max is the primary signal. Timer drift covers a stall ending
26
+ // in the same timers phase before the histogram's internal sampler runs.
27
+ const delayMs = Math.max(histogram.max / 1e6, now - lastSampleAt - sampleMs);
28
+ lastSampleAt = now;
29
+ histogram.reset();
30
+ if (delayMs < thresholdMs) return;
31
+ try {
32
+ process.stderr.write(
33
+ `[mixdog-hitch] timestamp=${new Date().toISOString()} delayMs=${delayMs.toFixed(1)} thresholdMs=${thresholdMs}\n`,
34
+ );
35
+ } catch {}
36
+ }, sampleMs);
37
+ timer.unref?.();
38
+
39
+ return () => {
40
+ clearInterval(timer);
41
+ histogram.disable();
42
+ };
43
+ }
44
+
45
+ installHitchProfiler();