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
@@ -1,7 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
- import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, unlinkSync, watch as fsWatch, writeFileSync } from 'fs';
2
+ import { existsSync, readFileSync, statSync, unlinkSync, watch as fsWatch, writeFileSync } from 'fs';
3
3
  import { basename } from 'path';
4
- import { stripAnsi } from '../shell-command.mjs';
5
4
  import { scrubLoaderVars, scrubProviderSecrets } from '../env-scrub.mjs';
6
5
  import {
7
6
  normalizeToolNotifyContext,
@@ -43,313 +42,38 @@ import {
43
42
  _liveJobIdsByPid,
44
43
  _killLiveJobPid,
45
44
  } from './shell-job-process.mjs';
45
+ import {
46
+ attachJobInsights,
47
+ looksLikeInteractivePrompt,
48
+ readPromptTail,
49
+ shellJobOutputBytes,
50
+ shellJobPublicTaskResult,
51
+ SHELL_JOB_OUTPUT_DISK_CAP,
52
+ } from './lib/shell-job-insights.mjs';
53
+ import {
54
+ sleep,
55
+ awaitSpawnReady,
56
+ adoptSpawnErrorHandler,
57
+ discardSpawnErrorGuard,
58
+ rollbackSpawnedChild,
59
+ shellQuoteSingle,
60
+ psSingleQuote,
61
+ isPowerShellShell,
62
+ } from './lib/shell-spawn-helpers.mjs';
46
63
 
47
64
  // Facade re-exports: path/detail helpers and the job-not-found message moved
48
65
  // to sibling modules; keep existing importers of shell-jobs.mjs resolving.
49
66
  export { buildJobNotFoundMessage } from './shell-job-paths.mjs';
67
+ export { shellJobPublicTaskResult } from './lib/shell-job-insights.mjs';
50
68
 
51
69
  globalThis.__mixdogShellJobsRuntimeLoaded = true;
52
70
 
53
- function sleep(ms) {
54
- return new Promise((resolve) => setTimeout(resolve, ms));
55
- }
56
-
57
- const SPAWN_ERROR_GUARD = Symbol('mixdog.spawnErrorGuard');
58
- function awaitSpawnReady(child, label) {
59
- return new Promise((resolve, reject) => {
60
- if (!child || typeof child.once !== 'function') {
61
- reject(new Error(`${label} spawn returned no child process`));
62
- return;
63
- }
64
- let spawned = false;
65
- let bufferedError = null;
66
- const cleanup = () => child.removeListener('spawn', onSpawn);
67
- const onError = (error) => {
68
- if (spawned) {
69
- bufferedError = bufferedError || error;
70
- return;
71
- }
72
- cleanup();
73
- child.removeListener('error', onError);
74
- reject(error);
75
- };
76
- const onSpawn = () => {
77
- cleanup();
78
- spawned = true;
79
- const pid = Number(child.pid);
80
- if (!Number.isFinite(pid) || pid <= 0) {
81
- child.removeListener('error', onError);
82
- reject(new Error(`${label} spawn returned no pid`));
83
- return;
84
- }
85
- child[SPAWN_ERROR_GUARD] = {
86
- adopt(handler) {
87
- child.on('error', handler);
88
- child.removeListener('error', onError);
89
- if (bufferedError) handler(bufferedError);
90
- delete child[SPAWN_ERROR_GUARD];
91
- },
92
- discard() {
93
- child.removeListener('error', onError);
94
- delete child[SPAWN_ERROR_GUARD];
95
- },
96
- };
97
- resolve(child);
98
- };
99
- child.once('error', onError);
100
- child.once('spawn', onSpawn);
101
- });
102
- }
103
-
104
- function adoptSpawnErrorHandler(child, handler) {
105
- const guard = child?.[SPAWN_ERROR_GUARD];
106
- if (guard) guard.adopt(handler);
107
- else child?.on?.('error', handler);
108
- }
109
71
 
110
- async function rollbackSpawnedChild(child, { timeoutMs = 5000 } = {}) {
111
- if (!child || child.exitCode != null || child.signalCode != null) {
112
- return { confirmed: true, errors: [] };
113
- }
114
- const errors = [];
115
- let timer = null;
116
- let settled = false;
117
- const outcome = await new Promise((resolve) => {
118
- const finish = (confirmed) => {
119
- if (settled) return;
120
- settled = true;
121
- if (timer) clearTimeout(timer);
122
- child.removeListener('exit', onExit);
123
- child.removeListener('close', onExit);
124
- child.removeListener('error', onError);
125
- resolve({ confirmed, errors });
126
- };
127
- const onExit = () => finish(true);
128
- const onError = (error) => { errors.push(error); };
129
- child.on('error', onError);
130
- child.once('exit', onExit);
131
- child.once('close', onExit);
132
- timer = setTimeout(() => finish(false), Math.max(1, Number(timeoutMs) || 5000));
133
- try { killProcessTree(child.pid, 'SIGKILL'); } catch (error) { errors.push(error); }
134
- try { child.kill?.('SIGKILL'); } catch (error) { errors.push(error); }
135
- });
136
- return outcome;
137
- }
138
-
139
- const JOB_STATUS_PREVIEW_MAX_BYTES = 4096;
140
- const JOB_STATUS_PREVIEW_MAX_LINES = 20;
141
- const JOB_STATUS_PREVIEW_MAX_CHARS = 1200;
142
- // Hard ceiling on a background job's on-disk stdout+stderr. Mirrors the
143
- // foreground SHELL_OUTPUT_DISK_CAP (shell-command.mjs) so a runaway
144
- // background loop is killed and flagged instead of filling the filesystem.
145
- const SHELL_JOB_OUTPUT_DISK_CAP = 100 * 1024 * 1024;
146
72
  // Poll cadence for the adopted-job output-cap self-tick (mirrors the
147
73
  // foreground sizeWatchdog in shell-command.mjs).
148
74
  const ADOPTED_JOB_CAP_POLL_MS = 1_000;
149
75
 
150
- // Combined byte size of a job's spilled stdout/stderr files, or 0 if
151
- // unreadable. mergeStderr collapses both onto stdoutPath, so count it once.
152
- function shellJobOutputBytes(detail) {
153
- let total = 0;
154
- const seen = new Set();
155
- for (const p of [detail?.stdoutPath, detail?.stderrPath]) {
156
- if (!p || seen.has(p)) continue;
157
- seen.add(p);
158
- try {
159
- if (existsSync(p)) total += statSync(p).size;
160
- } catch { /* ignore */ }
161
- }
162
- return total;
163
- }
164
-
165
- function shellQuoteSingle(s) {
166
- return `'${String(s).replace(/'/g, `'\"'\"'`)}'`;
167
- }
168
-
169
- function psSingleQuote(s) {
170
- return `'${String(s).replace(/'/g, "''")}'`;
171
- }
172
-
173
- function isPowerShellShell(shell, shellType) {
174
- if (shellType === 'powershell') return true;
175
- const stem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
176
- return stem === 'pwsh' || stem === 'powershell';
177
- }
178
-
179
- function readTailPreviewSync(filePath, { maxBytes = JOB_STATUS_PREVIEW_MAX_BYTES, maxLines = JOB_STATUS_PREVIEW_MAX_LINES, maxChars = JOB_STATUS_PREVIEW_MAX_CHARS } = {}) {
180
- try {
181
- if (!filePath || !existsSync(filePath)) return null;
182
- const st = statSync(filePath);
183
- if (!st.isFile()) return null;
184
- const size = st.size;
185
- if (size <= 0) return { bytes: 0, preview: '' };
186
- const readBytes = Math.min(size, maxBytes);
187
- const fd = openSync(filePath, 'r');
188
- try {
189
- const buf = Buffer.alloc(readBytes);
190
- readSync(fd, buf, 0, readBytes, size - readBytes);
191
- let text = buf.toString('utf8');
192
- if (size > readBytes) {
193
- const nl = text.indexOf('\n');
194
- if (nl !== -1) text = text.slice(nl + 1);
195
- }
196
- let lines = text.split(/\r?\n/);
197
- if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
198
- let truncated = size > readBytes;
199
- if (lines.length > maxLines) {
200
- lines = lines.slice(-maxLines);
201
- truncated = true;
202
- }
203
- let preview = lines.join('\n');
204
- if (preview.length > maxChars) {
205
- preview = preview.slice(preview.length - maxChars);
206
- const nl = preview.indexOf('\n');
207
- if (nl !== -1) preview = preview.slice(nl + 1);
208
- truncated = true;
209
- }
210
- return {
211
- bytes: size,
212
- preview,
213
- truncated,
214
- };
215
- } finally {
216
- try { closeSync(fd); } catch { /* ignore */ }
217
- }
218
- } catch {
219
- return null;
220
- }
221
- }
222
-
223
- function attachJobPreview(detail) {
224
- if (!detail || typeof detail !== 'object') return detail;
225
- const withPreview = { ...detail };
226
- const stdoutInfo = readTailPreviewSync(detail.stdoutPath);
227
- if (stdoutInfo) {
228
- withPreview.stdoutBytes = stdoutInfo.bytes;
229
- if (stdoutInfo.preview) withPreview.stdoutPreview = stdoutInfo.preview;
230
- if (stdoutInfo.truncated) withPreview.stdoutPreviewTruncated = true;
231
- }
232
- if (detail.mergeStderr !== true) {
233
- const stderrInfo = readTailPreviewSync(detail.stderrPath);
234
- if (stderrInfo) {
235
- withPreview.stderrBytes = stderrInfo.bytes;
236
- if (stderrInfo.preview) withPreview.stderrPreview = stderrInfo.preview;
237
- if (stderrInfo.truncated) withPreview.stderrPreviewTruncated = true;
238
- }
239
- }
240
- return withPreview;
241
- }
242
-
243
- function summarizeJobPreviewText(text, maxChars = 160) {
244
- if (typeof text !== 'string' || !text.trim()) return '';
245
- const lines = text
246
- .split(/\r?\n/)
247
- .map((line) => stripAnsi(line).replace(/\s+/g, ' ').trim())
248
- .filter(Boolean);
249
- if (lines.length === 0) return '';
250
- let summary = lines[lines.length - 1];
251
- if (summary.length > maxChars) summary = `${summary.slice(0, maxChars - 1)}…`;
252
- return summary;
253
- }
254
-
255
76
  const SHELL_JOB_PROMPT_STALL_MS = 45_000;
256
- const SHELL_JOB_PROMPT_TAIL_BYTES = 1024;
257
- const SHELL_JOB_PROMPT_TAIL_LINES = 16;
258
- const SHELL_JOB_PROMPT_TAIL_CHARS = 1024;
259
- const SHELL_JOB_PROMPT_PATTERNS = [
260
- /\((?:y|yes)\/(?:n|no)\)\s*[:?]?\s*$/i,
261
- /\[(?:y|yes)\/(?:n|no)\]\s*[:?]?\s*$/i,
262
- /\b(?:continue|proceed|confirm|overwrite|replace)\b[^\n]*[?:]\s*$/i,
263
- /\bpress\s+(?:enter|return)\b[^\n]*$/i,
264
- /\bdo you (?:want|wish|agree|accept)\b[^\n]*\?\s*$/i,
265
- /\b(?:password|passphrase|otp|verification code)\b[^\n]*[:?]\s*$/i,
266
- ];
267
-
268
- function looksLikeInteractivePrompt(text) {
269
- const tail = stripAnsi(String(text || '')).trim();
270
- if (!tail) return false;
271
- const last = tail.split(/\r?\n/).slice(-4).join('\n').trim();
272
- return SHELL_JOB_PROMPT_PATTERNS.some((pattern) => pattern.test(last));
273
- }
274
-
275
- function readPromptTail(detail) {
276
- if (!detail || typeof detail !== 'object') return { bytes: 0, text: '' };
277
- const stdoutInfo = readTailPreviewSync(detail.stdoutPath, {
278
- maxBytes: SHELL_JOB_PROMPT_TAIL_BYTES,
279
- maxLines: SHELL_JOB_PROMPT_TAIL_LINES,
280
- maxChars: SHELL_JOB_PROMPT_TAIL_CHARS,
281
- });
282
- const stderrInfo = detail.mergeStderr === true ? null : readTailPreviewSync(detail.stderrPath, {
283
- maxBytes: SHELL_JOB_PROMPT_TAIL_BYTES,
284
- maxLines: SHELL_JOB_PROMPT_TAIL_LINES,
285
- maxChars: SHELL_JOB_PROMPT_TAIL_CHARS,
286
- });
287
- const bytes = (stdoutInfo?.bytes || 0) + (stderrInfo?.bytes || 0);
288
- const parts = [
289
- stdoutInfo?.preview ? `[stdout tail]\n${stdoutInfo.preview}` : '',
290
- stderrInfo?.preview ? `[stderr tail]\n${stderrInfo.preview}` : '',
291
- ].filter(Boolean);
292
- return { bytes, text: parts.join('\n\n') };
293
- }
294
-
295
- function attachJobInsights(detail) {
296
- const withPreview = attachJobPreview(detail);
297
- if (!withPreview || typeof withPreview !== 'object') return withPreview;
298
- let summary = '';
299
- let summarySource = '';
300
- if (withPreview.status === 'completed') {
301
- summary = summarizeJobPreviewText(withPreview.stdoutPreview)
302
- || summarizeJobPreviewText(withPreview.stderrPreview);
303
- summarySource = summary ? (withPreview.stdoutPreview ? 'stdout' : 'stderr') : '';
304
- } else if (withPreview.status === 'failed') {
305
- summary = summarizeJobPreviewText(withPreview.stderrPreview)
306
- || summarizeJobPreviewText(withPreview.stdoutPreview)
307
- || String(withPreview.error || '').trim();
308
- summarySource = summary ? (withPreview.stderrPreview ? 'stderr' : (withPreview.stdoutPreview ? 'stdout' : 'status')) : '';
309
- } else if (withPreview.status === 'cancelled') {
310
- summary = 'cancelled before completion';
311
- summarySource = 'status';
312
- } else if (withPreview.status === 'running') {
313
- summary = summarizeJobPreviewText(withPreview.stdoutPreview)
314
- || summarizeJobPreviewText(withPreview.stderrPreview);
315
- summarySource = summary ? (withPreview.stdoutPreview ? 'stdout' : 'stderr') : '';
316
- }
317
- if (summary) {
318
- withPreview.summary = summary;
319
- withPreview.summarySource = summarySource;
320
- }
321
- return withPreview;
322
- }
323
-
324
- export function shellJobPublicTaskResult(detail) {
325
- if (!detail || typeof detail !== 'object') return detail;
326
- const result = {
327
- task_id: detail.jobId || detail.task_id || null,
328
- shell: detail.shellType || null,
329
- status: detail.status || null,
330
- cwd: detail.cwd || null,
331
- pid: detail.pid || null,
332
- exit_code: (typeof detail.exitCode === 'number') ? detail.exitCode : null,
333
- signal: detail.signal || null,
334
- timed_out: detail.timedOut === true ? true : null,
335
- killed: detail.killed === true ? true : null,
336
- stdout_bytes: (typeof detail.stdoutBytes === 'number') ? detail.stdoutBytes : null,
337
- stderr_bytes: (typeof detail.stderrBytes === 'number') ? detail.stderrBytes : null,
338
- stdout_preview: detail.stdoutPreview || null,
339
- stderr_preview: detail.stderrPreview || null,
340
- summary: detail.summary || null,
341
- summary_source: detail.summarySource || null,
342
- waited_ms: (typeof detail.waitedMs === 'number') ? detail.waitedMs : null,
343
- wait_timed_out: detail.waitTimedOut === true ? true : null,
344
- started_at: detail.startedAt || null,
345
- finished_at: detail.finishedAt || null,
346
- error: detail.error || null,
347
- };
348
- for (const [key, value] of Object.entries(result)) {
349
- if (value == null || value === '') delete result[key];
350
- }
351
- return result;
352
- }
353
77
 
354
78
  export async function waitForShellJob(jobId, { timeoutMs = 30_000, pollMs = 250 } = {}) {
355
79
  const started = Date.now();
@@ -1184,7 +908,7 @@ async function _startBackgroundShellJobImpl({
1184
908
  terminal = true;
1185
909
  const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1186
910
  child.removeListener('error', onRuntimeError);
1187
- child[SPAWN_ERROR_GUARD]?.discard?.();
911
+ discardSpawnErrorGuard(child);
1188
912
  try { unlinkSync(wrappedTempPath); } catch {}
1189
913
  const failure = { jobId, kind: 'bash', status: 'failed', error: `failed to persist shell background task: ${e?.message || e}` };
1190
914
  if (!rollback.confirmed) {
@@ -1425,7 +1149,7 @@ async function startBackgroundPowerShellJob({
1425
1149
  terminal = true;
1426
1150
  const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1427
1151
  child.removeListener('error', onRuntimeError);
1428
- child[SPAWN_ERROR_GUARD]?.discard?.();
1152
+ discardSpawnErrorGuard(child);
1429
1153
  try { unlinkSync(wrappedTempPath); } catch {}
1430
1154
  try { unlinkSync(innerTempPath); } catch {}
1431
1155
  const failure = { jobId, kind: 'bash', status: 'failed', error: `failed to persist PowerShell background task: ${e?.message || e}` };
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ let activeShellSpawns = 0;
4
+
5
+ function isPowerShellSpawn(shell, shellArg) {
6
+ return /pwsh|powershell/i.test(String(shell || '')) || shellArg === '-Command';
7
+ }
8
+
9
+ export async function spawnShellWithRetry({ shell, argv, spawnOptions, shellArg, cwd }) {
10
+ const delays = [100, 300, 700];
11
+ const isPowerShell = isPowerShellSpawn(shell, shellArg);
12
+ activeShellSpawns++;
13
+ try {
14
+ let attempt = 0;
15
+ for (;;) {
16
+ try {
17
+ const child = spawn(shell, argv, spawnOptions);
18
+ let bufferedError = null;
19
+ const guardError = (err) => { bufferedError = bufferedError || err; };
20
+ child.on('error', guardError);
21
+ try {
22
+ await new Promise((resolveSpawn, rejectSpawn) => {
23
+ const onSpawn = () => {
24
+ child.removeListener('error', onError);
25
+ resolveSpawn();
26
+ };
27
+ const onError = (err) => {
28
+ child.removeListener('spawn', onSpawn);
29
+ rejectSpawn(err);
30
+ };
31
+ child.once('spawn', onSpawn);
32
+ child.once('error', onError);
33
+ });
34
+ } catch (err) {
35
+ child.removeListener('error', guardError);
36
+ throw err;
37
+ }
38
+ return {
39
+ child,
40
+ adoptErrorHandler(handler) {
41
+ child.on('error', handler);
42
+ child.removeListener('error', guardError);
43
+ if (bufferedError) handler(bufferedError);
44
+ },
45
+ };
46
+ } catch (err) {
47
+ try {
48
+ console.error('[shell-spawn-retry] ' + JSON.stringify({
49
+ code: (err && err.code) || null,
50
+ syscall: (err && err.syscall) || null,
51
+ shell,
52
+ cwd,
53
+ activeSpawnCount: activeShellSpawns,
54
+ }));
55
+ } catch { /* logging must never mask the spawn error */ }
56
+ const canRetry = err && err.code === 'EPERM'
57
+ && process.platform === 'win32'
58
+ && isPowerShell
59
+ && attempt < delays.length;
60
+ if (!canRetry) throw err;
61
+ await new Promise((r) => setTimeout(r, delays[attempt++]));
62
+ }
63
+ }
64
+ } finally {
65
+ activeShellSpawns--;
66
+ }
67
+ }
@@ -44,6 +44,7 @@ import {
44
44
  _maybeEncodePowerShellCommand,
45
45
  extractPowerShellCommandInner,
46
46
  } from './shell-powershell.mjs';
47
+ import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
47
48
 
48
49
  export {
49
50
  _maybeEncodePowerShellCommand,
@@ -447,12 +448,6 @@ async function _execPolicyBlockMessage(command) {
447
448
  // EPERM backoff). Logged with each failed spawn so a Defender-induced storm
448
449
  // is reconstructable: activeSpawnCount > 1 means concurrent spawns were
449
450
  // racing the AV scan when the failure hit.
450
- let _activeShellSpawns = 0;
451
-
452
- function _isPowerShellSpawn(shell, shellArg) {
453
- return /pwsh|powershell/i.test(String(shell || '')) || shellArg === '-Command';
454
- }
455
-
456
451
  // Windows Defender intermittently fails node→PowerShell spawns with EPERM
457
452
  // while it scans the child image (see shell-runtime.mjs Trojan false-positive
458
453
  // note). The failure is at spawn() time — before any stdio/side effect — so a
@@ -460,71 +455,6 @@ function _isPowerShellSpawn(shell, shellArg) {
460
455
  // Retry ONLY on EPERM/win32/powershell; everything else throws on first
461
456
  // failure. Backoff 100/300/700ms caps added latency at ~1.1s. Every failed
462
457
  // attempt logs one diagnostic line for later reconstruction.
463
- async function _spawnShellWithRetry({ shell, argv, spawnOptions, shellArg, cwd }) {
464
- const _delays = [100, 300, 700];
465
- const _isPowerShell = _isPowerShellSpawn(shell, shellArg);
466
- _activeShellSpawns++;
467
- try {
468
- let attempt = 0;
469
- for (;;) {
470
- try {
471
- const child = spawn(shell, argv, spawnOptions);
472
- // spawn() reports ENOENT/EACCES on nextTick. Keep a guard listener
473
- // attached from the same synchronous frame as spawn(), and do not
474
- // resolve this helper until the child emits `spawn`. The caller adopts
475
- // the guard atomically (new listener first, guard removal second), so
476
- // there is never an unhandled-error window across either await.
477
- let bufferedError = null;
478
- const guardError = (err) => { bufferedError = bufferedError || err; };
479
- child.on('error', guardError);
480
- try {
481
- await new Promise((resolveSpawn, rejectSpawn) => {
482
- const onSpawn = () => {
483
- child.removeListener('error', onError);
484
- resolveSpawn();
485
- };
486
- const onError = (err) => {
487
- child.removeListener('spawn', onSpawn);
488
- rejectSpawn(err);
489
- };
490
- child.once('spawn', onSpawn);
491
- child.once('error', onError);
492
- });
493
- } catch (err) {
494
- child.removeListener('error', guardError);
495
- throw err;
496
- }
497
- return {
498
- child,
499
- adoptErrorHandler(handler) {
500
- child.on('error', handler);
501
- child.removeListener('error', guardError);
502
- if (bufferedError) handler(bufferedError);
503
- },
504
- };
505
- } catch (err) {
506
- try {
507
- console.error('[shell-spawn-retry] ' + JSON.stringify({
508
- code: (err && err.code) || null,
509
- syscall: (err && err.syscall) || null,
510
- shell,
511
- cwd,
512
- activeSpawnCount: _activeShellSpawns,
513
- }));
514
- } catch { /* logging must never mask the spawn error */ }
515
- const _canRetry = err && err.code === 'EPERM'
516
- && process.platform === 'win32'
517
- && _isPowerShell
518
- && attempt < _delays.length;
519
- if (!_canRetry) throw err;
520
- await new Promise((r) => setTimeout(r, _delays[attempt++]));
521
- }
522
- }
523
- } finally {
524
- _activeShellSpawns--;
525
- }
526
- }
527
-
528
458
  export function execShellCommand({
529
459
  shell,
530
460
  shellArg,
@@ -31,7 +31,7 @@ async function connectInner(self) {
31
31
  self.client = null;
32
32
  throw err;
33
33
  }
34
- self.persistAccessFromMainChannel();
34
+ await self.persistAccessFromMainChannel();
35
35
  }
36
36
 
37
37
  async function buildClient(self) {
@@ -18,7 +18,7 @@ import { join, sep } from "path";
18
18
  import { createHash } from "crypto";
19
19
  import { chunk, formatForDiscord, MAX_DISCORD_MESSAGE } from "../lib/format.mjs";
20
20
  import { withConfigLock } from "../lib/config-lock.mjs";
21
- import { readSection, updateSection } from "../../shared/config.mjs";
21
+ import { readSection, updateSectionAsync } from "../../shared/config.mjs";
22
22
  import { normalizeAccess, safeAttName } from "./discord-access.mjs";
23
23
  import { MAX_ATTACHMENT_BYTES, downloadSingleAttachment } from "./discord-attachments.mjs";
24
24
  import * as gateway from "./discord-gateway.mjs";
@@ -473,7 +473,7 @@ class DiscordBackend {
473
473
  return this.initialAccess;
474
474
  }
475
475
  }
476
- persistAccessFromMainChannel() {
476
+ async persistAccessFromMainChannel() {
477
477
  if (this.isStatic || !this.configFile) return;
478
478
  try {
479
479
  const id = this.mainChannelId;
@@ -482,7 +482,7 @@ class DiscordBackend {
482
482
  const access = normalizeAccess(parsed.access);
483
483
  if (!(id in access.channels)) {
484
484
  access.channels[id] = { requireMention: false, allowFrom: [] };
485
- this.saveAccess(access);
485
+ await this.saveAccess(access);
486
486
  }
487
487
  } catch (err) {
488
488
  process.stderr.write(`mixdog discord: persistAccessFromMainChannel failed: ${err}\n`);
@@ -499,10 +499,10 @@ class DiscordBackend {
499
499
  }
500
500
  return a;
501
501
  }
502
- saveAccess(a) {
502
+ async saveAccess(a) {
503
503
  if (this.isStatic) return;
504
504
  if (!this.configFile) return;
505
- return withConfigLock(() => {
505
+ return withConfigLock(async () => {
506
506
  mkdirSync(this.stateDir, { recursive: true, mode: 448 });
507
507
  const access = {
508
508
  dmPolicy: a.dmPolicy,
@@ -513,7 +513,7 @@ class DiscordBackend {
513
513
  ...a.replyToMode ? { replyToMode: a.replyToMode } : {},
514
514
  ...a.textChunkLimit ? { textChunkLimit: a.textChunkLimit } : {}
515
515
  };
516
- updateSection("channels", (channels) => ({
516
+ await updateSectionAsync("channels", (channels) => ({
517
517
  ...channels,
518
518
  access
519
519
  }));
@@ -2,7 +2,16 @@ import { readFileSync, mkdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { DiscordBackend } from "../backends/discord.mjs";
4
4
  import { TelegramBackend } from "../backends/telegram.mjs";
5
- import { readSection, updateSection, CONFIG_PATH as MIXDOG_CONFIG_PATH, getDiscordToken, getTelegramToken, diagnoseDiscordTokenValue } from "../../shared/config.mjs";
5
+ import {
6
+ readSection,
7
+ updateSection,
8
+ CONFIG_PATH as MIXDOG_CONFIG_PATH,
9
+ SECRET_ACCOUNTS,
10
+ getDiscordToken,
11
+ getTelegramToken,
12
+ diagnoseDiscordTokenValue,
13
+ invalidateSecretReadCache,
14
+ } from "../../shared/config.mjs";
6
15
  import { listSchedules } from "../../shared/schedules-db.mjs";
7
16
  import { resolvePluginData } from "../../shared/plugin-paths.mjs";
8
17
  const DATA_DIR = resolvePluginData();
@@ -46,8 +55,12 @@ function resolveChannelId(raw = {}, backend = "discord") {
46
55
  return "";
47
56
  }
48
57
 
49
- async function loadConfig() {
58
+ async function loadConfig({ freshSecrets = false } = {}) {
50
59
  try {
60
+ if (freshSecrets) {
61
+ invalidateSecretReadCache(SECRET_ACCOUNTS.discordToken);
62
+ invalidateSecretReadCache(SECRET_ACCOUNTS.telegramToken);
63
+ }
51
64
  let raw = readSection("channels");
52
65
  raw = raw && typeof raw === "object" ? raw : {};
53
66
  // Schedules are the PG `scheduler.schedules` table (single source of
@@ -218,6 +218,16 @@ function atomicWrite(targetPath, contents) {
218
218
  // site, so no drain/replay format or TTL semantics change.
219
219
  const _dedupeIndex = new Map()
220
220
  let _dedupeIndexSeeded = false
221
+ function dropDedupeIndexForPath(bufferPath) {
222
+ for (const [key, indexedPath] of _dedupeIndex) {
223
+ if (indexedPath === bufferPath) _dedupeIndex.delete(key)
224
+ }
225
+ }
226
+ function replaceDedupeIndexPath(previousPath, nextPath) {
227
+ for (const [key, indexedPath] of _dedupeIndex) {
228
+ if (indexedPath === previousPath) _dedupeIndex.set(key, nextPath)
229
+ }
230
+ }
221
231
  function seedDedupeIndex(kind) {
222
232
  if (_dedupeIndexSeeded) return
223
233
  _dedupeIndexSeeded = true
@@ -266,9 +276,11 @@ function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
266
276
  // leaves the file in place and returns false so the caller skips it this pass.
267
277
  function moveToDead(name, reason) {
268
278
  process.stderr.write(`[memory-client] quarantining ${name} to dead/ (${reason})\n`)
279
+ const bufferPath = path.join(BUFFER_DIR, name)
269
280
  try {
270
281
  fs.mkdirSync(DEAD_DIR, { recursive: true })
271
- fs.renameSync(path.join(BUFFER_DIR, name), path.join(DEAD_DIR, name))
282
+ fs.renameSync(bufferPath, path.join(DEAD_DIR, name))
283
+ dropDedupeIndexForPath(bufferPath)
272
284
  return true
273
285
  } catch (e) {
274
286
  process.stderr.write(`[memory-client] quarantine of ${name} failed (${e.message}) — leaving in place\n`)
@@ -355,7 +367,10 @@ export async function drainBuffer() {
355
367
  // throwOnError: non-2xx status or an {error} body REJECTS, so the
356
368
  // file is kept/aged for retry instead of unlinked (HIGH: data loss).
357
369
  await memoryFetch('POST', endpoint, payload, 10_000, { throwOnError: true })
358
- try { fs.unlinkSync(bufferPath) } catch {}
370
+ try {
371
+ fs.unlinkSync(bufferPath)
372
+ dropDedupeIndexForPath(bufferPath)
373
+ } catch {}
359
374
  drained++
360
375
  } catch (e) {
361
376
  const attempts = parseRetry(name) + 1
@@ -375,7 +390,9 @@ export async function drainBuffer() {
375
390
  // THIS pass so it can't block the oldest-first queue forever — the
376
391
  // next drain re-reads its (unchanged) .rN and retries.
377
392
  try {
378
- fs.renameSync(bufferPath, path.join(BUFFER_DIR, retryName(name, attempts)))
393
+ const nextPath = path.join(BUFFER_DIR, retryName(name, attempts))
394
+ fs.renameSync(bufferPath, nextPath)
395
+ replaceDedupeIndexPath(bufferPath, nextPath)
379
396
  } catch {
380
397
  // Rename lock/EPERM: don't break the whole pass on a file we
381
398
  // couldn't even age — skip it and CONTINUE so later buffered