mixdog 0.9.19 → 0.9.20

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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -29
  3. package/package.json +3 -2
  4. package/scripts/build-runtime-windows.ps1 +242 -242
  5. package/scripts/build-tui.mjs +6 -0
  6. package/scripts/hook-bus-test.mjs +8 -0
  7. package/scripts/log-writer-guard-smoke.mjs +131 -0
  8. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  9. package/scripts/recall-bench-cases.json +0 -1
  10. package/scripts/recall-quality-cases.json +1 -2
  11. package/scripts/recall-usecase-cases.json +1 -1
  12. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  13. package/scripts/smoke-runtime-negative.ps1 +106 -106
  14. package/scripts/tool-efficiency-diag.mjs +1 -1
  15. package/scripts/tool-smoke.mjs +150 -45
  16. package/src/help.mjs +2 -5
  17. package/src/lib/mixdog-debug.cjs +13 -0
  18. package/src/mixdog-session-runtime.mjs +7 -3328
  19. package/src/rules/lead/02-channels.md +3 -3
  20. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  22. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  23. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  24. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  25. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  26. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  27. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  28. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  29. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  30. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  31. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  32. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  33. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  34. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  35. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  37. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  42. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  43. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  44. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  45. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  47. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  48. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  49. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  52. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  54. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  55. package/src/runtime/channels/index.mjs +6 -2183
  56. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  57. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  58. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  59. package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
  60. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  61. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  62. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  63. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  64. package/src/runtime/channels/lib/worker-main.mjs +771 -0
  65. package/src/runtime/memory/index.mjs +73 -1725
  66. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  67. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  68. package/src/runtime/memory/lib/http-router.mjs +772 -0
  69. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  70. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  71. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  72. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  73. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  74. package/src/session-runtime/boot-profile.mjs +36 -0
  75. package/src/session-runtime/channel-config-api.mjs +70 -0
  76. package/src/session-runtime/context-status.mjs +181 -0
  77. package/src/session-runtime/env.mjs +17 -0
  78. package/src/session-runtime/lifecycle-api.mjs +242 -0
  79. package/src/session-runtime/model-route-api.mjs +198 -0
  80. package/src/session-runtime/provider-auth-api.mjs +135 -0
  81. package/src/session-runtime/resource-api.mjs +282 -0
  82. package/src/session-runtime/runtime-core.mjs +2046 -0
  83. package/src/session-runtime/session-turn-api.mjs +274 -0
  84. package/src/session-runtime/tool-catalog.mjs +18 -264
  85. package/src/session-runtime/tool-defs.mjs +2 -2
  86. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  87. package/src/standalone/agent-tool.mjs +2 -2
  88. package/src/standalone/channel-worker.mjs +4 -0
  89. package/src/standalone/memory-runtime-proxy.mjs +56 -6
  90. package/src/tui/App.jsx +39 -28
  91. package/src/tui/app/core-memory-picker.mjs +1 -1
  92. package/src/tui/app/use-mouse-input.mjs +6 -0
  93. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  94. package/src/tui/dist/index.mjs +1990 -1527
  95. package/src/tui/engine/context-state.mjs +145 -0
  96. package/src/tui/engine/prompt-history.mjs +27 -0
  97. package/src/tui/engine/session-api-ext.mjs +478 -0
  98. package/src/tui/engine/session-api.mjs +545 -0
  99. package/src/tui/engine/session-flow.mjs +485 -0
  100. package/src/tui/engine/turn.mjs +1078 -0
  101. package/src/tui/engine.mjs +68 -2620
  102. package/src/tui/index.jsx +7 -0
  103. package/vendor/ink/build/ink.js +16 -1
  104. package/vendor/ink/build/output.js +30 -4
  105. package/vendor/ink/build/render.js +5 -0
  106. 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`);
@@ -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 || '');
@@ -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,6 +165,7 @@ 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
151
169
 
152
170
  function invalidateMemoryRuntimeAfterTransient(err) {
153
171
  portCache = null;
@@ -244,11 +262,27 @@ export function createStandaloneMemoryRuntime({
244
262
  async function start() {
245
263
  if (portCache) {
246
264
  const port = await findLivePort();
247
- if (port) return { running: true, port, mode: 'http-proxy' };
265
+ if (port) { crashState = null; return { running: true, port, mode: 'http-proxy' }; }
248
266
  portCache = null;
249
267
  }
250
268
  const existing = await findLivePort();
251
- if (existing) return { running: true, port: existing, mode: 'http-proxy' };
269
+ if (existing) { crashState = null; return { running: true, port: existing, mode: 'http-proxy' }; }
270
+ // Persistent crash-loop guard: no live daemon and a recent deterministic
271
+ // spawn crash → fail fast with the cached reason, don't re-fork per call.
272
+ // But a healthy singleton owner may be mid-boot and not yet advertising a
273
+ // port; probe/await it before trusting cached crashState so a recovering
274
+ // daemon isn't handed a stale hard failure.
275
+ if (crashState && Date.now() - crashState.at < MEMORY_CRASH_COOLDOWN_MS) {
276
+ if (singletonEnabled) {
277
+ const owner = readSingletonOwner(ownerPath);
278
+ if (owner.alive) {
279
+ const live = await waitForPort(15_000);
280
+ crashState = null;
281
+ return { running: true, port: live, mode: 'http-proxy' };
282
+ }
283
+ }
284
+ throw new Error(crashState.reason);
285
+ }
252
286
  if (startPromise) {
253
287
  const port = await startPromise;
254
288
  return { running: true, port, mode: 'http-proxy' };
@@ -309,9 +343,12 @@ export function createStandaloneMemoryRuntime({
309
343
  meta: { cwd, launcherPid: process.pid },
310
344
  });
311
345
  }
346
+ let stderrTail = '';
312
347
  child.stderr?.on('data', chunk => {
313
- const text = String(chunk || '').trimEnd();
314
- if (text) logLine(logPath, text);
348
+ const text = String(chunk || '');
349
+ const trimmed = text.trimEnd();
350
+ if (trimmed) logLine(logPath, trimmed);
351
+ stderrTail = (stderrTail + text).slice(-4000);
315
352
  });
316
353
  child.on('exit', () => {
317
354
  if (singletonEnabled && childPid) releaseSingletonOwner(ownerPath, childPid);
@@ -332,24 +369,37 @@ export function createStandaloneMemoryRuntime({
332
369
  });
333
370
  child.once('exit', (code, signal) => {
334
371
  clearTimeout(timer);
335
- rejectReady(new Error(`memory worker exited before ready (${signal || code || 'unknown'})`));
372
+ const tail = stderrTail.trim().split('\n').slice(-8).join('\n');
373
+ const detail = tail ? `: ${tail}` : '';
374
+ const err = new Error(`memory worker exited before ready (${signal || code || 'unknown'})${detail}`);
375
+ err.stderrTail = tail;
376
+ rejectReady(err);
336
377
  });
337
378
  });
338
379
 
339
380
  try {
340
381
  await ready;
341
382
  } catch (err) {
383
+ // A deterministic startup crash (bad entry path -> MODULE_NOT_FOUND,
384
+ // syntax/require errors) is not an owner-lock race: cache the reason
385
+ // for the cooldown window and fail immediately with the stderr tail
386
+ // instead of burning waitForPort() on a child that will never publish.
387
+ const msg = String(err?.message || err || '');
388
+ if (looksLikeStartupCrash(err?.stderrTail || msg)) {
389
+ crashState = { reason: msg, at: Date.now() };
390
+ throw err;
391
+ }
342
392
  // Loser fallback: two proxies (TUI host vs channels worker) can race to
343
393
  // fork; the child that lost the owner-lock exits before ready. Instead
344
394
  // of propagating "exited before ready" (which would surface as no
345
395
  // daemon), wait for the WINNER's daemon to publish a live port and use
346
396
  // it. Only rethrow if no live daemon appears in the window.
347
- const msg = String(err?.message || err || '');
348
397
  const raceLoss = /exited before ready|degraded|ready timeout|owner lock|lock/i.test(msg);
349
398
  if (!raceLoss) throw err;
350
399
  return await waitForPort(30_000);
351
400
  }
352
401
  const port = await waitForPort(15_000);
402
+ crashState = null;
353
403
  try { child.disconnect?.(); } catch {}
354
404
  try { child.unref?.(); } catch {}
355
405
  try { child.stderr?.unref?.(); } catch {}
package/src/tui/App.jsx CHANGED
@@ -845,34 +845,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
845
845
  }, 2200);
846
846
  }, []);
847
847
 
848
- // Copy the currently-highlighted selection to the OS clipboard. ink's fork
849
- // refreshed store.getRenderSelectionText() on the synchronous render that the
850
- // final setSelection() triggered, so the selected text is ready to read.
851
- const copySelection = useCallback((attempt = 0) => {
852
- const renderText = store.getRenderSelectionText?.();
853
- const remembered = selectionTextRef.current || '';
854
- // A selection that has partially scrolled out of the viewport renders —
855
- // and therefore harvests — only its visible rows. The remembered text
856
- // (captured while the selection was last fully painted) is the fuller
857
- // copy; prefer whichever is longer so scrolling never shrinks a copy.
858
- const text = renderText == null
859
- ? remembered
860
- : (remembered.length > renderText.length ? remembered : renderText);
861
- if ((!text || !text.trim()) && attempt < 4) {
862
- setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
863
- return;
864
- }
865
- if (!text || !text.trim()) return;
866
- selectionTextRef.current = text;
867
- copyToClipboard(text)
868
- .then(() => {
869
- const lines = text.split('\n').length;
870
- const chars = text.length;
871
- showSelectionCopyHint(`copied ${chars} char${chars === 1 ? '' : 's'}${lines > 1 ? ` · ${lines} lines` : ''}`, 'plain');
872
- })
873
- .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
874
- }, [store, showSelectionCopyHint]);
875
-
876
848
  // ── Post-mount input gate ──────────────────────────────────────────────
877
849
  // Let one event-loop poll pass so Ink processes (and discards, because
878
850
  // PromptInput is still disabled) any keystrokes queued during boot/first
@@ -1047,6 +1019,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1047
1019
  scrollTranscriptRows,
1048
1020
  queueScrollCoalesced,
1049
1021
  moveSelectionFocus,
1022
+ getStitchedSelectionText,
1023
+ clearStitchBuffer,
1050
1024
  } = useTranscriptScroll({
1051
1025
  store,
1052
1026
  frameColumns,
@@ -1067,6 +1041,42 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1067
1041
  selectionTextRef,
1068
1042
  });
1069
1043
 
1044
+ // Copy the currently-highlighted selection to the OS clipboard. ink's fork
1045
+ // refreshed store.getRenderSelectionText() on the synchronous render that the
1046
+ // final setSelection() triggered, so the selected text is ready to read.
1047
+ // NOTE: declared after useTranscriptScroll — the dependency array below
1048
+ // evaluates getStitchedSelectionText at render time, so referencing it
1049
+ // before the destructuring above is a TDZ ReferenceError.
1050
+ const copySelection = useCallback((attempt = 0) => {
1051
+ const renderText = store.getRenderSelectionText?.();
1052
+ const remembered = selectionTextRef.current || '';
1053
+ // A selection that has partially scrolled out of the viewport renders —
1054
+ // and therefore harvests — only its visible rows. The remembered text
1055
+ // (captured while the selection was last fully painted) is the fuller
1056
+ // copy; prefer whichever is longer so scrolling never shrinks a copy.
1057
+ let text = renderText == null
1058
+ ? remembered
1059
+ : (remembered.length > renderText.length ? remembered : renderText);
1060
+ // The stitch buffer accumulates rows harvested across every scroll position
1061
+ // during a transcript drag, so it can reconstruct rows that scrolled out of
1062
+ // view entirely (neither renderText nor the last-full-paint remembered text
1063
+ // ever saw them). Prefer it only when it is strictly longer than both.
1064
+ const stitched = getStitchedSelectionText?.() || '';
1065
+ if (stitched.length > text.length) text = stitched;
1066
+ if ((!text || !text.trim()) && attempt < 4) {
1067
+ setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
1068
+ return;
1069
+ }
1070
+ if (!text || !text.trim()) return;
1071
+ selectionTextRef.current = text;
1072
+ copyToClipboard(text)
1073
+ .then(() => {
1074
+ const lines = text.split('\n').length;
1075
+ const chars = text.length;
1076
+ showSelectionCopyHint(`copied ${chars} char${chars === 1 ? '' : 's'}${lines > 1 ? ` · ${lines} lines` : ''}`, 'plain');
1077
+ })
1078
+ .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
1079
+ }, [store, showSelectionCopyHint, getStitchedSelectionText]);
1070
1080
 
1071
1081
  useEffect(() => () => {
1072
1082
  stopSmoothScroll();
@@ -1102,6 +1112,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1102
1112
  queueScrollCoalesced,
1103
1113
  setSlashIndex,
1104
1114
  setMeasuredRowsVersion,
1115
+ clearStitchBuffer,
1105
1116
  });
1106
1117
 
1107
1118
  // Enable extended keyboard reporting (kitty + xterm modifyOtherKeys)
@@ -47,7 +47,7 @@ export function createCoreMemoryPicker({
47
47
  const rows = [
48
48
  {
49
49
  value: 'memory-toggle',
50
- label: 'Memory on/off',
50
+ label: 'Memory',
51
51
  meta: memoryOn ? 'On' : 'Off',
52
52
  description: memoryOn
53
53
  ? 'Background memory cycles on'
@@ -54,6 +54,7 @@ export function useMouseInput({
54
54
  queueScrollCoalesced,
55
55
  setSlashIndex,
56
56
  setMeasuredRowsVersion,
57
+ clearStitchBuffer,
57
58
  }) {
58
59
  const mouseZoomPassthroughTimerRef = useRef(null);
59
60
 
@@ -353,6 +354,8 @@ export function useMouseInput({
353
354
  const hi = { x: wr.x2, y: wr.y2 };
354
355
  const rect = linearSelection(lo, hi);
355
356
  stopSmoothScroll();
357
+ // Fresh word/line anchor: reset the stitch buffer (see char-drag).
358
+ clearStitchBuffer?.();
356
359
  dragRef.current = {
357
360
  anchor: { x, y },
358
361
  anchorScroll: region === 'transcript' ? scrollTargetRef.current : 0,
@@ -376,6 +379,9 @@ export function useMouseInput({
376
379
  // there; keep the transcript scroll anchor only for the transcript.
377
380
  // Plain single press clears any word/line anchorSpan (char-drag mode).
378
381
  stopSmoothScroll();
382
+ // Fresh char-drag anchor: drop any rows stitched from a prior
383
+ // selection so the new drag reconstructs only its own content.
384
+ clearStitchBuffer?.();
379
385
  dragRef.current = {
380
386
  anchor: { x, y },
381
387
  anchorScroll: region === 'transcript' ? scrollTargetRef.current : 0,
@@ -44,6 +44,71 @@ export function useTranscriptScroll({
44
44
  // SCROLL_COALESCE_MS). Both call sites accumulate into pendingRows.
45
45
  const scrollCoalesceRef = useRef({ pendingRows: 0, timer: null });
46
46
  const selectionTextTimerRef = useRef(null);
47
+ // Stitch buffer: accumulates harvested transcript selection rows across scroll
48
+ // positions so Ctrl+C copies the FULL drag even after it auto-scrolled past the
49
+ // viewport. Keyed by scroll-invariant content row = screenY - scrollTarget at
50
+ // harvest time; value = row text. Only transcript-region drags accumulate.
51
+ const stitchBufferRef = useRef(new Map());
52
+ const stitchHarvestTimerRef = useRef(null);
53
+ // Scroll offset captured at the SCHEDULE (paint) time of the pending harvest —
54
+ // the deferred timer must key rows by the frame's scroll, not by whatever
55
+ // scrollTargetRef holds when the timer eventually fires (a scroll in between
56
+ // would mis-key the rows). Latest schedule wins (latest paint = latest frame).
57
+ const stitchHarvestScrollRef = useRef(0);
58
+
59
+ const clearStitchBuffer = useCallback(() => {
60
+ stitchBufferRef.current.clear();
61
+ if (stitchHarvestTimerRef.current) {
62
+ clearTimeout(stitchHarvestTimerRef.current);
63
+ stitchHarvestTimerRef.current = null;
64
+ }
65
+ }, []);
66
+
67
+ // Deferred (like rememberSelectionTextSoon) harvest of the currently visible
68
+ // selection rows into the stitch buffer. Runs on EVERY transcript selection
69
+ // paint AND on scroll-shift repaints (the rememberText:false path) so rows
70
+ // revealed only mid-scroll are captured. Later harvest of a key overwrites,
71
+ // handling partial↔full endpoint rows on retraction.
72
+ const harvestStitchRowsSoon = useCallback(() => {
73
+ if (dragRef.current.region !== 'transcript') return;
74
+ // Capture the scroll offset for THIS paint (schedule time). If a timer is
75
+ // already pending, only refresh the captured offset to the latest frame and
76
+ // reuse the existing timer.
77
+ stitchHarvestScrollRef.current = Number(scrollTargetRef.current) || 0;
78
+ if (stitchHarvestTimerRef.current) return;
79
+ stitchHarvestTimerRef.current = setTimeout(() => {
80
+ stitchHarvestTimerRef.current = null;
81
+ if (dragRef.current.region !== 'transcript') return;
82
+ const rows = store.getRenderSelectionRows?.();
83
+ if (!Array.isArray(rows)) return;
84
+ const scroll = stitchHarvestScrollRef.current;
85
+ for (const row of rows) {
86
+ if (!row || typeof row.y !== 'number') continue;
87
+ stitchBufferRef.current.set(row.y - scroll, typeof row.text === 'string' ? row.text : '');
88
+ }
89
+ }, 0);
90
+ }, [store]);
91
+
92
+ // Map the CURRENT rect + current scrollTarget onto the content-key range and
93
+ // join buffered rows sorted by key with '\n' (skipping missing keys). Returns
94
+ // '' when unusable so callers can fall back to render/remembered text.
95
+ const getStitchedSelectionText = useCallback(() => {
96
+ const buf = stitchBufferRef.current;
97
+ if (!buf.size) return '';
98
+ if (dragRef.current.region !== 'transcript') return '';
99
+ const rect = dragRef.current.rect;
100
+ if (!rect) return '';
101
+ const y1 = Number(rect.y1);
102
+ const y2 = Number(rect.y2);
103
+ if (!Number.isFinite(y1) || !Number.isFinite(y2)) return '';
104
+ const scroll = Number(scrollTargetRef.current) || 0;
105
+ const lo = Math.min(y1, y2) - scroll;
106
+ const hi = Math.max(y1, y2) - scroll;
107
+ const keys = [...buf.keys()].filter((k) => k >= lo && k <= hi).sort((a, b) => a - b);
108
+ if (!keys.length) return '';
109
+ const text = keys.map((k) => buf.get(k)).filter((t) => t != null).join('\n');
110
+ return text.trim() ? text : '';
111
+ }, []);
47
112
 
48
113
  const stopSmoothScroll = useCallback(() => {
49
114
  if (!scrollAnimationRef.current) return;
@@ -145,19 +210,24 @@ export function useTranscriptScroll({
145
210
  store.setRenderSelection?.(nextRect, { immediate: true });
146
211
  }
147
212
  if (needsCapture) rememberSelectionTextSoon();
213
+ if (nextRect) harvestStitchRowsSoon();
148
214
  return true;
149
215
  }
150
216
  state.rect = nextRect;
151
217
  state.t = Date.now();
152
218
  store.setRenderSelection?.(nextRect, immediate ? { immediate: true } : undefined);
153
219
  if (nextRect && rememberText && nextRect.captureText !== false) rememberSelectionTextSoon();
220
+ if (nextRect) harvestStitchRowsSoon();
154
221
  return true;
155
- }, [store, rememberSelectionTextSoon]);
222
+ }, [store, rememberSelectionTextSoon, harvestStitchRowsSoon]);
156
223
 
157
224
  const applySelectionRect = useCallback((rect) => {
158
225
  const clippedRect = withSelectionClip(rect);
159
226
  dragRef.current.rect = clippedRect || null;
160
- if (!clippedRect) selectionTextRef.current = '';
227
+ if (!clippedRect) {
228
+ selectionTextRef.current = '';
229
+ clearStitchBuffer();
230
+ }
161
231
  const state = selectionPaintRef.current;
162
232
  if (state.timer) {
163
233
  clearTimeout(state.timer);
@@ -165,7 +235,7 @@ export function useTranscriptScroll({
165
235
  state.pending = null;
166
236
  }
167
237
  paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
168
- }, [paintSelectionRect, withSelectionClip]);
238
+ }, [paintSelectionRect, withSelectionClip, clearStitchBuffer]);
169
239
 
170
240
  const applySelectionRectThrottled = useCallback((rect) => {
171
241
  const clippedRect = withSelectionClip(rect, { captureText: false });
@@ -274,6 +344,8 @@ export function useTranscriptScroll({
274
344
  paintState.pending = null;
275
345
  if (selectionTextTimerRef.current) clearTimeout(selectionTextTimerRef.current);
276
346
  selectionTextTimerRef.current = null;
347
+ if (stitchHarvestTimerRef.current) clearTimeout(stitchHarvestTimerRef.current);
348
+ stitchHarvestTimerRef.current = null;
277
349
  const coalesceState = scrollCoalesceRef.current;
278
350
  if (coalesceState.timer) clearTimeout(coalesceState.timer);
279
351
  coalesceState.timer = null;
@@ -512,5 +584,7 @@ export function useTranscriptScroll({
512
584
  scrollTranscriptRows,
513
585
  queueScrollCoalesced,
514
586
  moveSelectionFocus,
587
+ getStitchedSelectionText,
588
+ clearStitchBuffer,
515
589
  };
516
590
  }