mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -55,6 +55,51 @@ function isTcpPortFree(port) {
55
55
 
56
56
  const PG_PORT_MIN = 55432
57
57
  const PG_PORT_MAX = 55632
58
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
59
+
60
+ function isPidAlive(pid) {
61
+ if (!Number.isInteger(pid) || pid <= 0) return false
62
+ try {
63
+ process.kill(pid, 0)
64
+ return true
65
+ } catch (err) {
66
+ return err?.code === 'EPERM'
67
+ }
68
+ }
69
+
70
+ function readPostmasterInfo(pgdataDir) {
71
+ try {
72
+ const lines = readFileSync(join(pgdataDir, 'postmaster.pid'), 'utf8').split('\n')
73
+ const pid = parseInt(lines[0], 10)
74
+ const port = parseInt(lines[3], 10)
75
+ return {
76
+ pid: Number.isInteger(pid) && pid > 0 ? pid : null,
77
+ port: Number.isInteger(port) && port > 0 ? port : null,
78
+ }
79
+ } catch {
80
+ return { pid: null, port: null }
81
+ }
82
+ }
83
+
84
+ function pgIsReady(runtimeDir, env, port) {
85
+ const probe = spawnSync(pgBin(runtimeDir, 'pg_isready'), ['-h', '127.0.0.1', '-p', String(port)], {
86
+ env, stdio: 'pipe', timeout: 3_000, windowsHide: true,
87
+ })
88
+ return probe.status === 0
89
+ }
90
+
91
+ async function awaitExistingPostmaster({ runtimeDir, pgdataDir, env, waitMs }) {
92
+ const deadline = Date.now() + Math.max(0, Number(waitMs) || 0)
93
+ let info = readPostmasterInfo(pgdataDir)
94
+ while (info.pid && info.port) {
95
+ if (pgIsReady(runtimeDir, env, info.port)) return { state: 'ready', ...info }
96
+ if (!isPidAlive(info.pid)) return { state: 'dead', ...info }
97
+ if (Date.now() >= deadline) return { state: 'alive-not-ready', ...info }
98
+ await delay(250)
99
+ info = readPostmasterInfo(pgdataDir)
100
+ }
101
+ return { state: 'missing', ...info }
102
+ }
58
103
 
59
104
  async function findFreePort(preferred) {
60
105
  // I2: clamp out-of-range callers to the valid window.
@@ -135,7 +180,13 @@ export function reconcileConfV2(runtimeDir, pgdataDir) {
135
180
  // startPg
136
181
  // ---------------------------------------------------------------------------
137
182
 
138
- export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 55432, logPath }) {
183
+ export async function startPg({
184
+ runtimeDir,
185
+ pgdataDir,
186
+ port: preferredPort = 55432,
187
+ logPath,
188
+ existingWaitMs = 15_000,
189
+ }) {
139
190
  mkdirSync(pgdataDir, { recursive: true })
140
191
 
141
192
  if (process.platform === 'darwin') {
@@ -146,41 +197,35 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
146
197
  // the block was just appended (so the attach path can trigger pg_ctl reload).
147
198
  // Fresh-init path falls through to confAppend below which already includes v2.
148
199
  const v2Applied = ensureConfV2(pgdataDir)
200
+ const env = libEnv(runtimeDir)
149
201
 
150
202
  // Pre-check: if postmaster.pid exists and the instance is reachable, attach
151
203
  // rather than attempting a second pg_ctl start (which would crash the worker).
152
204
  const postmasterPidPath = join(pgdataDir, 'postmaster.pid')
153
205
  if (existsSync(postmasterPidPath)) {
154
- try {
155
- const lines = readFileSync(postmasterPidPath, 'utf8').split('\n')
156
- const existingPid = parseInt(lines[0], 10)
157
- // Line 4 (index 3) in postmaster.pid holds the port number.
158
- const existingPort = parseInt(lines[3], 10)
159
- if (existingPid > 0 && existingPort > 0) {
160
- // Liveness must be confirmed by pg_isready (status 0). A bare open TCP
161
- // port is insufficient: any unrelated listener that happened to grab
162
- // this port would be mistaken for our PG instance, causing a false
163
- // attach. pg_isready performs a real PG protocol handshake, so a
164
- // non-Postgres listener returns non-zero and we correctly fall through
165
- // to normal startup (which reclaims the stale postmaster.pid).
166
- const pgIsReady = pgBin(runtimeDir, 'pg_isready')
167
- const probe = spawnSync(pgIsReady, ['-h', '127.0.0.1', '-p', String(existingPort)], {
168
- env: libEnv(runtimeDir), stdio: 'pipe', timeout: 3_000, windowsHide: true,
169
- })
170
- const alive = probe.status === 0
171
- if (alive) {
172
- __mixdogMemoryLog(`[pg-process] attaching to existing PG pid=${existingPid} port=${existingPort}\n`)
173
- // Route through the single reconcile entry point so v1 → v2 conf
174
- // upgrades land on already-running instances without restart.
175
- if (v2Applied) reconcileConfV2(runtimeDir, pgdataDir)
176
- return { pid: existingPid, port: existingPort, attached: true }
177
- }
178
- // Dead instance — fall through to normal startup; pg_ctl will reclaim stale lock.
179
- }
180
- } catch {}
206
+ const existing = await awaitExistingPostmaster({
207
+ runtimeDir, pgdataDir, env, waitMs: existingWaitMs,
208
+ })
209
+ if (existing.state === 'ready') {
210
+ __mixdogMemoryLog(`[pg-process] attaching to existing PG pid=${existing.pid} port=${existing.port}\n`)
211
+ // Route through the single reconcile entry point so v1 → v2 conf
212
+ // upgrades land on already-running instances without restart.
213
+ if (v2Applied) reconcileConfV2(runtimeDir, pgdataDir)
214
+ return { pid: existing.pid, port: existing.port, attached: true }
215
+ }
216
+ if (existing.state === 'alive-not-ready') {
217
+ // A postmaster in startup/shutdown still owns this pgdata even when it no
218
+ // longer accepts connections. Starting the same directory on a "free"
219
+ // alternate port produces the observed endless "another server might be
220
+ // running" loop and can corrupt lifecycle state. The supervisor may
221
+ // finish a graceful stop, but this lower layer must never race it.
222
+ throw new Error(
223
+ `[pg-process] existing PG pid=${existing.pid} port=${existing.port} is alive but not ready; refusing concurrent start`,
224
+ )
225
+ }
226
+ // Dead instance fall through to normal startup; pg_ctl reclaims the stale lock.
181
227
  }
182
228
 
183
- const env = libEnv(runtimeDir)
184
229
  const initdb = pgBin(runtimeDir, 'initdb')
185
230
  const pgctl = pgBin(runtimeDir, 'pg_ctl')
186
231
 
@@ -248,7 +293,6 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
248
293
 
249
294
  __mixdogMemoryLog(`[pg-process] pg_ctl start -D ${pgdataDir} -p ${port}\n`)
250
295
 
251
- const pgIsReady = pgBin(runtimeDir, 'pg_isready')
252
296
  const startArgs = [
253
297
  'start',
254
298
  '-D', pgdataDir,
@@ -258,8 +302,6 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
258
302
 
259
303
  // Poll-sleep is intentionally NOT unref'd: while startPg is awaiting readiness
260
304
  // it must keep the process alive even if the event loop would otherwise drain.
261
- const sleep = ms => new Promise(res => setTimeout(res, ms))
262
-
263
305
  // Read pid + port from postmaster.pid (line 1 = pid, line 4 = port). Returns
264
306
  // null unless the file exists, pid > 0, and its port matches ours.
265
307
  function readPostmaster() {
@@ -280,7 +322,7 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
280
322
  for (let i = 0; i < 5; i++) {
281
323
  const pm = readPostmaster()
282
324
  if (pm) return pm.pid
283
- await sleep(100)
325
+ await delay(100)
284
326
  }
285
327
  return null
286
328
  }
@@ -312,10 +354,7 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
312
354
  const deadline = Date.now() + 30_000
313
355
  // eslint-disable-next-line no-constant-condition
314
356
  while (true) {
315
- const probe = spawnSync(pgIsReady, ['-h', '127.0.0.1', '-p', String(port)], {
316
- env, stdio: 'pipe', timeout: 3_000, windowsHide: true,
317
- })
318
- if (probe.status === 0) {
357
+ if (pgIsReady(runtimeDir, env, port)) {
319
358
  const pid = await confirmPid()
320
359
  // pid confirmed with matching port → ready. Otherwise keep polling until
321
360
  // the cap (postmaster.pid not yet written or port mismatch).
@@ -324,7 +363,7 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
324
363
  // pg_ctl exited nonzero before PG became reachable — real startup failure.
325
364
  if (closed && exitCode !== 0) return { ready: false, exited: true, stdout, stderr }
326
365
  if (Date.now() >= deadline) return { ready: false, timeout: true, stdout, stderr }
327
- await sleep(250)
366
+ await delay(250)
328
367
  }
329
368
  }
330
369
 
@@ -332,19 +371,19 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
332
371
  if (r.ready) return { pid: r.pid, port }
333
372
 
334
373
  const errText = r.stderr || r.stdout || ''
335
- // "another server might be running" try status + immediate stop before failing.
374
+ // A cross-process race can still land after the pre-check. Re-probe and
375
+ // attach if that winner becomes ready; never immediate-stop an unknown live
376
+ // postmaster, because synchronous_commit=off makes a crash-stop lossy.
336
377
  if (r.exited && errText.includes('another server might be running')) {
337
378
  __mixdogMemoryLog(`[pg-process] pg_ctl start: "another server might be running" — probing status\n`)
338
379
  const statusR = spawnSync(pgctl, ['status', '-D', pgdataDir], { env, stdio: 'pipe', timeout: 3_000, windowsHide: true })
339
380
  __mixdogMemoryLog(`[pg-process] pg_ctl status: ${statusR.stdout?.toString() || statusR.stderr?.toString() || 'no output'}\n`)
340
- const stopR = spawnSync(pgctl, ['stop', '-m', 'immediate', '-D', pgdataDir], { env, stdio: 'pipe', timeout: 3_000, windowsHide: true })
341
- if (stopR.status === 0) {
342
- // Retry start once after clearing the stale instance.
343
- const r2 = await startAndWaitReady()
344
- if (r2.ready) return { pid: r2.pid, port }
345
- __mixdogMemoryLog(`[pg-process] retry start after stop also failed: ${r2.stderr || ''}\n`)
346
- } else {
347
- __mixdogMemoryLog(`[pg-process] immediate stop failed: ${stopR.stderr?.toString() || ''} — treating as degraded\n`)
381
+ const existing = await awaitExistingPostmaster({
382
+ runtimeDir, pgdataDir, env, waitMs: existingWaitMs,
383
+ })
384
+ if (existing.state === 'ready') {
385
+ __mixdogMemoryLog(`[pg-process] attaching to race winner pid=${existing.pid} port=${existing.port}\n`)
386
+ return { pid: existing.pid, port: existing.port, attached: true }
348
387
  }
349
388
  }
350
389
 
@@ -419,6 +458,11 @@ export async function healthcheckPg({ port, host = '127.0.0.1' }) {
419
458
  host, port, user: 'postgres', database: 'postgres', password: '',
420
459
  connectionTimeoutMillis: 2000,
421
460
  })
461
+ // A socket can terminate after SELECT 1 resolves but before client.end()
462
+ // finishes. Raw pg.Client instances have no pool-level error listener, so
463
+ // keep this transient health probe from turning that narrow race into an
464
+ // uncaught EventEmitter error.
465
+ client.on('error', () => {})
422
466
  await client.connect()
423
467
  await client.query('SELECT 1')
424
468
  return true
@@ -399,6 +399,18 @@ async function isPostmasterAlive(pid) {
399
399
  return isPostgresPid(pid);
400
400
  }
401
401
 
402
+ async function waitForPostmasterReady({ pid, port, healthcheckPg, timeoutMs = 30_000 }) {
403
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
404
+ while (isPidAlive(pid)) {
405
+ try {
406
+ if (await healthcheckPg({ port })) return 'ready';
407
+ } catch {}
408
+ if (Date.now() >= deadline) return 'alive-not-ready';
409
+ await new Promise(resolve => setTimeout(resolve, 250));
410
+ }
411
+ return 'dead';
412
+ }
413
+
402
414
  // ── Internal: spawn a fresh PG instance ─────────────────────────────────────
403
415
 
404
416
  async function _startFresh(dataDir, pgdata, port, runtimeDir) {
@@ -473,7 +485,7 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
473
485
  // ── Internal: full ensure logic (runs exclusively via _ensureInFlight) ────────
474
486
 
475
487
  async function _doEnsure(dataDir) {
476
- const { healthcheckPg, stopPg } = await _getPgProc();
488
+ const { healthcheckPg } = await _getPgProc();
477
489
  const pgdata = join(dataDir, 'pgdata');
478
490
 
479
491
  // Resolve runtimeDir via runtime-fetcher (cache-hits immediately when already downloaded).
@@ -563,22 +575,47 @@ async function _doEnsure(dataDir) {
563
575
  __mixdogMemoryLog(
564
576
  `[supervisor-pg] pg_port=${existingPort} recorded but healthcheck failed — recovering\n`,
565
577
  );
566
- const pmPid = readPostmasterPid(pgdata);
567
- if (pmPid && await isPostmasterAlive(pmPid)) {
568
- // postmaster alive but unhealthy: attempt graceful stop first
569
- __mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pmPid} alive — attempting graceful stopPg\n`);
570
- try { await stopPg({ runtimeDir, pgdataDir: pgdata }); } catch (e) {
571
- __mixdogMemoryLog(`[supervisor-pg] graceful stopPg failed: ${e?.message} — continuing to fresh start\n`);
572
- }
573
- } else if (pmPid) {
574
- // postmaster dead: remove stale postmaster.pid so initdb/start is not blocked
575
- __mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pmPid} dead — removing stale postmaster.pid\n`);
576
- try { unlinkSync(join(pgdata, 'postmaster.pid')); } catch {}
577
- }
578
578
  // Clear stale pg fields before restart
579
579
  patchActiveInstance({ pg_port: null, pg_started_at: null, pg_pgdata: null }, { timeoutMs: 1000, background: true });
580
580
  }
581
581
 
582
+ // postmaster.pid is the authoritative pgdata owner even when the discovery
583
+ // advert is absent/stale. The old path checked it only inside the
584
+ // `existingPort` branch, then allocated 55433 and tried to start the SAME
585
+ // pgdata while a recovering/rejecting server still owned 55432. Give crash
586
+ // recovery a bounded chance to finish and attach if it does. Never issue a
587
+ // stop from an ensure path: an alive postmaster owns the data directory,
588
+ // and interrupting recovery can turn a transient outage into a restart loop.
589
+ const pm = readPostmasterInfo(pgdata);
590
+ if (pm.pid && pm.port && await isPostmasterAlive(pm.pid)) {
591
+ __mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pm.pid} alive but not ready — awaiting recovery\n`);
592
+ const state = await waitForPostmasterReady({
593
+ pid: pm.pid,
594
+ port: pm.port,
595
+ healthcheckPg,
596
+ });
597
+ if (state === 'ready') {
598
+ __mixdogMemoryLog(`[supervisor-pg] attaching to recovered PG pid=${pm.pid} port=${pm.port}\n`);
599
+ _live = { port: pm.port, pgdata, runtimeDir, proc: null };
600
+ patchActiveInstance({
601
+ pg_port: pm.port,
602
+ pg_started_at: ai?.pg_started_at ?? Date.now(),
603
+ pg_pgdata: pgdata,
604
+ pg_runtime_dir: runtimeDir,
605
+ }, { timeoutMs: 1000, background: true });
606
+ return { host: '127.0.0.1', port: pm.port, runtimeDir, pgdataDir: pgdata };
607
+ }
608
+ if (state === 'alive-not-ready') {
609
+ throw new Error(
610
+ `[supervisor-pg] existing postmaster PID ${pm.pid} remains alive but unhealthy; refusing concurrent start`,
611
+ );
612
+ }
613
+ }
614
+ if (pm.pid) {
615
+ __mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pm.pid} dead — removing stale postmaster.pid\n`);
616
+ try { unlinkSync(join(pgdata, 'postmaster.pid')); } catch {}
617
+ }
618
+
582
619
  // ── Allocate a fresh port and spawn ───────────────────────────────────
583
620
  const port = await allocatePort();
584
621
  return await _startFresh(dataDir, pgdata, port, runtimeDir);
@@ -32,6 +32,7 @@ import {
32
32
  updateProviderState,
33
33
  } from './lib/state.mjs'
34
34
  import { getScrapeCapabilities, scrapeUrls } from './lib/web-tools.mjs'
35
+ import { fetchLoopbackText, fetchPublicImage } from './lib/http-fetch.mjs'
35
36
  import { formatResponse } from './lib/formatter.mjs'
36
37
  ensureDataDir()
37
38
 
@@ -538,6 +539,46 @@ async function handleToolCall(name, rawArgs, options = {}) {
538
539
  return { content: [{ type: 'text', text: `Fetch failed: ${_cleanErr}` }], isError: true }
539
540
  }
540
541
  }
542
+ case 'local_fetch':
543
+ case 'image_fetch': {
544
+ let urlArgs
545
+ try {
546
+ urlArgs = searchUrlArgsSchema.parse(normalizeSearchUrlArgs(rawArgs || {}))
547
+ } catch (e) {
548
+ if (e instanceof z.ZodError) {
549
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Invalid arguments', details: e.errors }) }], isError: true }
550
+ }
551
+ throw e
552
+ }
553
+ const urls = Array.isArray(urlArgs.url) ? urlArgs.url : [urlArgs.url]
554
+ if (urls.length > 8) return { content: [{ type: 'text', text: 'Error: fetch batch exceeds maximum of 8 URLs.' }], isError: true }
555
+ const fetchSignal = signal
556
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
557
+ : AbortSignal.timeout(timeoutMs)
558
+ try {
559
+ if (name === 'local_fetch') {
560
+ const parts = []
561
+ for (const url of urls) {
562
+ const text = await fetchLoopbackText(url, { signal: fetchSignal })
563
+ const start = urlArgs.startIndex || 0
564
+ const max = urlArgs.maxLength == null ? 50_000 : urlArgs.maxLength
565
+ const body = max === 0 ? text.slice(start) : text.slice(start, start + max)
566
+ parts.push({ type: 'text', text: `${url}\n\n${body}` })
567
+ }
568
+ return { content: parts }
569
+ }
570
+ const parts = []
571
+ for (const url of urls) {
572
+ const image = await fetchPublicImage(url, { signal: fetchSignal })
573
+ parts.push({ type: 'text', text: `Downloaded image: ${url} (${image.mimeType}, ${image.bytes} bytes)` })
574
+ parts.push({ type: 'image', source: { type: 'base64', media_type: image.mimeType, data: image.data } })
575
+ }
576
+ return { content: parts }
577
+ } catch (error) {
578
+ const message = presentErrorText(normalizeErrorMessage(error instanceof Error ? error.message : String(error)), { surface: name })
579
+ return { content: [{ type: 'text', text: `Fetch failed: ${message}` }], isError: true }
580
+ }
581
+ }
541
582
  default:
542
583
  throw new Error(`Unknown tool: ${name}`)
543
584
  }
@@ -1,4 +1,6 @@
1
1
  import { readFileSync } from 'fs'
2
+ import dns from 'node:dns'
3
+ import { Agent, fetch as undiciFetch } from 'undici'
2
4
 
3
5
  import {
4
6
  assertPublicUrl,
@@ -128,6 +130,158 @@ export async function readBodyBytesWithCap(response, maxBytes) {
128
130
  return Buffer.concat(chunks.map((c) => Buffer.from(c)))
129
131
  }
130
132
 
133
+ const SAFE_IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
134
+
135
+ function loopbackHost(hostname) {
136
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
137
+ if (host === 'localhost' || host === '::1') return true
138
+ const match = host.match(/^(\d{1,3})(?:\.(\d{1,3})){3}$/)
139
+ return Boolean(match && Number(match[1]) === 127)
140
+ }
141
+
142
+ function abortRace(promise, signal) {
143
+ if (!signal) return promise
144
+ if (signal.aborted) return Promise.reject(signal.reason || new Error('local_fetch aborted'))
145
+ return new Promise((resolve, reject) => {
146
+ const onAbort = () => reject(signal.reason || new Error('local_fetch aborted'))
147
+ signal.addEventListener('abort', onAbort, { once: true })
148
+ promise.then(
149
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
150
+ (error) => { signal.removeEventListener('abort', onAbort); reject(error) },
151
+ )
152
+ })
153
+ }
154
+
155
+ async function pinnedLoopbackFetch(url, options = {}) {
156
+ const parsed = assertLoopbackUrl(url)
157
+ const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
158
+ const addresses = host === 'localhost'
159
+ ? await abortRace(dns.promises.lookup(host, { all: true }), options.signal)
160
+ : [{ address: host, family: host.includes(':') ? 6 : 4 }]
161
+ if (!addresses.length || addresses.some((entry) => !loopbackHost(entry.address))) {
162
+ throw new Error(`Blocked non-loopback local_fetch resolution: ${host}`)
163
+ }
164
+ const pinned = addresses[0]
165
+ const dispatcher = new Agent({
166
+ connect: {
167
+ lookup: (_hostname, opts, cb) => opts?.all
168
+ ? cb(null, [{ address: pinned.address, family: pinned.family }])
169
+ : cb(null, pinned.address, pinned.family),
170
+ },
171
+ })
172
+ let response
173
+ try {
174
+ response = await undiciFetch(url, { ...options, dispatcher })
175
+ } catch (error) {
176
+ dispatcher.destroy().catch(() => {})
177
+ throw error
178
+ }
179
+ if (!response.body) {
180
+ dispatcher.destroy().catch(() => {})
181
+ return response
182
+ }
183
+ const reader = response.body.getReader()
184
+ let cleaned = false
185
+ const cleanup = () => {
186
+ if (cleaned) return
187
+ cleaned = true
188
+ dispatcher.destroy().catch(() => {})
189
+ }
190
+ const monitored = new ReadableStream({
191
+ async pull(controller) {
192
+ try {
193
+ const { done, value } = await reader.read()
194
+ if (done) {
195
+ controller.close()
196
+ cleanup()
197
+ } else {
198
+ controller.enqueue(value)
199
+ }
200
+ } catch (error) {
201
+ controller.error(error)
202
+ cleanup()
203
+ }
204
+ },
205
+ cancel(reason) {
206
+ reader.cancel(reason).catch(() => {})
207
+ cleanup()
208
+ },
209
+ })
210
+ return new Response(monitored, {
211
+ status: response.status,
212
+ statusText: response.statusText,
213
+ headers: response.headers,
214
+ })
215
+ }
216
+
217
+ export function assertLoopbackUrl(url) {
218
+ const parsed = new URL(url)
219
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
220
+ throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
221
+ }
222
+ if (parsed.username || parsed.password) throw new Error('Blocked loopback URL with userinfo credentials')
223
+ if (!loopbackHost(parsed.hostname)) throw new Error(`Blocked non-loopback local_fetch target: ${parsed.hostname}`)
224
+ return parsed
225
+ }
226
+
227
+ async function boundedManualFetch(url, { signal, fetchImpl, validateUrl, sameHost = false } = {}) {
228
+ const original = validateUrl(url)
229
+ let currentUrl = original.toString()
230
+ for (let hops = 0; ; hops++) {
231
+ const current = validateUrl(currentUrl)
232
+ if (sameHost && current.hostname.toLowerCase() !== original.hostname.toLowerCase()) {
233
+ throw new Error(`cross-host redirect blocked (redirected_to: ${currentUrl})`)
234
+ }
235
+ const response = await fetchImpl(currentUrl, {
236
+ signal,
237
+ headers: buildHeaders(),
238
+ redirect: 'manual',
239
+ })
240
+ if (!REDIRECT_STATUSES.has(response.status)) return response
241
+ try { await response.body?.cancel() } catch {}
242
+ if (hops >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
243
+ const location = response.headers.get('location')
244
+ if (!location) throw new Error(`Redirect ${response.status} without Location header`)
245
+ currentUrl = new URL(location, currentUrl).toString()
246
+ }
247
+ }
248
+
249
+ export async function fetchLoopbackText(url, { signal, fetchImpl = pinnedLoopbackFetch } = {}) {
250
+ const response = await boundedManualFetch(url, {
251
+ signal,
252
+ fetchImpl,
253
+ validateUrl: assertLoopbackUrl,
254
+ })
255
+ if (!response.ok) {
256
+ try { await response.body?.cancel() } catch {}
257
+ throw new Error(`HTTP ${response.status}`)
258
+ }
259
+ return readBodyWithCap(response, MAX_BODY_BYTES)
260
+ }
261
+
262
+ export async function fetchPublicImage(url, { signal, fetchImpl = pinnedFetch } = {}) {
263
+ const response = await boundedManualFetch(url, {
264
+ signal,
265
+ fetchImpl,
266
+ validateUrl: (value) => {
267
+ assertPublicUrl(value)
268
+ return new URL(value)
269
+ },
270
+ sameHost: true,
271
+ })
272
+ if (!response.ok) {
273
+ try { await response.body?.cancel() } catch {}
274
+ throw new Error(`HTTP ${response.status}`)
275
+ }
276
+ const mimeType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
277
+ if (!SAFE_IMAGE_MIMES.has(mimeType)) {
278
+ try { await response.body?.cancel() } catch {}
279
+ throw new Error(`Blocked unsupported image content-type: ${mimeType || '(missing)'}`)
280
+ }
281
+ const bytes = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
282
+ return { mimeType, data: bytes.toString('base64'), bytes: bytes.byteLength }
283
+ }
284
+
131
285
  const CDP_FORBIDDEN_RESPONSE_HEADERS = new Set([
132
286
  'content-length',
133
287
  'transfer-encoding',
@@ -2,6 +2,8 @@ import {
2
2
  TOOL_SYNC_EXECUTION_CONTRACT,
3
3
  } from '../shared/tool-execution-contract.mjs';
4
4
 
5
+ const TOOL_DEFS_PLACEHOLDER = Symbol('web-fetch-schema')
6
+
5
7
  export const TOOL_DEFS = [
6
8
  {
7
9
  name: 'search',
@@ -54,4 +56,25 @@ export const TOOL_DEFS = [
54
56
  },
55
57
  annotations: { title: 'Mixdog Web Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
56
58
  },
59
+ {
60
+ name: 'local_fetch',
61
+ title: 'Mixdog Loopback Fetch',
62
+ public: false,
63
+ description: 'Runtime-only loopback HTTP(S) fetch target.',
64
+ inputSchema: TOOL_DEFS_PLACEHOLDER,
65
+ annotations: { title: 'Mixdog Loopback Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
66
+ },
67
+ {
68
+ name: 'image_fetch',
69
+ title: 'Mixdog Image Fetch',
70
+ public: false,
71
+ description: 'Runtime-only bounded public image fetch target.',
72
+ inputSchema: TOOL_DEFS_PLACEHOLDER,
73
+ annotations: { title: 'Mixdog Image Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
74
+ },
57
75
  ]
76
+
77
+ const webFetchSchema = TOOL_DEFS.find((tool) => tool.name === 'web_fetch').inputSchema
78
+ for (const tool of TOOL_DEFS) {
79
+ if (tool.inputSchema === TOOL_DEFS_PLACEHOLDER) tool.inputSchema = webFetchSchema
80
+ }