mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,238 @@
1
+ import { clean, hasOwn, sessionHasConversationMessages } from './session-text.mjs';
2
+ import { isKnownProvider } from '../standalone/provider-admin.mjs';
3
+ import {
4
+ normalizeWorkflowRoute,
5
+ upsertWorkflowPreset,
6
+ workflowPresetId,
7
+ WORKFLOW_ROUTE_SLOTS,
8
+ FIXED_AGENT_SLOTS,
9
+ agentPresetSlot,
10
+ normalizeAgentId,
11
+ normalizeWorkflowId,
12
+ DEFAULT_WORKFLOW_ID,
13
+ normalizeSearchRouteConfig,
14
+ } from './workflow.mjs';
15
+ import { ONBOARDING_VERSION } from './quick-search-models.mjs';
16
+ import { findOutputStyle } from './output-styles.mjs';
17
+ import { ensureProviderEnabled } from './config-helpers.mjs';
18
+ import { fastCapableFor, saveModelSettings } from './model-capabilities.mjs';
19
+
20
+ // Onboarding + agents/workflows/output-style selection surface. Extracted
21
+ // verbatim from the runtime API object; stateless helpers are imported directly
22
+ // and the runtime injects live getters/setters for the mutable config/route/
23
+ // session locals plus the closure callbacks.
24
+ export function createWorkflowAgentsApi(deps) {
25
+ const {
26
+ getConfig, getRoute, setRouteState, getSession, setSession, getConfigHasSecrets,
27
+ cfgMod, reg, mgr, STANDALONE_DATA_DIR,
28
+ resolveRoute, lookupModelMeta, adoptConfig, saveConfigAndAdopt, displayConfig,
29
+ agentRouteFromConfig, loadAgentDefinition, activeWorkflowId, listWorkflowPacks,
30
+ loadWorkflowPack, workflowSummary,
31
+ getOutputStyleStatusCached, seedOutputStyleStatusCache, scheduleOutputStyleSave,
32
+ recreateCurrentSessionIfReady, notifyFnForSession, invalidateContextStatusCache,
33
+ } = deps;
34
+ return {
35
+ async completeOnboarding(payload = {}) {
36
+ // Only fall back to the live runtime route when the caller actually sent a
37
+ // defaultRoute. The onboarding "partial save" path (Main left unset, only
38
+ // Search/agent picks) omits defaultRoute entirely and must NOT persist the
39
+ // current route as Main or recreate the session.
40
+ const config = getConfig();
41
+ const defaultRoute = hasOwn(payload, 'defaultRoute')
42
+ ? normalizeWorkflowRoute(payload.defaultRoute, getRoute())
43
+ : null;
44
+ const workflowInput = payload.workflowRoutes && typeof payload.workflowRoutes === 'object'
45
+ ? payload.workflowRoutes
46
+ : {};
47
+ const nextConfig = { ...config };
48
+ if (hasOwn(payload, 'defaultProvider')) {
49
+ const requested = clean(payload.defaultProvider);
50
+ if (requested) {
51
+ if (!isKnownProvider(requested)) throw new Error(`unknown provider "${payload.defaultProvider}"`);
52
+ nextConfig.defaultProvider = requested;
53
+ }
54
+ }
55
+ let presets = Array.isArray(nextConfig.presets) ? nextConfig.presets.slice() : [];
56
+ const workflowRoutes = { ...(nextConfig.workflowRoutes || {}) };
57
+ const touchedWorkflowSlots = new Set();
58
+
59
+ if (defaultRoute) {
60
+ presets = upsertWorkflowPreset(presets, 'lead', defaultRoute);
61
+ workflowRoutes.lead = defaultRoute;
62
+ nextConfig.default = workflowPresetId('lead');
63
+ }
64
+
65
+ for (const slot of WORKFLOW_ROUTE_SLOTS) {
66
+ const normalized = normalizeWorkflowRoute(workflowInput[slot]);
67
+ if (!normalized) continue;
68
+ workflowRoutes[slot] = normalized;
69
+ presets = upsertWorkflowPreset(presets, slot, normalized);
70
+ touchedWorkflowSlots.add(slot);
71
+ }
72
+
73
+ nextConfig.presets = presets;
74
+ nextConfig.workflowRoutes = workflowRoutes;
75
+ nextConfig.maintenance = {
76
+ ...(nextConfig.maintenance || {}),
77
+ ...(touchedWorkflowSlots.has('explorer') ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
78
+ ...(touchedWorkflowSlots.has('memory') ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
79
+ };
80
+ const agentInput = payload.agentRoutes && typeof payload.agentRoutes === 'object'
81
+ ? payload.agentRoutes
82
+ : null;
83
+ if (agentInput) {
84
+ const nextAgents = { ...(nextConfig.agents || {}) };
85
+ const nextMaintenance = { ...(nextConfig.maintenance || {}) };
86
+ for (const agent of FIXED_AGENT_SLOTS) {
87
+ const routeToSave = normalizeWorkflowRoute(agentInput[agent.id]);
88
+ if (!routeToSave) continue;
89
+ nextAgents[agent.id] = routeToSave;
90
+ presets = upsertWorkflowPreset(presets, agentPresetSlot(agent.id), routeToSave);
91
+ if (agent.workflowSlot) {
92
+ workflowRoutes[agent.workflowSlot] = routeToSave;
93
+ presets = upsertWorkflowPreset(presets, agent.workflowSlot, routeToSave);
94
+ if (agent.id === 'explore') nextMaintenance.explore = routeToSave;
95
+ if (agent.id === 'maintainer') nextMaintenance.memory = routeToSave;
96
+ }
97
+ }
98
+ nextConfig.agents = nextAgents;
99
+ nextConfig.presets = presets;
100
+ nextConfig.workflowRoutes = workflowRoutes;
101
+ nextConfig.maintenance = nextMaintenance;
102
+ }
103
+ nextConfig.onboarding = {
104
+ ...(nextConfig.onboarding || {}),
105
+ completed: true,
106
+ version: ONBOARDING_VERSION,
107
+ completedAt: new Date().toISOString(),
108
+ };
109
+
110
+ if (payload.searchRoute) {
111
+ const searchToSave = normalizeSearchRouteConfig(payload.searchRoute);
112
+ if (searchToSave) nextConfig.searchRoute = searchToSave;
113
+ }
114
+
115
+ saveConfigAndAdopt(nextConfig);
116
+ if (defaultRoute) {
117
+ setRouteState(resolveRoute(getConfig(), { provider: defaultRoute.provider, model: defaultRoute.model, effort: defaultRoute.effort }));
118
+ const session = getSession();
119
+ if (session?.id) mgr.closeSession(session.id, 'cli-onboarding-complete');
120
+ await recreateCurrentSessionIfReady();
121
+ }
122
+ return this.getOnboardingStatus();
123
+ },
124
+ listAgents() {
125
+ const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
126
+ const config = getConfig();
127
+ return FIXED_AGENT_SLOTS.map((agent) => ({
128
+ ...agent,
129
+ locked: true,
130
+ route: agentRouteFromConfig(config, agent.id, dataDir),
131
+ definition: loadAgentDefinition(dataDir, agent.id),
132
+ }));
133
+ },
134
+ listWorkflows() {
135
+ const currentConfig = displayConfig();
136
+ const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
137
+ const active = activeWorkflowId(currentConfig);
138
+ return listWorkflowPacks(dataDir).map((workflow) => ({
139
+ id: workflow.id,
140
+ name: workflow.name,
141
+ description: workflow.description,
142
+ source: workflow.source,
143
+ active: workflow.id === active,
144
+ agents: workflow.agents,
145
+ }));
146
+ },
147
+ getOutputStyle() {
148
+ return getOutputStyleStatusCached();
149
+ },
150
+ listOutputStyles() {
151
+ return getOutputStyleStatusCached();
152
+ },
153
+ async setOutputStyle(value) {
154
+ const before = getOutputStyleStatusCached({ fresh: true });
155
+ const selected = findOutputStyle(value, before.styles);
156
+ if (!selected) {
157
+ const names = before.styles.map((style) => style.label || style.id).join(', ') || 'Default';
158
+ throw new Error(`output style must be one of ${names}`);
159
+ }
160
+ // Adopt in-memory immediately so same-tick readers see the new style;
161
+ // persist off the key-handler tick via the flushOutputStyleSave debounce.
162
+ const nextConfig = { ...getConfig(), outputStyle: selected.id };
163
+ if (nextConfig.agent && typeof nextConfig.agent === 'object' && !Array.isArray(nextConfig.agent)) {
164
+ const agent = { ...nextConfig.agent };
165
+ delete agent.outputStyle;
166
+ nextConfig.agent = agent;
167
+ }
168
+ adoptConfig(nextConfig);
169
+ scheduleOutputStyleSave(selected.id);
170
+ const freshStatus = { configured: selected.id, current: selected, styles: before.styles };
171
+ seedOutputStyleStatusCache(freshStatus);
172
+ const session = getSession();
173
+ const hasConversation = sessionHasConversationMessages(session);
174
+ let appliedToCurrentSession = !hasConversation;
175
+ if (session?.id && !hasConversation) {
176
+ const closedSessionId = session.id;
177
+ mgr.closeSession(closedSessionId, 'cli-output-style-switch');
178
+ setSession(null);
179
+ setTimeout(() => {
180
+ recreateCurrentSessionIfReady().catch((err) => {
181
+ try {
182
+ notifyFnForSession(closedSessionId)(
183
+ `Failed to start a new session after output style change: ${err?.message || err}`,
184
+ { level: 'error' },
185
+ );
186
+ } catch {}
187
+ });
188
+ }, 0);
189
+ }
190
+ invalidateContextStatusCache();
191
+ return { ...freshStatus, appliedToCurrentSession };
192
+ },
193
+ async setWorkflow(workflowId) {
194
+ const id = normalizeWorkflowId(workflowId, DEFAULT_WORKFLOW_ID);
195
+ const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
196
+ const pack = loadWorkflowPack(dataDir, id);
197
+ if (!pack || pack.id !== id) throw new Error(`workflow "${workflowId}" not found`);
198
+ const nextConfig = { ...getConfig() };
199
+ nextConfig.workflow = { ...(nextConfig.workflow || {}), active: id };
200
+ saveConfigAndAdopt(nextConfig);
201
+ return workflowSummary(pack);
202
+ },
203
+ async setAgentRoute(agentId, next) {
204
+ const id = normalizeAgentId(agentId);
205
+ if (!id) throw new Error(`unknown agent "${agentId}"`);
206
+ let selectedRoute = resolveRoute(getConfig(), { ...(next || {}) });
207
+ await reg.initProviders(ensureProviderEnabled(getConfig(), selectedRoute.provider));
208
+ const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
209
+ const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
210
+ selectedRoute = { ...selectedRoute, fast: fastCapable ? selectedRoute.fast === true : false };
211
+ adoptConfig(saveModelSettings(cfgMod, selectedRoute, { fastCapable, baseConfig: getConfig() }), { hasSecrets: getConfigHasSecrets() });
212
+
213
+ const routeToSave = normalizeWorkflowRoute(selectedRoute);
214
+ if (!routeToSave) throw new Error('agent route requires provider and model');
215
+ const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
216
+ const nextConfig = { ...getConfig() };
217
+ nextConfig.agents = {
218
+ ...(nextConfig.agents || {}),
219
+ [id]: routeToSave,
220
+ };
221
+ nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, agentPresetSlot(id), routeToSave);
222
+ if (agent?.workflowSlot) {
223
+ nextConfig.workflowRoutes = {
224
+ ...(nextConfig.workflowRoutes || {}),
225
+ [agent.workflowSlot]: routeToSave,
226
+ };
227
+ nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, agent.workflowSlot, routeToSave);
228
+ nextConfig.maintenance = {
229
+ ...(nextConfig.maintenance || {}),
230
+ ...(id === 'explore' ? { explore: routeToSave } : {}),
231
+ ...(id === 'maintainer' ? { memory: routeToSave } : {}),
232
+ };
233
+ }
234
+ saveConfigAndAdopt(nextConfig);
235
+ return routeToSave;
236
+ },
237
+ };
238
+ }
@@ -99,14 +99,14 @@ const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIME
99
99
  // Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
