mixdog 0.9.51 → 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 (178) 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/bench-run.mjs +2 -2
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/channel-daemon-smoke.mjs +630 -10
  10. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  11. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  12. package/scripts/code-graph-root-federation-test.mjs +273 -0
  13. package/scripts/compact-pressure-test.mjs +159 -1
  14. package/scripts/compact-smoke.mjs +80 -0
  15. package/scripts/context-mcp-metering-test.mjs +1350 -19
  16. package/scripts/deferred-tool-loading-test.mjs +17 -0
  17. package/scripts/desktop-session-bridge-test.mjs +704 -0
  18. package/scripts/freevar-smoke.mjs +7 -4
  19. package/scripts/gemini-provider-test.mjs +1053 -0
  20. package/scripts/interrupted-turn-history-test.mjs +371 -0
  21. package/scripts/lifecycle-api-test.mjs +137 -0
  22. package/scripts/max-output-recovery-test.mjs +86 -0
  23. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  24. package/scripts/memory-core-input-test.mjs +10 -0
  25. package/scripts/memory-pg-recovery-test.mjs +59 -0
  26. package/scripts/openai-oauth-ws-1006-retry-test.mjs +387 -6
  27. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  28. package/scripts/parent-abort-link-test.mjs +24 -0
  29. package/scripts/process-lifecycle-test.mjs +447 -0
  30. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  31. package/scripts/provider-contract-test.mjs +525 -0
  32. package/scripts/provider-toolcall-test.mjs +492 -11
  33. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  34. package/scripts/resource-admission-test.mjs +789 -0
  35. package/scripts/session-orphan-sweep-test.mjs +27 -1
  36. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  37. package/scripts/steering-drain-buckets-test.mjs +18 -0
  38. package/scripts/toolcall-args-test.mjs +14 -6
  39. package/scripts/tui-transcript-perf-test.mjs +5 -7
  40. package/src/cli.mjs +15 -2
  41. package/src/lib/keychain-cjs.cjs +36 -23
  42. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +27 -13
  43. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  44. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  45. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  46. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  47. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  48. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +114 -308
  49. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +31 -21
  50. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -286
  51. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +42 -5
  52. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  53. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  54. package/src/runtime/agent/orchestrator/providers/gemini.mjs +84 -150
  55. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -119
  56. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
  57. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  58. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  59. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  60. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  61. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +28 -74
  64. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  65. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +136 -20
  66. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +94 -33
  67. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +201 -123
  68. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +37 -28
  69. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  70. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  71. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  72. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +50 -45
  73. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  74. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  75. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  76. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +229 -111
  77. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +130 -63
  78. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  79. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +37 -224
  81. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  82. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +81 -42
  83. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  84. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  85. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  86. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  87. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +30 -7
  88. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  89. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  90. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  91. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  92. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  93. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  94. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +12 -1
  96. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  97. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  98. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  99. package/src/runtime/agent/orchestrator/session/store.mjs +353 -108
  100. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  101. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  102. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  103. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  104. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  107. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  108. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  109. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +317 -250
  112. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  113. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  114. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  115. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  116. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  117. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  118. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +125 -85
  119. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  120. package/src/runtime/channels/backends/discord.mjs +6 -6
  121. package/src/runtime/channels/lib/config.mjs +15 -2
  122. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  123. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  124. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  125. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  126. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  127. package/src/runtime/memory/index.mjs +46 -202
  128. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  129. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  130. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  131. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  132. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  133. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -17
  134. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  135. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  136. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  137. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  138. package/src/runtime/shared/atomic-file.mjs +28 -150
  139. package/src/runtime/shared/config.mjs +58 -13
  140. package/src/runtime/shared/llm/cost.mjs +14 -4
  141. package/src/runtime/shared/memory-snapshot.mjs +236 -0
  142. package/src/runtime/shared/process-lifecycle.mjs +436 -0
  143. package/src/runtime/shared/process-shutdown.mjs +33 -4
  144. package/src/runtime/shared/resource-admission.mjs +364 -0
  145. package/src/runtime/shared/staged-child-result.mjs +19 -0
  146. package/src/session-runtime/channel-config-api.mjs +7 -7
  147. package/src/session-runtime/config-lifecycle.mjs +20 -17
  148. package/src/session-runtime/context-status.mjs +38 -27
  149. package/src/session-runtime/cwd-plugins.mjs +9 -7
  150. package/src/session-runtime/env.mjs +1 -2
  151. package/src/session-runtime/hitch-profile.mjs +45 -0
  152. package/src/session-runtime/lifecycle-api.mjs +60 -13
  153. package/src/session-runtime/mcp-glue.mjs +6 -11
  154. package/src/session-runtime/provider-init-key.mjs +17 -0
  155. package/src/session-runtime/provider-request-tools.mjs +72 -0
  156. package/src/session-runtime/runtime-core.mjs +49 -105
  157. package/src/session-runtime/runtime-paths.mjs +20 -0
  158. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  159. package/src/session-runtime/session-turn-api.mjs +5 -4
  160. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  161. package/src/session-runtime/tool-catalog.mjs +390 -104
  162. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  163. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  164. package/src/standalone/agent-tool.mjs +29 -200
  165. package/src/standalone/channel-admin.mjs +29 -0
  166. package/src/standalone/channel-daemon-client.mjs +200 -38
  167. package/src/standalone/channel-daemon-transport.mjs +136 -9
  168. package/src/standalone/channel-worker.mjs +79 -12
  169. package/src/tui/App.jsx +11 -5
  170. package/src/tui/dist/index.mjs +275 -343
  171. package/src/tui/engine/session-api-ext.mjs +50 -6
  172. package/src/tui/engine/session-api.mjs +1 -1
  173. package/src/tui/engine/turn.mjs +10 -6
  174. package/src/tui/engine.mjs +13 -4
  175. package/src/tui/index.jsx +5 -1
  176. package/src/tui/lib/voice-setup.mjs +21 -5
  177. package/scripts/_devtools-stub.mjs +0 -1
  178. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -33,15 +33,18 @@ import { randomUUID } from 'node:crypto';
