mixdog 0.9.23 → 0.9.25
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/package.json +2 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/tool-smoke.mjs +38 -30
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +246 -47
- package/src/tui/engine/agent-job-feed.mjs +47 -3
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/src/runtime/channels/lib/seat-lock.mjs +0 -196
|
@@ -8,6 +8,13 @@ import {
|
|
|
8
8
|
statSync,
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from 'fs';
|
|
11
|
+
import {
|
|
12
|
+
copyFile as copyFileP,
|
|
13
|
+
mkdir as mkdirP,
|
|
14
|
+
readdir as readdirP,
|
|
15
|
+
rm as rmP,
|
|
16
|
+
stat as statP,
|
|
17
|
+
} from 'fs/promises';
|
|
11
18
|
import { dirname, join, resolve } from 'path';
|
|
12
19
|
import { homedir } from 'os';
|
|
13
20
|
import { createHash } from 'crypto';
|
|
@@ -158,6 +165,65 @@ export function backupUserData(dataDir, reason = 'snapshot') {
|
|
|
158
165
|
return { dir: copied.length > 0 ? backupDir : null, copied };
|
|
159
166
|
}
|
|
160
167
|
|
|
168
|
+
// ── Async backup variant (fs.promises) ──────────────────────────────
|
|
169
|
+
// Byte-for-byte the same policy/skip guards/prune behavior as backupUserData,
|
|
170
|
+
// but every filesystem op yields via fs.promises so a debounced config flush
|
|
171
|
+
// on the UI event loop never blocks on the copy tree or the prune sweep.
|
|
172
|
+
async function copyTreeAsync(src, dst, copied) {
|
|
173
|
+
const st = await statP(src);
|
|
174
|
+
if (st.isDirectory()) {
|
|
175
|
+
for (const name of await readdirP(src)) {
|
|
176
|
+
await copyTreeAsync(join(src, name), join(dst, name), copied);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!st.isFile()) return;
|
|
181
|
+
await mkdirP(dirname(dst), { recursive: true });
|
|
182
|
+
await copyFileP(src, dst);
|
|
183
|
+
copied.push(dst);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function pruneBackupsAsync(keep = 40) {
|
|
187
|
+
let entries = [];
|
|
188
|
+
try {
|
|
189
|
+
entries = (await readdirP(getBackupRoot(), { withFileTypes: true }))
|
|
190
|
+
.filter((entry) => entry.isDirectory())
|
|
191
|
+
.map((entry) => entry.name)
|
|
192
|
+
.sort()
|
|
193
|
+
.reverse();
|
|
194
|
+
} catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const name of entries.slice(keep)) {
|
|
198
|
+
try { await rmP(join(getBackupRoot(), name), { recursive: true, force: true }); } catch {}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function backupUserDataAsync(dataDir, reason = 'snapshot') {
|
|
203
|
+
if (process.env.MIXDOG_SKIP_USER_DATA_BACKUP === '1' || process.env.MIXDOG_SKIP_USER_DATA_BACKUP === 'true') {
|
|
204
|
+
return { dir: null, copied: [] };
|
|
205
|
+
}
|
|
206
|
+
if (!dataDir || !existsSync(dataDir)) return { dir: null, copied: [] };
|
|
207
|
+
const backupDir = join(getBackupRoot(), `${stamp()}-${safeReason(reason)}`);
|
|
208
|
+
const copied = [];
|
|
209
|
+
for (const rel of USER_DATA_FILES) {
|
|
210
|
+
const src = join(dataDir, rel);
|
|
211
|
+
if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
|
|
212
|
+
}
|
|
213
|
+
for (const rel of USER_DATA_DIRS) {
|
|
214
|
+
const src = join(dataDir, rel);
|
|
215
|
+
if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
|
|
216
|
+
}
|
|
217
|
+
if (copied.length > 0) {
|
|
218
|
+
markUserDataInitialized(dataDir);
|
|
219
|
+
await pruneBackupsAsync();
|
|
220
|
+
if (process.env.MIXDOG_SETUP_QUIET !== '1') {
|
|
221
|
+
process.stderr.write(`[user-data-backup] ${reason}: copied ${copied.length} file(s) to ${backupDir}\n`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { dir: copied.length > 0 ? backupDir : null, copied };
|
|
225
|
+
}
|
|
226
|
+
|
|
161
227
|
export function shouldSeedMissingUserData(dataDir, rel) {
|
|
162
228
|
if (!dataDir) return true;
|
|
163
229
|
if (existsSync(join(dataDir, rel))) {
|
|
@@ -31,6 +31,7 @@ export function createConfigLifecycle({
|
|
|
31
31
|
cfgMod,
|
|
32
32
|
sharedCfgMod,
|
|
33
33
|
setBackend,
|
|
34
|
+
setBackendAsync,
|
|
34
35
|
setConfiguredShell,
|
|
35
36
|
normalizeSystemShellConfig,
|
|
36
37
|
normalizeSearchRouteConfig,
|
|
@@ -90,8 +91,61 @@ export function createConfigLifecycle({
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
// --- debounced config save --------------------------------------------------
|
|
94
|
+
// Coexistence strategy (sync vs async flush):
|
|
95
|
+
// * The debounce TIMER fires the ASYNC flush (async lock wait + async icacls
|
|
96
|
+
// + async backup + async atomic write) so a toggle never blocks the UI
|
|
97
|
+
// event loop.
|
|
98
|
+
// * Per-channel serialization: each channel keeps ONE in-flight promise tail;
|
|
99
|
+
// a new async flush chains after it so flushes never interleave. The pending
|
|
100
|
+
// payload is re-read at write time (identity guard), so a burst collapses to
|
|
101
|
+
// the last writer without dropping a newer toggle.
|
|
102
|
+
// * The SYNC flush is retained for reloadFullConfig/teardown (they need the
|
|
103
|
+
// write durable before continuing). It nulls the pending payload and writes
|
|
104
|
+
// synchronously; that sync write takes the SAME cross-process lock file as
|
|
105
|
+
// any in-flight async write, so it serializes AFTER it (lock contention),
|
|
106
|
+
// and the async loop's identity guard then finds a null/superseded payload
|
|
107
|
+
// and does not rewrite — never a revert, and no double-write except a rare
|
|
108
|
+
// idempotent same-content window if a sync flush lands mid async disk-write.
|
|
93
109
|
let pendingConfigToSave = null;
|
|
94
110
|
let configSaveTimer = null;
|
|
111
|
+
let configFlushInFlight = null;
|
|
112
|
+
|
|
113
|
+
async function runConfigFlushAsync() {
|
|
114
|
+
// Drain config, then skills, and RE-CHECK config. A config snapshot queued
|
|
115
|
+
// after the skills patch may carry a stale snapshot.skills (the snapshot
|
|
116
|
+
// captured an older config object ref, before the skills toggle replaced it)
|
|
117
|
+
// that would overwrite the just-patched skills.disabled. Looping until BOTH
|
|
118
|
+
// channels are quiescent keeps the skills patch the last writer relative to
|
|
119
|
+
// EVERY pending/queued config snapshot.
|
|
120
|
+
do {
|
|
121
|
+
let configFailed = false;
|
|
122
|
+
while (pendingConfigToSave !== null) {
|
|
123
|
+
const snapshot = pendingConfigToSave;
|
|
124
|
+
try {
|
|
125
|
+
await cfgMod.saveConfigAsync(snapshot);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
process.stderr.write(`[config] async saveConfig failed: ${err?.message || err}\n`);
|
|
128
|
+
// Keep the payload: a failed write must not drop the pending change.
|
|
129
|
+
configFailed = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (pendingConfigToSave === snapshot) pendingConfigToSave = null;
|
|
133
|
+
}
|
|
134
|
+
// Ordering invariant: skills.disabled patch lands AFTER the config save.
|
|
135
|
+
await flushSkillsSaveAsync();
|
|
136
|
+
if (configFailed) break; // avoid a hot spin on a persistently failing write
|
|
137
|
+
} while (pendingConfigToSave !== null);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function flushConfigSaveAsync() {
|
|
141
|
+
if (configSaveTimer) { clearTimeout(configSaveTimer); configSaveTimer = null; }
|
|
142
|
+
const start = () => runConfigFlushAsync();
|
|
143
|
+
const p = configFlushInFlight ? configFlushInFlight.then(start, start) : start();
|
|
144
|
+
configFlushInFlight = p;
|
|
145
|
+
const clear = () => { if (configFlushInFlight === p) configFlushInFlight = null; };
|
|
146
|
+
p.then(clear, clear);
|
|
147
|
+
return p;
|
|
148
|
+
}
|
|
95
149
|
|
|
96
150
|
function flushConfigSave() {
|
|
97
151
|
if (configSaveTimer) {
|
|
@@ -100,9 +154,13 @@ export function createConfigLifecycle({
|
|
|
100
154
|
}
|
|
101
155
|
if (pendingConfigToSave !== null) {
|
|
102
156
|
const snapshot = pendingConfigToSave;
|
|
103
|
-
pendingConfigToSave = null;
|
|
104
157
|
try {
|
|
105
158
|
cfgMod.saveConfig(snapshot);
|
|
159
|
+
// Clear ONLY after a durable write. saveConfig blocks on the same
|
|
160
|
+
// cross-process lock an async flush may hold, so it serializes after it;
|
|
161
|
+
// if it still fails (e.g. lock timeout) we keep the payload so a later
|
|
162
|
+
// flush / reloadFullConfig retries instead of reverting to stale disk.
|
|
163
|
+
if (pendingConfigToSave === snapshot) pendingConfigToSave = null;
|
|
106
164
|
} catch (err) {
|
|
107
165
|
process.stderr.write(`[config] debounced saveConfig failed: ${err?.message || err}\n`);
|
|
108
166
|
}
|
|
@@ -126,7 +184,7 @@ export function createConfigLifecycle({
|
|
|
126
184
|
// disk write after CONFIG_SAVE_DEBOUNCE_MS of quiet.
|
|
127
185
|
pendingConfigToSave = getConfig();
|
|
128
186
|
if (configSaveTimer) clearTimeout(configSaveTimer);
|
|
129
|
-
configSaveTimer = setTimeout(
|
|
187
|
+
configSaveTimer = setTimeout(() => { flushConfigSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
|
|
130
188
|
configSaveTimer.unref?.();
|
|
131
189
|
return adopted;
|
|
132
190
|
}
|
|
@@ -134,6 +192,31 @@ export function createConfigLifecycle({
|
|
|
134
192
|
// --- debounced backend switch ----------------------------------------------
|
|
135
193
|
let pendingBackendName = null;
|
|
136
194
|
let backendSaveTimer = null;
|
|
195
|
+
let backendFlushInFlight = null;
|
|
196
|
+
|
|
197
|
+
async function runBackendFlushAsync() {
|
|
198
|
+
while (pendingBackendName !== null) {
|
|
199
|
+
const name = pendingBackendName;
|
|
200
|
+
try {
|
|
201
|
+
await setBackendAsync(name);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
process.stderr.write(`[channels] async setBackend failed: ${err?.message || err}\n`);
|
|
204
|
+
if (pendingBackendName === name) pendingBackendName = null;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (pendingBackendName === name) pendingBackendName = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function flushBackendSaveAsync() {
|
|
212
|
+
if (backendSaveTimer) { clearTimeout(backendSaveTimer); backendSaveTimer = null; }
|
|
213
|
+
const start = () => runBackendFlushAsync();
|
|
214
|
+
const p = backendFlushInFlight ? backendFlushInFlight.then(start, start) : start();
|
|
215
|
+
backendFlushInFlight = p;
|
|
216
|
+
const clear = () => { if (backendFlushInFlight === p) backendFlushInFlight = null; };
|
|
217
|
+
p.then(clear, clear);
|
|
218
|
+
return p;
|
|
219
|
+
}
|
|
137
220
|
|
|
138
221
|
function flushBackendSave() {
|
|
139
222
|
if (backendSaveTimer) {
|
|
@@ -153,7 +236,7 @@ export function createConfigLifecycle({
|
|
|
153
236
|
function scheduleBackendSave(name) {
|
|
154
237
|
pendingBackendName = name;
|
|
155
238
|
if (backendSaveTimer) clearTimeout(backendSaveTimer);
|
|
156
|
-
backendSaveTimer = setTimeout(
|
|
239
|
+
backendSaveTimer = setTimeout(() => { flushBackendSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
|
|
157
240
|
backendSaveTimer.unref?.();
|
|
158
241
|
}
|
|
159
242
|
|
|
@@ -163,6 +246,31 @@ export function createConfigLifecycle({
|
|
|
163
246
|
// burst of settings-toggle key presses collapses into one disk write.
|
|
164
247
|
let pendingSkillsNames = null;
|
|
165
248
|
let skillsSaveTimer = null;
|
|
249
|
+
let skillsFlushInFlight = null;
|
|
250
|
+
|
|
251
|
+
async function runSkillsFlushAsync() {
|
|
252
|
+
while (pendingSkillsNames !== null) {
|
|
253
|
+
const names = pendingSkillsNames;
|
|
254
|
+
try {
|
|
255
|
+
await cfgMod.patchSkillsDisabledAsync(names);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
process.stderr.write(`[config] async patchSkillsDisabled failed: ${err?.message || err}\n`);
|
|
258
|
+
if (pendingSkillsNames === names) pendingSkillsNames = null;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
if (pendingSkillsNames === names) pendingSkillsNames = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function flushSkillsSaveAsync() {
|
|
266
|
+
if (skillsSaveTimer) { clearTimeout(skillsSaveTimer); skillsSaveTimer = null; }
|
|
267
|
+
const start = () => runSkillsFlushAsync();
|
|
268
|
+
const p = skillsFlushInFlight ? skillsFlushInFlight.then(start, start) : start();
|
|
269
|
+
skillsFlushInFlight = p;
|
|
270
|
+
const clear = () => { if (skillsFlushInFlight === p) skillsFlushInFlight = null; };
|
|
271
|
+
p.then(clear, clear);
|
|
272
|
+
return p;
|
|
273
|
+
}
|
|
166
274
|
|
|
167
275
|
function flushSkillsSave() {
|
|
168
276
|
if (skillsSaveTimer) {
|
|
@@ -182,13 +290,50 @@ export function createConfigLifecycle({
|
|
|
182
290
|
function scheduleSkillsSave(names) {
|
|
183
291
|
pendingSkillsNames = names;
|
|
184
292
|
if (skillsSaveTimer) clearTimeout(skillsSaveTimer);
|
|
185
|
-
skillsSaveTimer = setTimeout(
|
|
293
|
+
skillsSaveTimer = setTimeout(() => { flushSkillsSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
|
|
186
294
|
skillsSaveTimer.unref?.();
|
|
187
295
|
}
|
|
188
296
|
|
|
189
297
|
// --- debounced top-level outputStyle persist -------------------------------
|
|
190
298
|
let pendingOutputStyleId = null;
|
|
191
299
|
let outputStyleSaveTimer = null;
|
|
300
|
+
let outputStyleFlushInFlight = null;
|
|
301
|
+
|
|
302
|
+
function outputStyleUpdater(styleId) {
|
|
303
|
+
return (root) => {
|
|
304
|
+
const next = { ...(root || {}), outputStyle: styleId };
|
|
305
|
+
if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
|
|
306
|
+
const agent = { ...next.agent };
|
|
307
|
+
delete agent.outputStyle;
|
|
308
|
+
next.agent = agent;
|
|
309
|
+
}
|
|
310
|
+
return next;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function runOutputStyleFlushAsync() {
|
|
315
|
+
while (pendingOutputStyleId !== null) {
|
|
316
|
+
const styleId = pendingOutputStyleId;
|
|
317
|
+
try {
|
|
318
|
+
await sharedCfgMod.updateConfigAsync(outputStyleUpdater(styleId));
|
|
319
|
+
} catch (err) {
|
|
320
|
+
process.stderr.write(`[config] async outputStyle save failed: ${err?.message || err}\n`);
|
|
321
|
+
if (pendingOutputStyleId === styleId) pendingOutputStyleId = null;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
if (pendingOutputStyleId === styleId) pendingOutputStyleId = null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function flushOutputStyleSaveAsync() {
|
|
329
|
+
if (outputStyleSaveTimer) { clearTimeout(outputStyleSaveTimer); outputStyleSaveTimer = null; }
|
|
330
|
+
const start = () => runOutputStyleFlushAsync();
|
|
331
|
+
const p = outputStyleFlushInFlight ? outputStyleFlushInFlight.then(start, start) : start();
|
|
332
|
+
outputStyleFlushInFlight = p;
|
|
333
|
+
const clear = () => { if (outputStyleFlushInFlight === p) outputStyleFlushInFlight = null; };
|
|
334
|
+
p.then(clear, clear);
|
|
335
|
+
return p;
|
|
336
|
+
}
|
|
192
337
|
|
|
193
338
|
function flushOutputStyleSave() {
|
|
194
339
|
if (outputStyleSaveTimer) {
|
|
@@ -199,15 +344,7 @@ export function createConfigLifecycle({
|
|
|
199
344
|
const styleId = pendingOutputStyleId;
|
|
200
345
|
pendingOutputStyleId = null;
|
|
201
346
|
try {
|
|
202
|
-
sharedCfgMod.updateConfig((
|
|
203
|
-
const next = { ...(root || {}), outputStyle: styleId };
|
|
204
|
-
if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
|
|
205
|
-
const agent = { ...next.agent };
|
|
206
|
-
delete agent.outputStyle;
|
|
207
|
-
next.agent = agent;
|
|
208
|
-
}
|
|
209
|
-
return next;
|
|
210
|
-
});
|
|
347
|
+
sharedCfgMod.updateConfig(outputStyleUpdater(styleId));
|
|
211
348
|
} catch (err) {
|
|
212
349
|
process.stderr.write(`[config] debounced outputStyle save failed: ${err?.message || err}\n`);
|
|
213
350
|
}
|
|
@@ -216,7 +353,7 @@ export function createConfigLifecycle({
|
|
|
216
353
|
function scheduleOutputStyleSave(styleId) {
|
|
217
354
|
pendingOutputStyleId = styleId;
|
|
218
355
|
if (outputStyleSaveTimer) clearTimeout(outputStyleSaveTimer);
|
|
219
|
-
outputStyleSaveTimer = setTimeout(
|
|
356
|
+
outputStyleSaveTimer = setTimeout(() => { flushOutputStyleSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
|
|
220
357
|
outputStyleSaveTimer.unref?.();
|
|
221
358
|
}
|
|
222
359
|
|
|
@@ -227,7 +364,30 @@ export function createConfigLifecycle({
|
|
|
227
364
|
// subsequent adopt preserves) that change instead of reverting to a stale
|
|
228
365
|
// on-disk snapshot.
|
|
229
366
|
flushConfigSave();
|
|
230
|
-
|
|
367
|
+
const loaded = cfgMod.loadConfig();
|
|
368
|
+
if (pendingConfigToSave !== null) {
|
|
369
|
+
// The debounced write could not land (e.g. lock timeout), so on-disk is
|
|
370
|
+
// stale. Prefer the freshest in-memory state and re-overlay the keychain
|
|
371
|
+
// provider secrets that only the disk load carries, so a failed flush
|
|
372
|
+
// never reverts the user's latest change.
|
|
373
|
+
const current = getConfig();
|
|
374
|
+
const merged = { ...loaded, ...current, providers: { ...(current.providers || {}) } };
|
|
375
|
+
for (const [name, val] of Object.entries(loaded.providers || {})) {
|
|
376
|
+
if (val && val.apiKey) {
|
|
377
|
+
// Match loadConfig's keychain overlay: apiKey ⇒ enabled:true, UNLESS
|
|
378
|
+
// the in-memory pending state EXPLICITLY disabled this provider (a
|
|
379
|
+
// genuine newer user change that must not be reverted).
|
|
380
|
+
const explicitlyDisabled = current.providers?.[name]?.enabled === false;
|
|
381
|
+
merged.providers[name] = {
|
|
382
|
+
...(merged.providers[name] || {}),
|
|
383
|
+
apiKey: val.apiKey,
|
|
384
|
+
enabled: explicitlyDisabled ? false : true,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return adoptConfig(merged, { hasSecrets: true });
|
|
389
|
+
}
|
|
390
|
+
return adoptConfig(loaded, { hasSecrets: true });
|
|
231
391
|
}
|
|
232
392
|
|
|
233
393
|
function ensureFullConfig() {
|
|
@@ -250,11 +250,41 @@ export function createMcpGlue({
|
|
|
250
250
|
return { name, config };
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// First-turn gate: await the in-flight INITIAL connect, bounded by the global
|
|
254
|
+
// startup budget. Boot/UI never call this; only the first ask awaits so
|
|
255
|
+
// servers that connect within the budget land in THIS request's tool surface.
|
|
256
|
+
// Resolves (never rejects): a server still connecting after the budget flows
|
|
257
|
+
// through the existing late-tool deferred announcement path unchanged.
|
|
258
|
+
async function awaitInitialMcpConnect() {
|
|
259
|
+
const inFlight = state.mcpConnectInFlight;
|
|
260
|
+
if (!inFlight) return;
|
|
261
|
+
let budgetMs = 10000;
|
|
262
|
+
try {
|
|
263
|
+
const resolved = mcpClient.resolveMcpStartupTimeoutMs?.({});
|
|
264
|
+
if (Number.isFinite(resolved)) budgetMs = resolved;
|
|
265
|
+
} catch { /* fall back to default budget */ }
|
|
266
|
+
// Swallow the in-flight rejection: failures are already captured in
|
|
267
|
+
// state.mcpFailures, and this gate must never reject the turn.
|
|
268
|
+
const settled = Promise.resolve(inFlight).catch(() => {});
|
|
269
|
+
// Budget disabled (0/off) = no per-server startup timeout, so the connect
|
|
270
|
+
// promise may never settle; never gate the turn on it — fall back to the
|
|
271
|
+
// legacy fire-and-forget behavior (late servers use the deferred path).
|
|
272
|
+
if (!(budgetMs > 0)) return;
|
|
273
|
+
let timer = null;
|
|
274
|
+
const budget = new Promise((resolveBudget) => { timer = setTimeout(resolveBudget, budgetMs); });
|
|
275
|
+
try {
|
|
276
|
+
await Promise.race([settled, budget]);
|
|
277
|
+
} finally {
|
|
278
|
+
if (timer) clearTimeout(timer);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
253
282
|
return {
|
|
254
283
|
mcpTransportLabel,
|
|
255
284
|
resolveEffectiveMcpServers,
|
|
256
285
|
mcpStatus,
|
|
257
286
|
connectConfiguredMcp,
|
|
287
|
+
awaitInitialMcpConnect,
|
|
258
288
|
normalizeMcpServerInput,
|
|
259
289
|
};
|
|
260
290
|
}
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
saveWebhook,
|
|
68
68
|
saveWebhookAuthtoken,
|
|
69
69
|
setBackend,
|
|
70
|
+
setBackendAsync,
|
|
70
71
|
setScheduleEnabled,
|
|
71
72
|
setWebhookEnabled,
|
|
72
73
|
setWebhookConfig,
|
|
@@ -333,6 +334,11 @@ export async function createMixdogSessionRuntime({
|
|
|
333
334
|
// session.id + cwd inside ask(); only active while remoteEnabled.
|
|
334
335
|
let _transcriptWriter = null;
|
|
335
336
|
let _twKey = '';
|
|
337
|
+
// One-shot: an 'acquired' verdict (or other rebind trigger) landed before a
|
|
338
|
+
// session/writer existed, so the rebind push could not fire. Set true when a
|
|
339
|
+
// push is deferred; the next session-create / turn-start flushes it exactly
|
|
340
|
+
// once so the daemon forwarder always ends bound to THIS session's transcript.
|
|
341
|
+
let _pendingRebind = false;
|
|
336
342
|
// Last assistant text handed to the transcript writer (via onAssistantText),
|
|
337
343
|
// so the post-turn final-content append can skip an exact duplicate.
|
|
338
344
|
let _lastAppendedAssistant = '';
|
|
@@ -599,6 +605,7 @@ export async function createMixdogSessionRuntime({
|
|
|
599
605
|
resolveEffectiveMcpServers,
|
|
600
606
|
mcpStatus,
|
|
601
607
|
connectConfiguredMcp,
|
|
608
|
+
awaitInitialMcpConnect,
|
|
602
609
|
normalizeMcpServerInput,
|
|
603
610
|
} = createMcpGlue({
|
|
604
611
|
mcpClient,
|
|
@@ -683,7 +690,19 @@ export async function createMixdogSessionRuntime({
|
|
|
683
690
|
void (async () => {
|
|
684
691
|
await checkForUpdateInternal({ force: true });
|
|
685
692
|
if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
|
|
686
|
-
await runUpdateNowInternal();
|
|
693
|
+
const result = await runUpdateNowInternal();
|
|
694
|
+
// Surface the boot auto-update outcome as a UI-only notice (TUI maps
|
|
695
|
+
// meta.kind 'update-notice' to a transcript notice, never a
|
|
696
|
+
// model-visible message). Silent before: installed-but-needs-restart
|
|
697
|
+
// and failed installs were invisible unless the user opened the
|
|
698
|
+
// maintenance picker.
|
|
699
|
+
if (result?.phase === 'installed') {
|
|
700
|
+
const v = result.version && result.version !== 'unknown'
|
|
701
|
+
? `v${result.version}` : (updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest');
|
|
702
|
+
emitRuntimeNotification(`mixdog ${v} installed — restart to apply.`, { kind: 'update-notice', tone: 'info' });
|
|
703
|
+
} else if (result?.phase === 'failed') {
|
|
704
|
+
emitRuntimeNotification(`mixdog auto-update failed: ${result.error || 'unknown error'}`, { kind: 'update-notice', tone: 'warn' });
|
|
705
|
+
}
|
|
687
706
|
}
|
|
688
707
|
})().catch(() => {});
|
|
689
708
|
}, 0);
|
|
@@ -1131,6 +1150,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1131
1150
|
cfgMod,
|
|
1132
1151
|
sharedCfgMod,
|
|
1133
1152
|
setBackend,
|
|
1153
|
+
setBackendAsync,
|
|
1134
1154
|
setConfiguredShell,
|
|
1135
1155
|
normalizeSystemShellConfig,
|
|
1136
1156
|
normalizeSearchRouteConfig,
|
|
@@ -1380,7 +1400,28 @@ export async function createMixdogSessionRuntime({
|
|
|
1380
1400
|
session = mgr.createSession(sessionOpts);
|
|
1381
1401
|
sessionNeedsCwdRefresh = false;
|
|
1382
1402
|
attachSessionHooks(session, { hooks, hookCommonPayload, getCwd: () => currentCwd });
|
|
1383
|
-
|
|
1403
|
+
// Every-create MCP fold (NO blocking): seed the INITIAL deferred surface +
|
|
1404
|
+
// BP1 manifest from whatever MCP servers are ALREADY connected at create
|
|
1405
|
+
// time. There is no await — a boot connect still mid-handshake is caught on
|
|
1406
|
+
// the first user turn by refreshInitialDeferredMcpSurface (session-turn-api),
|
|
1407
|
+
// which re-folds the live registry into the initial manifest before the
|
|
1408
|
+
// prompt renders. This fold keeps recreate paths (cwd change with MCP
|
|
1409
|
+
// already connected) seeding their manifest instead of re-announcing late.
|
|
1410
|
+
let connectedMcpTools = [];
|
|
1411
|
+
try { connectedMcpTools = mcpClient.getMcpTools?.() || []; }
|
|
1412
|
+
catch { connectedMcpTools = []; }
|
|
1413
|
+
applyDeferredToolSurface(
|
|
1414
|
+
session,
|
|
1415
|
+
deferredSurfaceModeForLead(mode),
|
|
1416
|
+
connectedMcpTools.length ? [...standaloneTools, ...connectedMcpTools] : standaloneTools,
|
|
1417
|
+
{ provider: route.provider },
|
|
1418
|
+
);
|
|
1419
|
+
// Session-local one-shot: mark this FRESH session eligible for the
|
|
1420
|
+
// first-turn deferred-surface refresh (session-turn-api). A resumed
|
|
1421
|
+
// session (prior transcript) is NEVER marked, so its already-baked BP1 is
|
|
1422
|
+
// never rebuilt or re-announced — the gate is per-session, not the
|
|
1423
|
+
// process-wide firstTurnCompleted.
|
|
1424
|
+
session.deferredInitialRefreshPending = !/resume/i.test(String(reason || ''));
|
|
1384
1425
|
applyPreSessionToolSelection();
|
|
1385
1426
|
writeStatuslineRoute(statusRoutes, session, route);
|
|
1386
1427
|
hooks.emit('session:create', { sessionId: session.id, provider: route.provider, model: route.model, toolMode: mode, cwd: currentCwd });
|
|
@@ -1407,6 +1448,9 @@ export async function createMixdogSessionRuntime({
|
|
|
1407
1448
|
tools: Array.isArray(session.tools) ? session.tools.length : 0,
|
|
1408
1449
|
catalog: Array.isArray(session.deferredToolCatalog) ? session.deferredToolCatalog.length : 0,
|
|
1409
1450
|
});
|
|
1451
|
+
// A rebind push may have been deferred (e.g. 'acquired' landed before this
|
|
1452
|
+
// session existed). The writer is now bindable — flush it exactly once.
|
|
1453
|
+
flushPendingTranscriptRebind();
|
|
1410
1454
|
return session;
|
|
1411
1455
|
})();
|
|
1412
1456
|
|
|
@@ -1538,17 +1582,55 @@ export async function createMixdogSessionRuntime({
|
|
|
1538
1582
|
// failure must never throw into the lead paths that call this.
|
|
1539
1583
|
function pushTranscriptRebind() {
|
|
1540
1584
|
if (!remoteEnabled) return;
|
|
1541
|
-
|
|
1585
|
+
// Writer not bindable yet (e.g. 'acquired' before the session exists in
|
|
1586
|
+
// lazy mode): defer instead of silently dropping the push. flushPending-
|
|
1587
|
+
// TranscriptRebind() re-fires this exactly once when the writer is ready.
|
|
1588
|
+
if (!ensureRemoteTranscriptWriter()) { _pendingRebind = true; return; }
|
|
1542
1589
|
const transcriptPath = _transcriptWriter?.transcriptPath;
|
|
1543
|
-
if (!transcriptPath || !channelsEnabled()) return;
|
|
1590
|
+
if (!transcriptPath || !channelsEnabled()) { _pendingRebind = true; return; }
|
|
1591
|
+
_pendingRebind = false;
|
|
1592
|
+
executeTranscriptRebind(transcriptPath, 1);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Fire the idempotent worker op with bounded retry. A rejected/throwing
|
|
1596
|
+
// channels.execute retries a few times with short backoff; the final failure
|
|
1597
|
+
// surfaces one stderr line (not only the env-gated bootProfile) so a lost
|
|
1598
|
+
// rebind is diagnosable by default. Best-effort throughout — never throws
|
|
1599
|
+
// into the lead paths that call pushTranscriptRebind().
|
|
1600
|
+
function executeTranscriptRebind(transcriptPath, attempt) {
|
|
1601
|
+
const maxAttempts = 3;
|
|
1602
|
+
const onError = (error) => {
|
|
1603
|
+
const detail = error?.message || String(error);
|
|
1604
|
+
bootProfile('channels:rebind-push-failed', { attempt, error: detail });
|
|
1605
|
+
if (attempt < maxAttempts && remoteEnabled && !closeRequested) {
|
|
1606
|
+
const timer = setTimeout(() => {
|
|
1607
|
+
// Abort the retry chain silently if remote was dropped or the writer
|
|
1608
|
+
// moved on (supersede→re-acquire, newSession/clear): re-firing the
|
|
1609
|
+
// captured path would rebind forwarding back to a stale transcript.
|
|
1610
|
+
if (!remoteEnabled || !channelsEnabled()) return;
|
|
1611
|
+
if (_transcriptWriter?.transcriptPath !== transcriptPath) return;
|
|
1612
|
+
executeTranscriptRebind(transcriptPath, attempt + 1);
|
|
1613
|
+
}, 150 * attempt);
|
|
1614
|
+
timer.unref?.();
|
|
1615
|
+
} else {
|
|
1616
|
+
process.stderr.write(`mixdog: channels: rebind_current_transcript failed after ${attempt} attempt(s): ${detail}\n`);
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1544
1619
|
try {
|
|
1545
|
-
void channels.execute('rebind_current_transcript', { transcriptPath })
|
|
1546
|
-
.catch((error) => bootProfile('channels:rebind-push-failed', { error: error?.message || String(error) }));
|
|
1620
|
+
void channels.execute('rebind_current_transcript', { transcriptPath }).catch(onError);
|
|
1547
1621
|
} catch (error) {
|
|
1548
|
-
|
|
1622
|
+
onError(error);
|
|
1549
1623
|
}
|
|
1550
1624
|
}
|
|
1551
1625
|
|
|
1626
|
+
// Re-fire a deferred rebind exactly once, after a session/writer becomes
|
|
1627
|
+
// available (session create or turn start). No-op unless a push was deferred,
|
|
1628
|
+
// so no unconditional rebind fires per turn for already-bound sessions.
|
|
1629
|
+
function flushPendingTranscriptRebind() {
|
|
1630
|
+
if (!_pendingRebind || !remoteEnabled) return;
|
|
1631
|
+
pushTranscriptRebind();
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1552
1634
|
// Remote (Discord channel) mode is opt-in per session. Only a session that
|
|
1553
1635
|
// explicitly enables remote — via `mixdog --remote` or the runtime toggle —
|
|
1554
1636
|
// boots the channel worker and contends for channel ownership.
|
|
@@ -2008,6 +2090,7 @@ export async function createMixdogSessionRuntime({
|
|
|
2008
2090
|
createCurrentSession,
|
|
2009
2091
|
ensureRemoteTranscriptWriter,
|
|
2010
2092
|
pushTranscriptRebind,
|
|
2093
|
+
flushPendingTranscriptRebind,
|
|
2011
2094
|
channelsEnabled,
|
|
2012
2095
|
invokeChannelStart,
|
|
2013
2096
|
channels,
|
|
@@ -2027,6 +2110,7 @@ export async function createMixdogSessionRuntime({
|
|
|
2027
2110
|
resolveCwdPath,
|
|
2028
2111
|
agentStatusState,
|
|
2029
2112
|
notificationListeners,
|
|
2113
|
+
awaitInitialMcpConnect,
|
|
2030
2114
|
});
|
|
2031
2115
|
|
|
2032
2116
|
return {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sortedNamesByMeasuredUsage,
|
|
8
8
|
selectDeferredTools,
|
|
9
9
|
reconcileDeferredMcpToolCatalog,
|
|
10
|
+
refreshInitialDeferredMcpSurface,
|
|
10
11
|
} from './tool-catalog.mjs';
|
|
11
12
|
import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
|
|
12
13
|
|
|
@@ -24,11 +25,12 @@ export function createSessionTurnApi(deps) {
|
|
|
24
25
|
getTranscriptWriter, getTwKey, getLastAppendedAssistant, setLastAppendedAssistant,
|
|
25
26
|
scheduleCodeGraphPrewarm, refreshSessionForCwdIfNeeded, createCurrentSession,
|
|
26
27
|
ensureRemoteTranscriptWriter, channelsEnabled, invokeChannelStart, channels,
|
|
27
|
-
pushTranscriptRebind,
|
|
28
|
+
pushTranscriptRebind, flushPendingTranscriptRebind,
|
|
28
29
|
hooks, hookCommonPayload, mgr, notifyFnForSession, bootProfile,
|
|
29
30
|
scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
|
|
30
31
|
agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
|
|
31
32
|
activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
|
|
33
|
+
awaitInitialMcpConnect,
|
|
32
34
|
} = deps;
|
|
33
35
|
return {
|
|
34
36
|
async ask(prompt, options = {}) {
|
|
@@ -49,6 +51,9 @@ export function createSessionTurnApi(deps) {
|
|
|
49
51
|
setLastAppendedAssistant('');
|
|
50
52
|
const prevKey = getTwKey();
|
|
51
53
|
ensureRemoteTranscriptWriter();
|
|
54
|
+
// Flush a rebind deferred before the session/writer existed ('acquired'
|
|
55
|
+
// in lazy mode). One-shot: no-op unless a push was actually deferred.
|
|
56
|
+
flushPendingTranscriptRebind?.();
|
|
52
57
|
if (getTwKey() && getTwKey() !== prevKey && channelsEnabled() && !envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
|
|
53
58
|
void invokeChannelStart()
|
|
54
59
|
.then(() => {
|
|
@@ -59,22 +64,43 @@ export function createSessionTurnApi(deps) {
|
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
const session0 = getSession();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
if (session0.deferredInitialRefreshPending) {
|
|
68
|
+
// FIRST TURN of a FRESH session (session-local gate, NOT the
|
|
69
|
+
// process-wide firstTurnCompleted): an MCP server may have finished its
|
|
70
|
+
// handshake BETWEEN session-create and this first send. Re-fold the
|
|
71
|
+
// LIVE registry into the INITIAL deferred surface + BP1
|
|
72
|
+
// <available-deferred-tools> manifest (sync, in-place, idempotent) and
|
|
73
|
+
// pre-mark those names announced, so they ship in the initial manifest
|
|
74
|
+
// instead of a late-tool <system-reminder>. One-shot: cleared before
|
|
75
|
+
// the fold so a throw still never re-runs it, and a resumed session
|
|
76
|
+
// (flag unset) skips straight to the late path below.
|
|
77
|
+
session0.deferredInitialRefreshPending = false;
|
|
78
|
+
// First-turn gate: give the in-flight INITIAL MCP connect a bounded
|
|
79
|
+
// chance to finish so servers that connect within the startup budget
|
|
80
|
+
// land in THIS request's tool surface. Bounded by the same budget —
|
|
81
|
+
// UI/boot never blocks here; only this first ask waits, and only once.
|
|
82
|
+
try { await awaitInitialMcpConnect?.(); }
|
|
83
|
+
catch { /* gate must never break the turn */ }
|
|
84
|
+
try { refreshInitialDeferredMcpSurface(session0, getMcpTools()); }
|
|
85
|
+
catch { /* first-turn MCP fold must never break the turn */ }
|
|
86
|
+
} else {
|
|
87
|
+
// AFTER FIRST TURN: fold in MCP tools whose servers finished their
|
|
88
|
+
// handshake after this session was created, and announce the newly
|
|
89
|
+
// available deferred tool names via ONE appended, persistent
|
|
90
|
+
// system-reminder (append-only — never rewrites BP1 or touches the
|
|
91
|
+
// active tool surface, so the prompt-cache prefix stays intact).
|
|
92
|
+
try {
|
|
93
|
+
reconcileDeferredMcpToolCatalog(session0, getMcpTools(), {
|
|
94
|
+
// Deliver the late-tool announcement through the pending-message
|
|
95
|
+
// queue so it rides inside the next real user turn as a persisted
|
|
96
|
+
// system-reminder (no synthetic user + '.' assistant pair).
|
|
97
|
+
enqueue: (text) => (typeof mgr.enqueuePendingMessage === 'function'
|
|
98
|
+
? mgr.enqueuePendingMessage(session0.id, text) > 0
|
|
99
|
+
: false),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch { /* MCP delta must never break the turn */ }
|
|
76
103
|
}
|
|
77
|
-
catch { /* MCP delta must never break the turn */ }
|
|
78
104
|
hooks.emit('turn:start', { sessionId: session0.id, prompt, cwd: getCurrentCwd() });
|
|
79
105
|
// UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
|
|
80
106
|
let promptDispatch = null;
|