100
100
  // first provider calls in the same instant, racing the server-side prompt-
101
101
  // cache write/propagation window. Bench (parallel-10, identical-ms starts):
102
- // 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%. 2000ms default
102
+ // 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%. 1000ms default
103
103
  // picked as a low-cost middle ground. Chain (not a fixed lane count) so it
104
104
  // scales to any N: each new spawn's start is pushed to at least STAGGER_MS
105
105
  // after the previous spawn's start; sequential/non-overlapping spawns (now
106
106
  // already past the window) pay zero added latency. MIXDOG_SPAWN_STAGGER_MS=0
107
107
  // disables. Applied inside the deferred job body (see startDeferredSpawnJob)
108
108
  // so the agent tool call itself still returns task_id immediately.
109
- const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 2000);
109
+ const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 1000);
110
110
  let lastSpawnStartAt = 0;
111
111
  async function waitForSpawnStagger() {
112
112
  if (SPAWN_STAGGER_MS <= 0) return;
@@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { startChildGuardian } from '../runtime/shared/child-guardian.mjs';
7
7
  import { appendBuffered } from '../runtime/shared/buffered-appender.mjs';
8
8
  import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.mjs';
9
+ import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
9
10
 
10
11
  const CHANNEL_TOOLS = new Set([
11
12
  'reply',
@@ -98,6 +99,9 @@ export function createStandaloneChannelWorker({
98
99
  let inFlightToken = 0;
99
100
  let inFlightWatchdog = null;
100
101
  const logPath = join(dataDir, 'channels-worker-standalone.log');
102
+ // One-shot bound at own-process boot: this runtime may never pass through
103
+ // the channels-worker rotation path, so cap the log writer-side.
104
+ rotateBoundedLog(logPath, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES);
101
105
  const useProcessWorker = process.env.MIXDOG_CHANNEL_WORKER_PROCESS !== '0';
102
106
  const clientDir = join(runtimeRoot(), 'channel-clients');
103
107
  const clientPath = join(clientDir, `${process.pid}.json`);
@@ -248,6 +252,13 @@ export function createStandaloneChannelWorker({
248
252
  MIXDOG_STANDALONE: '1',
249
253
  MIXDOG_WORKER_MODE: '1',
250
254
  MIXDOG_CLI_OWNED: '0',
255
+ // Preserve the real terminal-lead PID (host TUI, or an outer run-mcp
256
+ // supervisor if one injected MIXDOG_SUPERVISOR_PID) through the fork.
257
+ // Without this the worker resolves getTerminalLeadPid() to its OWN pid,
258
+ // so the seat's ownerLeadPid tracks the headless worker instead of the
259
+ // terminal that owns it — and the seat can never be evicted when the
260
+ // owning terminal/TUI dies while the worker stays alive.
261
+ MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
251
262
  MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
252
263
  },
253
264
  windowsHide: true,
@@ -412,6 +423,12 @@ export function createStandaloneChannelWorker({
412
423
 
413
424
  function uninstallParentExitHook() {
414
425
  if (!parentExitCleanup) return;
426
+ // Refcount-aware: never strip the shared parent-exit protection while any
427
+ // owned child PID is still tracked. A newer worker may have been spawned
428
+ // (new PID added, hook install is a no-op because it is already present)
429
+ // before an older worker finishes its async teardown; letting that old
430
+ // teardown uninstall here would leave the live newer PID unprotected.
431
+ if (ownedChildPids.size > 0) return;
415
432
  const unregister = parentExitCleanup;
416
433
  parentExitCleanup = null;
417
434
  unregister();
@@ -454,27 +471,70 @@ export function createStandaloneChannelWorker({
454
471
  child = null;
455
472
  if (!waitForExit) {
456
473
  rejectPending(new Error(`channels runtime shutdown requested (${reason})`));
457
- if (targetPid) ownedChildPids.delete(targetPid);
474
+ // Fast/detached path is still TERMINAL: the caller does not block on the
475
+ // full teardown, but a background escalation ladder guarantees the worker
476
+ // dies so no zombie survives. Exit-hook + PID tracking stay installed
477
+ // until the process ACTUALLY exits (not on IPC ack), so a parent that
478
+ // force-exits mid-grace still force-kills the tree via the exit cleanup.
479
+ let torn = false;
480
+ const teardown = () => {
481
+ if (torn) return;
482
+ torn = true;
483
+ clearTimeout(termTimer);
484
+ clearTimeout(killTimer);
485
+ if (targetPid) ownedChildPids.delete(targetPid);
486
+ unrefChildHandles(target);
487
+ uninstallParentExitHook();
488
+ };
489
+ // Bounded grace after IPC shutdown, then SIGTERM.
490
+ const termTimer = setTimeout(() => {
491
+ try {
492
+ if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
493
+ } catch {}
494
+ }, 1500);
495
+ // Hard fallback: taskkill /T /F the whole tree, then SIGKILL the handle.
496
+ const killTimer = setTimeout(() => {
497
+ try {
498
+ if (target.exitCode == null) forceKillTree(targetPid);
499
+ } catch {}
500
+ try {
501
+ if (target.exitCode == null && !target.killed) target.kill('SIGKILL');
502
+ } catch {}
503
+ teardown();
504
+ }, 3000);
505
+ // Unref so these background escalation timers never keep the event loop
506
+ // (or a pending /exit) alive waiting out the grace/hard-kill window.
507
+ termTimer.unref?.();
508
+ killTimer.unref?.();
509
+ // Actual exit (or spawn error) is the only thing that tears down tracking.
510
+ target.once('exit', teardown);
511
+ target.once('error', teardown);
458
512
  stopPromise = new Promise((resolve) => {
459
513
  let settled = false;
460
- const finish = (ok) => {
514
+ const settle = (ok) => {
461
515
  if (settled) return;
462
516
  settled = true;
463
517
  clearTimeout(sendTimer);
464
518
  stopPromise = null;
465
- unrefChildHandles(target);
466
- uninstallParentExitHook();
467
519
  resolve(ok);
468
520
  };
469
- const sendTimer = setTimeout(() => finish(false), 250);
521
+ // Resolve the caller quickly once the IPC shutdown is acked/timed out;
522
+ // the escalation ladder above keeps running in the background.
523
+ const sendTimer = setTimeout(() => settle(true), 250);
524
+ sendTimer.unref?.();
525
+ target.once('exit', () => settle(true));
470
526
  try {
471
527
  target.send?.({ type: 'shutdown', reason }, () => {
472
528
  try { target.disconnect?.(); } catch {}
473
- finish(true);
529
+ settle(true);
474
530
  });
475
531
  } catch {
476
532
  try { target.disconnect?.(); } catch {}
477
- finish(false);
533
+ // IPC unavailable: skip the grace window and escalate now.
534
+ try {
535
+ if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
536
+ } catch {}
537
+ settle(false);
478
538
  }
479
539
  });
480
540
  return stopPromise;
@@ -232,7 +232,10 @@ namespace Mixdog {
232
232
  }
233
233
  }
234
234
  '@`,
235
- 'Add-Type -TypeDefinition $code -ReferencedAssemblies System.Windows.Forms | Out-Null',
235
+ // System.Drawing is required too: GetDialogOwnerCenter uses
236
+ // Screen.PrimaryScreen.WorkingArea (System.Drawing.Rectangle) — without it
237
+ // the Add-Type compile fails and the dialog silently falls back to typing.
238
+ 'Add-Type -TypeDefinition $code -ReferencedAssemblies System.Windows.Forms,System.Drawing | Out-Null',
236
239
  // Invisible TopMost owner anchored to the TUI console (or foreground window)
237
240
  // so IFileOpenDialog is modal, centered, and not detached on another monitor.
238
241
  '$owner = New-Object System.Windows.Forms.Form',
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
6
6
  import { pathToFileURL } from 'node:url';
7
7
  import { claimSingletonOwner, handoffSingletonOwner, readSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
8
8
  import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.mjs';
9
+ import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
9
10
 
10
11
  function logLine(path, line) {
11
12
  try {
@@ -56,6 +57,19 @@ function delay(ms) {
56
57
  }
57
58
 
58
59
  const TRANSIENT_MEMORY_RPC_BACKOFF_MS = 400;
60
+ // A child that dies during startup with one of these signatures is a hard,
61
+ // deterministic failure (bad entry path, syntax/require error) — never a
62
+ // transient owner-lock race. Surface it fast instead of burning the ready-wait.
63
+ function looksLikeStartupCrash(text) {
64
+ // Only genuinely deterministic loader/parse failures. Runtime errors like
65
+ // TypeError/ReferenceError can be transient init races — matching them would
66
+ // poison crashState and cache a hard failure for a recoverable spawn.
67
+ return /Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|SyntaxError/i.test(String(text || ''));
68
+ }
69
+ // Once a spawn crashes deterministically, short-circuit re-forks for this
70
+ // window so a persistent crash-loop returns the cached reason immediately
71
+ // instead of paying spawn+ready-wait on every call.
72
+ const MEMORY_CRASH_COOLDOWN_MS = Math.max(0, Number(process.env.MIXDOG_MEMORY_CRASH_COOLDOWN_MS) || 30_000);
59
73
 
60
74
  function isConnResetLikeError(err) {
61
75
  const code = String(err?.code || '');
@@ -67,7 +81,7 @@ function isConnResetLikeError(err) {
67
81
 
68
82
  function isMemoryWorkerNotReadyError(err) {
69
83
  const msg = String(err?.message || err || '');
70
- return /memory worker exited before ready|memory worker ready timeout|memory runtime did not become ready|memory worker degraded/i.test(msg);
84
+ return /memory worker exited before ready|memory worker ready timeout|memory runtime did not become ready|memory worker degraded|memory worker draining/i.test(msg);
71
85
  }
72
86
 
73
87
  function isTransientMemoryRpcError(err) {
@@ -141,6 +155,9 @@ export function createStandaloneMemoryRuntime({
141
155
  if (!dataDir) throw new Error('memory runtime dataDir is required');
142
156
 
143
157
  const logPath = join(dataDir, 'memory-runtime-proxy.log');
158
+ // One-shot bound at own-process boot: this runtime may never pass through
159
+ // the channels-worker rotation path, so cap the log writer-side.
160
+ rotateBoundedLog(logPath, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES);
144
161
  const ownerPath = join(dataDir, 'memory-runtime-owner.json');
145
162
  const singletonEnabled = process.env.MIXDOG_MEMORY_SINGLETON !== '0';
146
163
  const idleTtlMs = Math.max(0, Number(process.env.MIXDOG_MEMORY_IDLE_TTL_MS) || 10 * 60_000);
@@ -148,9 +165,68 @@ export function createStandaloneMemoryRuntime({
148
165
  let startPromise = null;
149
166
  let child = null;
150
167
  let nextCallId = 1;
168
+ let crashState = null; // { reason, at } — cached deterministic spawn crash
169
+ // Port we have registered this proxy pid with (so the shared daemon can
170
+ // reap itself promptly once every client deregisters). Re-registers when
171
+ // the daemon respawns on a new port.
172
+ let registeredWithPort = null;
173
+
174
+ async function ensureClientRegistered(port) {
175
+ // Returns the port the pending RPC should target. When an internal respawn
176
+ // happens the daemon moves to a fresh port, so the caller MUST use the
177
+ // returned value rather than the port it captured before registering.
178
+ if (!port || registeredWithPort === port) return port;
179
+ // The register RPC is provably side-effect-free from the caller's point of
180
+ // view: a refused/reset connection means the daemon never received it, and
181
+ // a draining 503 means it refused it. So a register-phase transient is ALWAYS
182
+ // safe to recover from by respawning a fresh daemon and retrying — even for a
183
+ // pending WRITE RPC. We do that respawn-and-retry HERE, before the write RPC
184
+ // is ever attempted, rather than leaning on the outer retry (which must not
185
+ // blanket-retry refused/reset on the write RPC itself).
186
+ let curPort = port;
187
+ for (let attempt = 0; ; attempt++) {
188
+ try {
189
+ await requestJson({
190
+ port: curPort,
191
+ method: 'POST',
192
+ path: '/client/register',
193
+ body: { clientPid: process.pid },
194
+ timeoutMs: 2000,
195
+ });
196
+ registeredWithPort = curPort;
197
+ return curPort;
198
+ } catch (err) {
199
+ // Benign failures (e.g. request timeout against a busy-but-live daemon)
200
+ // are not fatal: registration is best-effort and the idle TTL backstops
201
+ // a missed register. Only recover from genuine "daemon is gone/dying".
202
+ if (!isTransientMemoryRpcError(err)) return curPort;
203
+ if (attempt >= 3) throw err;
204
+ invalidateMemoryRuntimeAfterTransient(err);
205
+ await delay(TRANSIENT_MEMORY_RPC_BACKOFF_MS);
206
+ const started = await start();
207
+ curPort = started.port;
208
+ }
209
+ }
210
+ }
211
+
212
+ async function deregisterClient() {
213
+ const port = registeredWithPort || portCache;
214
+ registeredWithPort = null;
215
+ if (!port) return;
216
+ try {
217
+ await requestJson({
218
+ port,
219
+ method: 'POST',
220
+ path: '/client/deregister',
221
+ body: { clientPid: process.pid },
222
+ timeoutMs: 1500,
223
+ });
224
+ } catch { /* best-effort; sweep + idle TTL reap us anyway */ }
225
+ }
151
226
 
152
227
  function invalidateMemoryRuntimeAfterTransient(err) {
153
228
  portCache = null;
229
+ registeredWithPort = null;
154
230
  if (!isMemoryWorkerNotReadyError(err)) return;
155
231
  startPromise = null;
156
232
  const proc = child;
@@ -244,25 +320,61 @@ export function createStandaloneMemoryRuntime({
244
320
  async function start() {
245
321
  if (portCache) {
246
322
  const port = await findLivePort();
247
- if (port) return { running: true, port, mode: 'http-proxy' };
323
+ if (port) { crashState = null; return { running: true, port, mode: 'http-proxy' }; }
248
324
  portCache = null;
249
325
  }
250
326
  const existing = await findLivePort();
251
- if (existing) return { running: true, port: existing, mode: 'http-proxy' };
327
+ if (existing) { crashState = null; return { running: true, port: existing, mode: 'http-proxy' }; }
328
+ // Persistent crash-loop guard: no live daemon and a recent deterministic
329
+ // spawn crash → fail fast with the cached reason, don't re-fork per call.
330
+ // But a healthy singleton owner may be mid-boot and not yet advertising a
331
+ // port; probe/await it before trusting cached crashState so a recovering
332
+ // daemon isn't handed a stale hard failure.
333
+ if (crashState && Date.now() - crashState.at < MEMORY_CRASH_COOLDOWN_MS) {
334
+ if (singletonEnabled) {
335
+ const owner = readSingletonOwner(ownerPath);
336
+ if (owner.alive) {
337
+ const live = await waitForPort(15_000);
338
+ crashState = null;
339
+ return { running: true, port: live, mode: 'http-proxy' };
340
+ }
341
+ }
342
+ throw new Error(crashState.reason);
343
+ }
252
344
  if (startPromise) {
253
345
  const port = await startPromise;
254
346
  return { running: true, port, mode: 'http-proxy' };
255
347
  }
256
348
 
257
349
  startPromise = (async () => {
258
- const claim = claimOwner();
350
+ let claim = claimOwner();
259
351
  if (!claim.owned) {
260
- const owner = readSingletonOwner(ownerPath);
261
- if (owner.alive) {
262
- const live = await waitForPort(30_000);
263
- return live;
352
+ // Another owner holds the singleton. If it is live AND serving a
353
+ // healthy port, use it. If it is live but NOT healthy (a daemon that is
354
+ // draining/shutting down never revives — see getDraining/health in
355
+ // lib/http-router.mjs), poll: reclaim the instant the old owner exits so
356
+ // a quick restart still ends with a fresh, working daemon instead of
357
+ // binding to the dying one and failing the pending RPC.
358
+ const deadline = Date.now() + 30_000;
359
+ while (Date.now() < deadline) {
360
+ const livePort = await findLivePort({ allowStarting: true });
361
+ if (livePort) {
362
+ try {
363
+ const health = await requestJson({ port: livePort, path: '/health', timeoutMs: 1500 });
364
+ if (health?.status === 'ok') return livePort;
365
+ } catch { /* dying/unreachable — fall through to reclaim */ }
366
+ }
367
+ const reclaim = claimOwner();
368
+ if (reclaim.owned) { claim = reclaim; break; }
369
+ await delay(150);
370
+ }
371
+ if (!claim.owned) {
372
+ const owner = readSingletonOwner(ownerPath);
373
+ if (owner.alive) throw new Error('memory runtime did not become ready');
374
+ releaseOwnerIfSelf();
375
+ claim = claimOwner();
376
+ if (!claim.owned) throw new Error('memory runtime did not become ready');
264
377
  }
265
- releaseOwnerIfSelf();
266
378
  }
267
379
 
268
380
  const daemonEnv = { ...process.env };
@@ -309,14 +421,18 @@ export function createStandaloneMemoryRuntime({
309
421
  meta: { cwd, launcherPid: process.pid },
310
422
  });
311
423
  }
424
+ let stderrTail = '';
312
425
  child.stderr?.on('data', chunk => {
313
- const text = String(chunk || '').trimEnd();
314
- if (text) logLine(logPath, text);
426
+ const text = String(chunk || '');
427
+ const trimmed = text.trimEnd();
428
+ if (trimmed) logLine(logPath, trimmed);
429
+ stderrTail = (stderrTail + text).slice(-4000);
315
430
  });
316
431
  child.on('exit', () => {
317
432
  if (singletonEnabled && childPid) releaseSingletonOwner(ownerPath, childPid);
318
433
  if (child?.pid === childPid) child = null;
319
434
  portCache = null;
435
+ registeredWithPort = null;
320
436
  });
321
437
 
322
438
  const ready = new Promise((resolveReady, rejectReady) => {
@@ -332,24 +448,37 @@ export function createStandaloneMemoryRuntime({
332
448
  });
333
449
  child.once('exit', (code, signal) => {
334
450
  clearTimeout(timer);
335
- rejectReady(new Error(`memory worker exited before ready (${signal || code || 'unknown'})`));
451
+ const tail = stderrTail.trim().split('\n').slice(-8).join('\n');
452
+ const detail = tail ? `: ${tail}` : '';
453
+ const err = new Error(`memory worker exited before ready (${signal || code || 'unknown'})${detail}`);
454
+ err.stderrTail = tail;
455
+ rejectReady(err);
336
456
  });
337
457
  });
338
458
 
339
459
  try {
340
460
  await ready;
341
461
  } catch (err) {
462
+ // A deterministic startup crash (bad entry path -> MODULE_NOT_FOUND,
463
+ // syntax/require errors) is not an owner-lock race: cache the reason
464
+ // for the cooldown window and fail immediately with the stderr tail
465
+ // instead of burning waitForPort() on a child that will never publish.
466
+ const msg = String(err?.message || err || '');
467
+ if (looksLikeStartupCrash(err?.stderrTail || msg)) {
468
+ crashState = { reason: msg, at: Date.now() };
469
+ throw err;
470
+ }
342
471
  // Loser fallback: two proxies (TUI host vs channels worker) can race to
343
472
  // fork; the child that lost the owner-lock exits before ready. Instead
344
473
  // of propagating "exited before ready" (which would surface as no
345
474
  // daemon), wait for the WINNER's daemon to publish a live port and use
346
475
  // it. Only rethrow if no live daemon appears in the window.
347
- const msg = String(err?.message || err || '');
348
476
  const raceLoss = /exited before ready|degraded|ready timeout|owner lock|lock/i.test(msg);
349
477
  if (!raceLoss) throw err;
350
478
  return await waitForPort(30_000);
351
479
  }
352
480
  const port = await waitForPort(15_000);
481
+ crashState = null;
353
482
  try { child.disconnect?.(); } catch {}
354
483
  try { child.unref?.(); } catch {}
355
484
  try { child.stderr?.unref?.(); } catch {}
@@ -367,7 +496,11 @@ export function createStandaloneMemoryRuntime({
367
496
  const callId = `mem_${process.pid}_${nextCallId++}`;
368
497
  return await withTransientMemoryRpcRetry(async () => {
369
498
  await start();
370
- const port = portCache || await findLivePort({ allowStarting: true });
499
+ let port = portCache || await findLivePort({ allowStarting: true });
500
+ if (!port) throw new Error('memory runtime is not available');
501
+ // ensureClientRegistered may respawn onto a fresh daemon/port; target the
502
+ // port it hands back so the RPC and registration always hit the same one.
503
+ port = await ensureClientRegistered(port);
371
504
  if (!port) throw new Error('memory runtime is not available');
372
505
  return await requestJson({
373
506
  port,
@@ -383,7 +516,9 @@ export function createStandaloneMemoryRuntime({
383
516
  async function buildSessionCoreMemoryPayload(sessionCwd) {
384
517
  return await withTransientMemoryRpcRetry(async () => {
385
518
  await start();
386
- const port = portCache || await findLivePort({ allowStarting: true });
519
+ let port = portCache || await findLivePort({ allowStarting: true });
520
+ if (!port) throw new Error('memory runtime is not available');
521
+ port = await ensureClientRegistered(port);
387
522
  if (!port) throw new Error('memory runtime is not available');
388
523
  return await requestJson({
389
524
  port,
@@ -396,8 +531,10 @@ export function createStandaloneMemoryRuntime({
396
531
  }
397
532
 
398
533
  async function stop() {
399
- // Detach only. The daemon owns its own idle lifetime; CLI shutdown must not
400
- // tear it down out from under another tab.
534
+ // Deregister this client so a shared daemon can reap itself within the
535
+ // seconds-scale client grace once no clients remain, then detach. We never
536
+ // hard-kill the daemon here — another tab/session may still be using it.
537
+ await deregisterClient();
401
538
  try { child?.disconnect?.(); } catch {}
402
539
  try { child?.unref?.(); } catch {}
403
540
  child = null;