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.
- package/LICENSE +21 -0
- package/README.md +93 -29
- package/package.json +3 -2
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +150 -45
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +771 -0
- package/src/runtime/memory/index.mjs +73 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +772 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2046 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +4 -0
- package/src/standalone/memory-runtime-proxy.mjs +56 -6
- package/src/tui/App.jsx +39 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +1990 -1527
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +545 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- 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%.
|
|
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',
|
|
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 || '')
|
|
314
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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)
|
|
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
|
}
|