33
33
  import * as nodeUtil from 'node:util';
34
34
  import { getPluginData } from '../config.mjs';
35
35
  import { startChildGuardian } from '../../../shared/child-guardian.mjs';
36
+ import { resourceAdmission } from '../../../shared/resource-admission.mjs';
36
37
  // Runtime-only import (used inside execShellCommand's auto-background
37
38
  // transition). shell-jobs.mjs imports stripAnsi from this module, so this is
38
39
  // a static cycle — safe because neither binding is touched at module-eval
39
40
  // time, only when the respective functions actually run.
40
41
  import { adoptForegroundShellJob, killShellJob } from './builtin/shell-jobs.mjs';
42
+ import { trackProcessTreeQuiescence } from './builtin/shell-job-process.mjs';
41
43
  import {
42
44
  _maybeEncodePowerShellCommand,
43
45
  extractPowerShellCommandInner,
44
46
  } from './shell-powershell.mjs';
47
+ import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
45
48
 
46
49
  export {
47
50
  _maybeEncodePowerShellCommand,
@@ -445,12 +448,6 @@ async function _execPolicyBlockMessage(command) {
445
448
  // EPERM backoff). Logged with each failed spawn so a Defender-induced storm
446
449
  // is reconstructable: activeSpawnCount > 1 means concurrent spawns were
447
450
  // racing the AV scan when the failure hit.
448
- let _activeShellSpawns = 0;
449
-
450
- function _isPowerShellSpawn(shell, shellArg) {
451
- return /pwsh|powershell/i.test(String(shell || '')) || shellArg === '-Command';
452
- }
453
-
454
451
  // Windows Defender intermittently fails node→PowerShell spawns with EPERM
455
452
  // while it scans the child image (see shell-runtime.mjs Trojan false-positive
456
453
  // note). The failure is at spawn() time — before any stdio/side effect — so a
@@ -458,71 +455,6 @@ function _isPowerShellSpawn(shell, shellArg) {
458
455
  // Retry ONLY on EPERM/win32/powershell; everything else throws on first
459
456
  // failure. Backoff 100/300/700ms caps added latency at ~1.1s. Every failed
460
457
  // attempt logs one diagnostic line for later reconstruction.
461
- async function _spawnShellWithRetry({ shell, argv, spawnOptions, shellArg, cwd }) {
462
- const _delays = [100, 300, 700];
463
- const _isPowerShell = _isPowerShellSpawn(shell, shellArg);
464
- _activeShellSpawns++;
465
- try {
466
- let attempt = 0;
467
- for (;;) {
468
- try {
469
- const child = spawn(shell, argv, spawnOptions);
470
- // spawn() reports ENOENT/EACCES on nextTick. Keep a guard listener
471
- // attached from the same synchronous frame as spawn(), and do not
472
- // resolve this helper until the child emits `spawn`. The caller adopts
473
- // the guard atomically (new listener first, guard removal second), so
474
- // there is never an unhandled-error window across either await.
475
- let bufferedError = null;
476
- const guardError = (err) => { bufferedError = bufferedError || err; };
477
- child.on('error', guardError);
478
- try {
479
- await new Promise((resolveSpawn, rejectSpawn) => {
480
- const onSpawn = () => {
481
- child.removeListener('error', onError);
482
- resolveSpawn();
483
- };
484
- const onError = (err) => {
485
- child.removeListener('spawn', onSpawn);
486
- rejectSpawn(err);
487
- };
488
- child.once('spawn', onSpawn);
489
- child.once('error', onError);
490
- });
491
- } catch (err) {
492
- child.removeListener('error', guardError);
493
- throw err;
494
- }
495
- return {
496
- child,
497
- adoptErrorHandler(handler) {
498
- child.on('error', handler);
499
- child.removeListener('error', guardError);
500
- if (bufferedError) handler(bufferedError);
501
- },
502
- };
503
- } catch (err) {
504
- try {
505
- console.error('[shell-spawn-retry] ' + JSON.stringify({
506
- code: (err && err.code) || null,
507
- syscall: (err && err.syscall) || null,
508
- shell,
509
- cwd,
510
- activeSpawnCount: _activeShellSpawns,
511
- }));
512
- } catch { /* logging must never mask the spawn error */ }
513
- const _canRetry = err && err.code === 'EPERM'
514
- && process.platform === 'win32'
515
- && _isPowerShell
516
- && attempt < _delays.length;
517
- if (!_canRetry) throw err;
518
- await new Promise((r) => setTimeout(r, _delays[attempt++]));
519
- }
520
- }
521
- } finally {
522
- _activeShellSpawns--;
523
- }
524
- }
525
-
526
458
  export function execShellCommand({
527
459
  shell,
528
460
  shellArg,
@@ -537,8 +469,16 @@ export function execShellCommand({
537
469
  clientHostPid,
538
470
  backgroundOnTimeout,
539
471
  promotedTimeoutMs = 0,
472
+ admission = resourceAdmission,
540
473
  }) {
541
474
  return new Promise(async (resolve) => {
475
+ let resultResolved = false;
476
+ const resolveResult = (result) => {
477
+ if (resultResolved) return false;
478
+ resultResolved = true;
479
+ resolve(result);
480
+ return true;
481
+ };
542
482
  const taskId = `shell_${randomUUID().slice(0, 8)}`;
543
483
  const taskOutput = new TaskOutput(taskId);
544
484
  let timedOut = false;
@@ -553,6 +493,49 @@ export function execShellCommand({
553
493
  let timer = null;
554
494
  let abortHandler = null;
555
495
  let partialOutput = false;
496
+ let resourceLease = null;
497
+ let resourceLeaseSettlement = null;
498
+ const releaseResourceLease = async () => {
499
+ if (!resourceLease) return null;
500
+ const lease = resourceLease;
501
+ resourceLease = null;
502
+ try {
503
+ await lease.release();
504
+ return null;
505
+ } catch (error) {
506
+ return error;
507
+ }
508
+ };
509
+ const releaseResourceLeaseWhenTreeQuiescent = (pid, { waitForRootExit = false } = {}) => {
510
+ if (resourceLeaseSettlement) return resourceLeaseSettlement;
511
+ if (!resourceLease) return { pending: false, promise: Promise.resolve() };
512
+ const lease = resourceLease;
513
+ resourceLease = null;
514
+ const cleanup = Promise.withResolvers();
515
+ const tracker = trackProcessTreeQuiescence(pid, () => {
516
+ try {
517
+ Promise.resolve(lease.release()).then(
518
+ () => cleanup.resolve({ error: null }),
519
+ (error) => cleanup.resolve({ error }),
520
+ );
521
+ } catch (error) {
522
+ cleanup.resolve({ error });
523
+ }
524
+ }, { waitForRootExit });
525
+ resourceLeaseSettlement = {
526
+ lease,
527
+ tracker,
528
+ get pending() { return tracker.pending; },
529
+ promise: cleanup.promise,
530
+ };
531
+ return resourceLeaseSettlement;
532
+ };
533
+ const detachAbortHandler = () => {
534
+ if (abortSignal && abortHandler) {
535
+ try { abortSignal.removeEventListener('abort', abortHandler); } catch {}
536
+ abortHandler = null;
537
+ }
538
+ };
556
539
  // MCP live-progress: throttled "running Ns, M lines" emits while the
557
540
  // foreground command runs. Inert (never armed) when onProgress is null.
558
541
  const _hasProgress = typeof onProgress === 'function';
@@ -568,6 +551,7 @@ export function execShellCommand({
568
551
  // the child's lifecycle is owned by the shell-jobs registry. Mutually
569
552
  // exclusive with `settled`: whichever transition wins first wins for good.
570
553
  let autoBackgrounded = false;
554
+ let autoBackgroundJobId = null;
571
555
  let autoBgTimer = null;
572
556
  // Treekill + force-settle deadline. treeKill alone leaves settle()
573
557
  // pending on 'close'/'exit'; on Windows a taskkill miss or a grandchild
@@ -597,9 +581,14 @@ export function execShellCommand({
597
581
 
598
582
  let child;
599
583
  try {
584
+ resourceLease = await admission.acquire('shell', {
585
+ signal: abortSignal || null,
586
+ label: String(command || '').slice(0, 120),
587
+ });
600
588
  const _policyErr = await _execPolicyBlockMessage(command);
601
589
  if (_policyErr) {
602
- resolve(
590
+ await releaseResourceLease();
591
+ resolveResult(
603
592
  new ExecResult({
604
593
  stdout: '',
605
594
  stderr: _policyErr,
@@ -655,18 +644,28 @@ export function execShellCommand({
655
644
  childGroupPid: child.pid,
656
645
  label: 'shell-command',
657
646
  });
647
+ // Windows has no process-group handle. Begin observing while the owned
648
+ // root still exists so descendants can be proven before root exit.
649
+ releaseResourceLeaseWhenTreeQuiescent(child.pid, { waitForRootExit: true });
658
650
  } catch (err) {
659
- resolve(
651
+ const cleanupError = await releaseResourceLease();
652
+ const spawnText = String((err && err.message) || err);
653
+ const cleanupText = cleanupError
654
+ ? `${spawnText}; resource cleanup failed: ${cleanupError?.message || cleanupError}`
655
+ : spawnText;
656
+ resolveResult(
660
657
  new ExecResult({
661
658
  stdout: '',
662
- stderr: String((err && err.message) || err),
659
+ stderr: cleanupText,
663
660
  exitCode: 1,
664
661
  signal: null,
665
662
  timedOut: false,
666
663
  killed: false,
667
664
  taskId,
668
665
  failurePhase: 'tool',
669
- failureReason: 'spawn failed',
666
+ failureReason: err?.code === 'ERESOURCEPRESSURE' || err?.code === 'ERESOURCEQUEUEFULL'
667
+ ? 'resource pressure'
668
+ : 'spawn failed',
670
669
  }),
671
670
  );
672
671
  return;
@@ -712,11 +711,7 @@ export function execShellCommand({
712
711
  clearTimeout(autoBgTimer);
713
712
  autoBgTimer = null;
714
713
  }
715
- if (abortSignal && abortHandler) {
716
- try {
717
- abortSignal.removeEventListener('abort', abortHandler);
718
- } catch {}
719
- }
714
+ detachAbortHandler();
720
715
  // getStdout/getStderr can throw on a spilled-file read failure (EBADF
721
716
  // after unlink race, EACCES). Without this catch the rejection bubbles
722
717
  // up and leaves the outer settle promise unresolved, hanging the call.
@@ -741,7 +736,12 @@ export function execShellCommand({
741
736
  } else {
742
737
  taskOutput.closeFds();
743
738
  }
744
- resolve(
739
+ const leaseSettlement = resourceLeaseSettlement
740
+ || releaseResourceLeaseWhenTreeQuiescent(child.pid);
741
+ leaseSettlement.tracker?.rootExited?.();
742
+ await leaseSettlement.tracker?.afterRootExitCheck?.();
743
+ if (!leaseSettlement.pending) await leaseSettlement.promise;
744
+ resolveResult(
745
745
  new ExecResult({
746
746
  stdout,
747
747
  stderr,
@@ -863,6 +863,8 @@ export function execShellCommand({
863
863
  }
864
864
  return;
865
865
  }
866
+ const promotedLease = resourceLeaseSettlement?.lease || resourceLease;
867
+ child.once('close', () => { resourceLeaseSettlement?.tracker?.rootExited?.(); });
866
868
  // Wire the lifecycle: on close, write the exit-code file FIRST then
867
869
  // touch donePath STRICTLY AFTER — the exact ordering refreshShellJob()
868
870
  // gates completion on (donePath visible ⇒ exit file fully flushed).
@@ -881,6 +883,7 @@ export function execShellCommand({
881
883
  try { stderr = await taskOutput.getStderr(); }
882
884
  catch (err) { taskOutput.writeError = taskOutput.writeError || err; }
883
885
  const jobId = job ? job.jobId : null;
886
+ autoBackgroundJobId = jobId;
884
887
  // Re-check after the awaited capture reads: cancellation can race after
885
888
  // adoption commits. Never report that cancelled process as a successful
886
889
  // still-running background task.
@@ -889,7 +892,8 @@ export function execShellCommand({
889
892
  killCause = 'cancellation';
890
893
  try { killShellJob(jobId); } catch {}
891
894
  try { treeKill(child); } catch {}
892
- resolve(new ExecResult({
895
+ await promotedLease?.detachDependency?.();
896
+ resolveResult(new ExecResult({
893
897
  stdout,
894
898
  stderr,
895
899
  exitCode: null,
@@ -908,11 +912,20 @@ export function execShellCommand({
908
912
  }));
909
913
  return;
910
914
  }
915
+ // Promotion changes a scoped dependency into detached lifetime work.
916
+ // Re-admit the continuing parent through the normal bounded queue before
917
+ // returning control; if the child exits first, its close release supplies
918
+ // the capacity that completes this restoration.
919
+ await promotedLease?.detachDependency?.();
920
+ // The adopted job now owns cancellation through task control. Retaining
921
+ // the foreground caller's signal listener would keep the completed tool
922
+ // frame alive and could later kill an unrelated, already-returned job.
923
+ detachAbortHandler();
911
924
  const secs = Math.max(0, Math.round((Date.now() - _startMs) / 1000));
912
925
  const _verb = reason === 'timeout'
913
926
  ? `moved to background at timeout after ${secs}s`
914
927
  : `auto-backgrounded after ${secs}s`;
915
- resolve(
928
+ resolveResult(
916
929
  new ExecResult({
917
930
  stdout,
918
931
  stderr,
@@ -936,6 +949,33 @@ export function execShellCommand({
936
949
  }),
937
950
  );
938
951
  };
952
+ const fireAutoBackground = (options) => {
953
+ void _autoBackground(options).catch((error) => {
954
+ if (resultResolved) return;
955
+ settled = true;
956
+ autoBackgrounded = true;
957
+ killed = true;
958
+ killCause = 'resource-cleanup-error';
959
+ detachAbortHandler();
960
+ try { if (autoBackgroundJobId) killShellJob(autoBackgroundJobId); } catch {}
961
+ try { treeKill(child); } catch {}
962
+ resolveResult(new ExecResult({
963
+ stdout: '',
964
+ stderr: `resource cleanup failed during background promotion: ${error?.message || error}`,
965
+ exitCode: 1,
966
+ signal: child?.signalCode || null,
967
+ timedOut: false,
968
+ killed: true,
969
+ killCause,
970
+ taskId,
971
+ partialOutput: true,
972
+ outputCaptureError: taskOutput.writeError,
973
+ failurePhase: 'tool',
974
+ failureReason: 'resource cleanup failed',
975
+ backgrounded: false,
976
+ }));
977
+ });
978
+ };
939
979
 
940
980
  if (timeoutMs > 0) {
941
981
  timer = setTimeout(() => {
@@ -953,7 +993,7 @@ export function execShellCommand({
953
993
  child.exitCode == null &&
954
994
  child.signalCode == null
955
995
  ) {
956
- _autoBackground({ reason: 'timeout' });
996
+ fireAutoBackground({ reason: 'timeout' });
957
997
  return;
958
998
  }
959
999
  timedOut = true;
@@ -985,7 +1025,7 @@ export function execShellCommand({
985
1025
  !_isBackground &&
986
1026
  (timeoutMs <= 0 || autoBackgroundMs < timeoutMs)
987
1027
  ) {
988
- autoBgTimer = setTimeout(() => { _autoBackground(); }, autoBackgroundMs);
1028
+ autoBgTimer = setTimeout(() => { fireAutoBackground(); }, autoBackgroundMs);
989
1029
  if (autoBgTimer.unref) autoBgTimer.unref();
990
1030
  }
991
1031
 
@@ -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
@@ -410,7 +410,9 @@ async function refreshBridgeOwnership(options = {}) {
410
410
  async function reloadRuntimeConfig() {
411
411
  const previousBackend = getBackend();
412
412
  const previousBackendName = previousBackend?.name || "";
413
- setConfig(await loadConfig());
413
+ // File-watch/tool-triggered reloads must bypass the short keychain hit cache:
414
+ // another process may have just saved or rotated the channel credential.
415
+ setConfig(await loadConfig({ freshSecrets: true }));
414
416
  scheduler.reloadConfig(
415
417
  getConfig().nonInteractive ?? [],
416
418
  getConfig().interactive ?? [],
@@ -419,7 +421,10 @@ async function reloadRuntimeConfig() {
419
421
  { restart: getBridgeRuntimeConnected() }
420
422
  );
421
423
  const nextBackend = createBackend(getConfig());
422
- const backendChanged = (nextBackend?.name || "") !== previousBackendName;
424
+ const backendTypeChanged = (nextBackend?.name || "") !== previousBackendName;
425
+ const credentialsChanged = !backendTypeChanged
426
+ && String(nextBackend?.token || "") !== String(previousBackend?.token || "");
427
+ const backendChanged = backendTypeChanged || credentialsChanged;
423
428
  if (backendChanged) {
424
429
  const shouldRestart = getBridgeRuntimeConnected() || bridgeRuntimeStarting;
425
430
  if (shouldRestart) await stopOwnedRuntime("getBackend() getConfig() changed");
@@ -439,19 +444,21 @@ async function reloadRuntimeConfig() {
439
444
  // backendChanged — same-getBackend() reloads keep their binding untouched.
440
445
  // (active-instance is cleared by stopOwnedRuntime on the restart path; we
441
446
  // don't re-advertise here to avoid resurrecting a just-cleared entry.)
442
- try { forwarder.stopWatch(); } catch {}
443
- forwarder.channelId = "";
444
- forwarder.transcriptPath = "";
445
- try {
446
- statusState.update((state) => {
447
- state.channelId = "";
448
- state.transcriptPath = "";
449
- });
450
- } catch {}
447
+ if (backendTypeChanged) {
448
+ try { forwarder.stopWatch(); } catch {}
449
+ forwarder.channelId = "";
450
+ forwarder.transcriptPath = "";
451
+ try {
452
+ statusState.update((state) => {
453
+ state.channelId = "";
454
+ state.transcriptPath = "";
455
+ });
456
+ } catch {}
457
+ }
451
458
  // stopOwnedRuntime above tore the owned runtime down; a same-session reload
452
459
  // reconnects the NEW backend here. The in-flight start (if any) has already
453
460
  // settled above, so this start is not dropped by the in-flight guard.
454
- if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
461
+ if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: !backendTypeChanged });
455
462
  } else if (nextBackend !== previousBackend) {
456
463
  try { await nextBackend.disconnect?.(); } catch {}
457
464
  }
@@ -35,6 +35,7 @@ function stableSnapshotJson(snapshot) {
35
35
  // Key: channelId Value: { label, latestSeenId, unseenCount }
36
36
  // No persistence across restarts — clean start is fine for v1.
37
37
  const _discordUnread = new Map();
38
+ const DISCORD_UNREAD_MAX_CHANNELS = 500;
38
39
 
39
40
  /**
40
41
  * Called whenever the backend observes messages for a channelId.
@@ -63,6 +64,14 @@ export function recordFetchedMessages(channelId, channelLabel, messages, options
63
64
  }
64
65
  // First call: zero unread (baseline, not retroactive).
65
66
 
67
+ // Retain the most recently observed channels only. Channel ids originate from
68
+ // inbound traffic and ad-hoc fetches, so an unbounded process-wide registry
69
+ // would otherwise grow for the worker lifetime.
70
+ if (_discordUnread.has(channelId)) {
71
+ _discordUnread.delete(channelId);
72
+ } else if (_discordUnread.size >= DISCORD_UNREAD_MAX_CHANNELS) {
73
+ _discordUnread.delete(_discordUnread.keys().next().value);
74
+ }
66
75
  _discordUnread.set(channelId, {
67
76
  label: channelLabel ?? channelId,
68
77
  latestSeenId: newLatestId,
@@ -82,14 +82,18 @@ function createToolDispatch({
82
82
  }
83
83
  case "reload_config": {
84
84
  await reloadRuntimeConfig();
85
- // Extend reload to the agent module so providers/presets/maintenance
86
- // hot-reload on the same call (dynamic import: agent/index.mjs does not
87
- // import channels, so this stays acyclic and tolerant of load order).
85
+ // Extend reload to the refactored agent runtime so providers/presets/
86
+ // maintenance hot-reload on the same call.
88
87
  let agentReloadMsg = "";
89
88
  if (process.env.MIXDOG_STANDALONE !== '1') {
90
89
  try {
91
- const { reloadAgentConfig } = await import("../../agent/index.mjs");
92
- await reloadAgentConfig("reload_config tool");
90
+ const [{ loadConfig }, { initProviders, refreshCatalogs }] = await Promise.all([
91
+ import("../../agent/orchestrator/config.mjs"),
92
+ import("../../agent/orchestrator/providers/registry.mjs"),
93
+ ]);
94
+ const agentConfig = loadConfig();
95
+ await initProviders(agentConfig.providers || {});
96
+ await refreshCatalogs();
93
97
  agentReloadMsg = ", agent providers/presets/maintenance";
94
98
  } catch (err) {
95
99
  process.stderr.write(`[reload_config] agent reload failed: ${err?.message || String(err)}\n`);
@@ -461,7 +461,19 @@ function wireWebhookHandlers() {
461
461
  // final event is forwarded to the Lead via the same channel-notify
462
462
  // path used by schedule & event-queue (injectAndRecord). Silent
463
463
  // lifecycle pings keep routing only to Discord.
464
- const agentMod = await import("../../agent/index.mjs");
464
+ const [{ createStandaloneAgent }, cfgMod, reg, mgr] = await Promise.all([
465
+ import("../../../standalone/agent-tool.mjs"),
466
+ import("../../agent/orchestrator/config.mjs"),
467
+ import("../../agent/orchestrator/providers/registry.mjs"),
468
+ import("../../agent/orchestrator/session/manager.mjs"),
469
+ ]);
470
+ const agentTool = createStandaloneAgent({
471
+ cfgMod,
472
+ reg,
473
+ mgr,
474
+ dataDir: cfgMod.getPluginData(),
475
+ cwd,
476
+ });
465
477
  const channelId = resolveWebhookChannelId(context?.channel);
466
478
  const endpoint = context?.endpoint || "unknown";
467
479
  const event = context?.event || null;
@@ -508,10 +520,9 @@ function wireWebhookHandlers() {
508
520
  // Falls back to the session entry position; never the plugin CACHE root.
509
521
  const ownerPid = getActiveOwnerPid(readActiveInstance());
510
522
  const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
511
- return agentMod.handleToolCall(
512
- "bridge",
513
- { role, preset, prompt, cwd: cwd || ownerCwd },
514
- { notifyFn },
523
+ return agentTool.execute(
524
+ { type: "spawn", agent: role, preset, prompt, cwd: cwd || ownerCwd },
525
+ { callerCwd: cwd || ownerCwd, invocationSource: "webhook", notifyFn },
515
526
  );
516
527
  });
517
528
  }