mixdog 0.9.22 → 0.9.23
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/README.md +1 -4
- package/package.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Machine-global channels daemon entry.
|
|
2
|
+
//
|
|
3
|
+
// One process per machine hosts the channels runtime (worker-main) and exposes
|
|
4
|
+
// it to many TUIs over a local HTTP + SSE transport (see
|
|
5
|
+
// channel-daemon-transport.mjs) instead of the old per-TUI fork + node-IPC.
|
|
6
|
+
// Spawned (or attached-to) by createStandaloneChannelWorker; ownership is a
|
|
7
|
+
// pid-verified singleton lock (singleton-owner.mjs) — NOT the try-once
|
|
8
|
+
// active-instance lock that starved under 6 contending workers. A stale daemon
|
|
9
|
+
// (dead owner pid) is reclaimed by the next claim; a live peer that wins the
|
|
10
|
+
// race makes this process exit(0) so the spawner attaches to the winner.
|
|
11
|
+
//
|
|
12
|
+
// Boot order matters: MIXDOG_CHANNEL_DAEMON must be set BEFORE importing the
|
|
13
|
+
// channels runtime so worker-main skips its parent-IPC loop (runWorkerIpc) and
|
|
14
|
+
// lets this entry own start()/stop() + the transport.
|
|
15
|
+
process.env.MIXDOG_CHANNEL_DAEMON = '1';
|
|
16
|
+
process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
|
|
17
|
+
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { mkdirSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { performance } from 'node:perf_hooks';
|
|
23
|
+
import { claimSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
|
|
24
|
+
import { setChannelNotifySink } from '../runtime/channels/lib/parent-bridge.mjs';
|
|
25
|
+
import { setOwnerContext } from '../runtime/channels/lib/runtime-paths.mjs';
|
|
26
|
+
import { createChannelDaemonTransport } from './channel-daemon-transport.mjs';
|
|
27
|
+
import { createStandaloneMemoryRuntime } from './memory-runtime-proxy.mjs';
|
|
28
|
+
|
|
29
|
+
function runtimeRoot() {
|
|
30
|
+
return process.env.MIXDOG_RUNTIME_ROOT
|
|
31
|
+
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
32
|
+
: path.join(os.tmpdir(), 'mixdog');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RUNTIME_ROOT = runtimeRoot();
|
|
36
|
+
const DATA_DIR = process.env.MIXDOG_DATA_DIR ? path.resolve(process.env.MIXDOG_DATA_DIR) : RUNTIME_ROOT;
|
|
37
|
+
const CWD = process.cwd();
|
|
38
|
+
const DISCOVERY_PATH = path.join(RUNTIME_ROOT, 'channel-daemon.json');
|
|
39
|
+
// Owner-election lock, separate from the channels seat/bridge state. Reused
|
|
40
|
+
// pid-verified claim primitive with real claim-lock retry inside.
|
|
41
|
+
const OWNER_PATH = path.join(DATA_DIR, 'channel-daemon-owner.json');
|
|
42
|
+
// Memory runtime is folded into the daemon: the ONE machine-global process is
|
|
43
|
+
// responsible for starting BOTH the channels runtime and the memory runtime.
|
|
44
|
+
// The memory proxy is spawn-or-attach + singleton (memory-runtime-owner lock)
|
|
45
|
+
// and advertises memory_port to active-instance.json exactly as before, so
|
|
46
|
+
// external readers / memory-client.mjs discovery is UNCHANGED.
|
|
47
|
+
const MEMORY_ENTRY = fileURLToPath(new URL('../runtime/memory/index.mjs', import.meta.url));
|
|
48
|
+
|
|
49
|
+
function log(line) {
|
|
50
|
+
try { process.stderr.write(`[channel-daemon] ${line}\n`); } catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let channels = null;
|
|
54
|
+
let transport = null;
|
|
55
|
+
let memoryRuntime = null;
|
|
56
|
+
let shuttingDown = false;
|
|
57
|
+
|
|
58
|
+
async function shutdown(reason, code = 0) {
|
|
59
|
+
if (shuttingDown) return;
|
|
60
|
+
shuttingDown = true;
|
|
61
|
+
log(`shutting down (${reason})`);
|
|
62
|
+
try { setChannelNotifySink(null); } catch {}
|
|
63
|
+
try { await channels?.stop?.(); } catch (e) { log(`channels.stop failed: ${e?.message || e}`); }
|
|
64
|
+
// Detach the memory client (never hard-kills the shared memory daemon).
|
|
65
|
+
try { await memoryRuntime?.stop?.(); } catch (e) { log(`memory.stop failed: ${e?.message || e}`); }
|
|
66
|
+
try { await transport?.stop?.(); } catch (e) { log(`transport.stop failed: ${e?.message || e}`); }
|
|
67
|
+
try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {}
|
|
68
|
+
process.exit(code);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
const startedAt = performance.now();
|
|
73
|
+
try { mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
|
|
74
|
+
|
|
75
|
+
// Pid-verified singleton claim (claimSingletonOwner reclaims a dead-pid owner
|
|
76
|
+
// file and refuses only a LIVE peer). Loser exits so the spawner attaches to
|
|
77
|
+
// the winner instead of running a second daemon.
|
|
78
|
+
const claim = claimSingletonOwner(OWNER_PATH, { kind: 'channel-runtime-daemon', pid: process.pid, meta: { cwd: CWD } });
|
|
79
|
+
if (!claim.owned) {
|
|
80
|
+
log(`live peer holds owner lock (pid=${claim.owner?.pid}) — exiting for attach`);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
|
|
84
|
+
|
|
85
|
+
// Import the channels runtime AFTER the daemon env is set so worker-main
|
|
86
|
+
// skips runWorkerIpc; this triggers its boot side effects (config/backend).
|
|
87
|
+
channels = await import('../runtime/channels/index.mjs');
|
|
88
|
+
|
|
89
|
+
// Pointer-move tools re-point active-instance owner/cwd at the binding TUI
|
|
90
|
+
// (mirrors the transport's routing pointer) so external readers see the
|
|
91
|
+
// pointer TUI, not the daemon's spawner.
|
|
92
|
+
const POINTER_TOOLS = new Set(['activate_channel_bridge', 'rebind_current_transcript']);
|
|
93
|
+
transport = createChannelDaemonTransport({
|
|
94
|
+
handleCall: (name, args, ctx) => {
|
|
95
|
+
if (ctx && POINTER_TOOLS.has(name)) {
|
|
96
|
+
try { setOwnerContext({ leadPid: ctx.leadPid, cwd: ctx.cwd }); } catch {}
|
|
97
|
+
}
|
|
98
|
+
return channels.handleToolCallWithBridgeRetry(name, args || {});
|
|
99
|
+
},
|
|
100
|
+
discoveryPath: DISCOVERY_PATH,
|
|
101
|
+
log,
|
|
102
|
+
// Self-shutdown when the last attached TUI leaves (reuses the SSE/client
|
|
103
|
+
// registry as the liveness signal — mirrors the memory daemon grace).
|
|
104
|
+
onClientsEmpty: () => { void shutdown('no live clients'); },
|
|
105
|
+
});
|
|
106
|
+
setChannelNotifySink((method, params) => transport.notify(method, params));
|
|
107
|
+
const { port, token } = await transport.start();
|
|
108
|
+
|
|
109
|
+
// Boot the owned channels runtime (Discord connect, transcript bind). Failure
|
|
110
|
+
// is non-fatal — the daemon still serves tool calls and can reconnect later.
|
|
111
|
+
try { await channels.start(); } catch (e) { log(`channels.start failed (non-fatal): ${e?.message || e}`); }
|
|
112
|
+
|
|
113
|
+
// Fold memory startup in: eagerly ensure the memory runtime is up under the
|
|
114
|
+
// daemon's lifecycle (spawn-or-attach singleton). Fire-and-forget — memory
|
|
115
|
+
// boot is heavy (DB/embeddings) and must NOT delay the ready handshake below
|
|
116
|
+
// (the spawner's ready wait would time out); the proxy publishes memory_port
|
|
117
|
+
// to active-instance.json when ready and memory-client buffers until then.
|
|
118
|
+
try {
|
|
119
|
+
memoryRuntime = createStandaloneMemoryRuntime({ entry: MEMORY_ENTRY, dataDir: DATA_DIR, cwd: CWD });
|
|
120
|
+
void memoryRuntime.start().catch((e) => log(`memory.start failed (non-fatal): ${e?.message || e}`));
|
|
121
|
+
} catch (e) { log(`memory.start setup failed (non-fatal): ${e?.message || e}`); }
|
|
122
|
+
|
|
123
|
+
// Ready handshake for the spawner (mirrors the memory daemon's ready port).
|
|
124
|
+
if (process.send) {
|
|
125
|
+
try { process.send({ type: 'ready', port, token }); } catch {}
|
|
126
|
+
}
|
|
127
|
+
log(`ready port=${port} pid=${process.pid} in ${(performance.now() - startedAt).toFixed(0)}ms`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
131
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
132
|
+
process.on('message', (msg) => {
|
|
133
|
+
if (msg && msg.type === 'shutdown') void shutdown('IPC shutdown');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
main().catch((err) => {
|
|
137
|
+
log(`fatal boot error: ${err?.stack || err?.message || err}`);
|
|
138
|
+
void shutdown('fatal boot error', 2);
|
|
139
|
+
});
|
|
@@ -7,6 +7,9 @@ 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
9
|
import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
|
|
10
|
+
import { attachToDaemon, readDaemonDiscovery, probeDaemonHealth } from './channel-daemon-client.mjs';
|
|
11
|
+
import { claimSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
10
13
|
|
|
11
14
|
const CHANNEL_TOOLS = new Set([
|
|
12
15
|
'reply',
|
|
@@ -17,9 +20,17 @@ const CHANNEL_TOOLS = new Set([
|
|
|
17
20
|
// Lead-only runtime plumbing (bridge-claim on start, config hot-reload).
|
|
18
21
|
'activate_channel_bridge',
|
|
19
22
|
'reload_config',
|
|
23
|
+
// Lead-pushed transcript repoint (auto-acquire / newSession / resume /
|
|
24
|
+
// clear). Not model-facing; internal Lead-only runtime plumbing.
|
|
25
|
+
'rebind_current_transcript',
|
|
20
26
|
]);
|
|
21
27
|
|
|
22
28
|
const WORKER_PRELOAD = fileURLToPath(new URL('./channel-worker-preload.cjs', import.meta.url));
|
|
29
|
+
// Machine-global channels daemon entry (spawn-or-attach target). Overridable
|
|
30
|
+
// via env so the flip smoke can point at a stub daemon (no Discord token).
|
|
31
|
+
const DAEMON_ENTRY = process.env.MIXDOG_CHANNEL_DAEMON_ENTRY
|
|
32
|
+
? resolve(process.env.MIXDOG_CHANNEL_DAEMON_ENTRY)
|
|
33
|
+
: fileURLToPath(new URL('./channel-daemon.mjs', import.meta.url));
|
|
23
34
|
|
|
24
35
|
// A global package update can briefly remove channel-worker-preload.cjs while
|
|
25
36
|
// the worker is spawning (MODULE_NOT_FOUND boot crash). Retry once before
|
|
@@ -83,7 +94,20 @@ export function createStandaloneChannelWorker({
|
|
|
83
94
|
let bootGeneration = 0;
|
|
84
95
|
let inProcessMod = null;
|
|
85
96
|
let inProcessStartPromise = null;
|
|
97
|
+
// Set when in-process mode (MIXDOG_CHANNEL_WORKER_PROCESS=0) holds the daemon
|
|
98
|
+
// singleton lock, so stop() releases it (never double-own beside a daemon).
|
|
99
|
+
let inProcessOwned = false;
|
|
100
|
+
// Machine-global channels daemon attach handle. Replaces the per-TUI fork +
|
|
101
|
+
// node-IPC call/notify plumbing; the daemon is SHARED and never killed on
|
|
102
|
+
// this TUI's exit (only detached-from). The fork machinery below is retained
|
|
103
|
+
// as an unreachable legacy path while useProcessWorker is true.
|
|
104
|
+
let daemonClient = null;
|
|
105
|
+
let attachPromise = null;
|
|
106
|
+
let daemonPid = null;
|
|
86
107
|
let nextCallId = 1;
|
|
108
|
+
// Per-proxy unique prefix so a callId can never collide across TUIs sharing
|
|
109
|
+
// the same supervisor pid (which would cross-dedup two distinct calls).
|
|
110
|
+
const proxyId = randomUUID();
|
|
87
111
|
let parentExitCleanup = null;
|
|
88
112
|
const pending = new Map();
|
|
89
113
|
const ownedChildPids = new Set();
|
|
@@ -159,10 +183,10 @@ export function createStandaloneChannelWorker({
|
|
|
159
183
|
};
|
|
160
184
|
}
|
|
161
185
|
return {
|
|
162
|
-
running: Boolean(
|
|
163
|
-
pid:
|
|
164
|
-
pending:
|
|
165
|
-
mode: '
|
|
186
|
+
running: Boolean(daemonClient),
|
|
187
|
+
pid: daemonPid,
|
|
188
|
+
pending: 0,
|
|
189
|
+
mode: 'daemon',
|
|
166
190
|
};
|
|
167
191
|
}
|
|
168
192
|
|
|
@@ -224,6 +248,9 @@ export function createStandaloneChannelWorker({
|
|
|
224
248
|
|
|
225
249
|
function start() {
|
|
226
250
|
if (!useProcessWorker) return startInProcess();
|
|
251
|
+
// Daemon mode (default): spawn-or-attach to the machine-global channels
|
|
252
|
+
// daemon. The fork code below is unreachable while useProcessWorker is true.
|
|
253
|
+
return ensureDaemonAttached().then(() => status());
|
|
227
254
|
if (stopPromise) {
|
|
228
255
|
return stopPromise.then(() => start());
|
|
229
256
|
}
|
|
@@ -335,6 +362,155 @@ export function createStandaloneChannelWorker({
|
|
|
335
362
|
}
|
|
336
363
|
|
|
337
364
|
async function startInProcess() {
|
|
365
|
+
if (inProcessMod || inProcessStartPromise) return _startInProcess();
|
|
366
|
+
// Singleton guard: in-process mode must not run a SECOND live bridge owner
|
|
367
|
+
// beside the machine-global daemon (double Discord gateway). Claim the same
|
|
368
|
+
// owner lock the daemon uses; if a LIVE daemon holds it, fail loudly rather
|
|
369
|
+
// than silently double-own (unset MIXDOG_CHANNEL_WORKER_PROCESS=0 to attach).
|
|
370
|
+
const claim = claimSingletonOwner(daemonOwnerPath, { kind: 'channel-runtime-daemon', pid: process.pid, meta: { cwd, mode: 'in-process' } });
|
|
371
|
+
if (!claim.owned) {
|
|
372
|
+
throw new Error(`in-process channels runtime refused: a live channels daemon (pid=${claim.owner?.pid}) already owns the bridge — refusing to double-own. Unset MIXDOG_CHANNEL_WORKER_PROCESS=0 to attach, or stop the daemon.`);
|
|
373
|
+
}
|
|
374
|
+
inProcessOwned = true;
|
|
375
|
+
try {
|
|
376
|
+
return await _startInProcess();
|
|
377
|
+
} catch (err) {
|
|
378
|
+
// Boot failed: release the just-claimed lock so a retry (or the daemon)
|
|
379
|
+
// can own it instead of leaking a live lock held by a non-running runtime.
|
|
380
|
+
if (inProcessOwned && !inProcessMod) {
|
|
381
|
+
try { releaseSingletonOwner(daemonOwnerPath, process.pid); } catch {}
|
|
382
|
+
inProcessOwned = false;
|
|
383
|
+
}
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Machine-global daemon: spawn-or-attach ────────────────────────────────
|
|
389
|
+
// Second+ TUI attaches to the live daemon instead of forking. Discovery is a
|
|
390
|
+
// pid-verified file (channel-daemon.json, 127.0.0.1 only). A stale daemon
|
|
391
|
+
// (dead pid) or unhealthy one is respawned; a spawn race loser exits(0) so we
|
|
392
|
+
// re-read discovery and attach to the winner (pid-verified singleton lock).
|
|
393
|
+
const daemonLeadPid = Number(process.env.MIXDOG_SUPERVISOR_PID) || process.pid;
|
|
394
|
+
const discoveryPath = join(runtimeRoot(), 'channel-daemon.json');
|
|
395
|
+
function daemonDelay(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
396
|
+
// Same singleton owner file the daemon claims (dataDir-relative), so the
|
|
397
|
+
// in-process fallback can refuse to run beside a live daemon.
|
|
398
|
+
const daemonOwnerPath = join(dataDir, 'channel-daemon-owner.json');
|
|
399
|
+
// Drop the cached attach so the next ensureDaemonAttached() re-reads discovery,
|
|
400
|
+
// health-probes, and respawns a dead daemon — the recovery hinge for a daemon
|
|
401
|
+
// restart mid-session (no TUI process restart needed).
|
|
402
|
+
function invalidateDaemonClient(reason = 'invalidate', expected = null) {
|
|
403
|
+
// CAS: only tear down when the live handle is still the one that failed, so
|
|
404
|
+
// a concurrent call that already re-attached a fresh daemon isn't clobbered.
|
|
405
|
+
if (expected && daemonClient !== expected) return;
|
|
406
|
+
const client = daemonClient;
|
|
407
|
+
daemonClient = null;
|
|
408
|
+
attachPromise = null;
|
|
409
|
+
daemonPid = null;
|
|
410
|
+
if (client) { try { client.close(reason); } catch {} }
|
|
411
|
+
}
|
|
412
|
+
function daemonEnv() {
|
|
413
|
+
const env = { ...process.env };
|
|
414
|
+
scrubLoaderVars(env);
|
|
415
|
+
return {
|
|
416
|
+
...env,
|
|
417
|
+
MIXDOG_ROOT: rootDir,
|
|
418
|
+
MIXDOG_DATA_DIR: dataDir,
|
|
419
|
+
MIXDOG_STANDALONE: '1',
|
|
420
|
+
MIXDOG_WORKER_MODE: '1',
|
|
421
|
+
MIXDOG_CHANNEL_DAEMON: '1',
|
|
422
|
+
MIXDOG_CLI_OWNED: '0',
|
|
423
|
+
MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
|
|
424
|
+
MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// Fork one daemon candidate DETACHED (it outlives this TUI — machine-global)
|
|
428
|
+
// and NEVER track it for parent-exit kill. Resolves when the candidate reports
|
|
429
|
+
// ready OR exits (race loss/crash); the caller then re-checks discovery.
|
|
430
|
+
function spawnDaemonCandidate() {
|
|
431
|
+
return new Promise((resolveSpawn) => {
|
|
432
|
+
let settled = false;
|
|
433
|
+
const done = () => { if (settled) return; settled = true; resolveSpawn(); };
|
|
434
|
+
let daemon;
|
|
435
|
+
try {
|
|
436
|
+
daemon = fork(DAEMON_ENTRY, [], {
|
|
437
|
+
cwd,
|
|
438
|
+
execArgv: ['--require', WORKER_PRELOAD],
|
|
439
|
+
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
440
|
+
detached: true,
|
|
441
|
+
env: daemonEnv(),
|
|
442
|
+
windowsHide: true,
|
|
443
|
+
});
|
|
444
|
+
} catch (err) {
|
|
445
|
+
logLine(logPath, `daemon spawn failed: ${err?.message || err}`);
|
|
446
|
+
done();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
daemon.stderr?.on('data', (chunk) => {
|
|
450
|
+
const text = String(chunk || '').trimEnd();
|
|
451
|
+
if (text) logLine(logPath, text);
|
|
452
|
+
});
|
|
453
|
+
daemon.once('message', (msg) => {
|
|
454
|
+
if (msg && msg.type === 'ready') {
|
|
455
|
+
// Fully detach: the daemon must not be tied to this TUI's IPC/lifecycle.
|
|
456
|
+
try { daemon.disconnect?.(); } catch {}
|
|
457
|
+
try { daemon.unref?.(); } catch {}
|
|
458
|
+
try { daemon.stderr?.unref?.(); } catch {}
|
|
459
|
+
done();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
daemon.once('exit', done); // race loss (exit 0) or crash → re-check discovery
|
|
463
|
+
daemon.once('error', (err) => { logLine(logPath, `daemon spawn error: ${err?.message || err}`); done(); });
|
|
464
|
+
const t = setTimeout(done, 20_000);
|
|
465
|
+
t.unref?.();
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
async function doAttach(discovery) {
|
|
469
|
+
const client = await attachToDaemon({
|
|
470
|
+
discovery,
|
|
471
|
+
leadPid: daemonLeadPid,
|
|
472
|
+
cwd,
|
|
473
|
+
onNotify: (msg) => { try { onNotify?.(msg); } catch {} },
|
|
474
|
+
// SSE gave up (daemon dead/restarted): drop this attach and proactively
|
|
475
|
+
// re-attach so notifies resume without waiting for the next call().
|
|
476
|
+
onFatal: () => { invalidateDaemonClient('sse fatal', client); void ensureDaemonAttached().catch(() => {}); },
|
|
477
|
+
log: (line) => logLine(logPath, line),
|
|
478
|
+
});
|
|
479
|
+
daemonClient = client;
|
|
480
|
+
daemonPid = discovery.pid;
|
|
481
|
+
return client;
|
|
482
|
+
}
|
|
483
|
+
async function ensureDaemonAttached() {
|
|
484
|
+
if (daemonClient) return daemonClient;
|
|
485
|
+
if (attachPromise) return attachPromise;
|
|
486
|
+
attachPromise = (async () => {
|
|
487
|
+
const deadline = Date.now() + 30_000;
|
|
488
|
+
for (let attempt = 0; ; attempt++) {
|
|
489
|
+
let discovery = readDaemonDiscovery(discoveryPath);
|
|
490
|
+
if (discovery) {
|
|
491
|
+
const health = await probeDaemonHealth({ port: discovery.port, token: discovery.token, timeoutMs: attempt === 0 ? 800 : 2000 });
|
|
492
|
+
if (health) return await doAttach(discovery);
|
|
493
|
+
discovery = null; // published but unhealthy → respawn
|
|
494
|
+
}
|
|
495
|
+
// No live daemon (absent / dead pid / unhealthy): spawn a candidate. A
|
|
496
|
+
// race loser exits(0); the winner publishes discovery we then attach to.
|
|
497
|
+
await spawnDaemonCandidate();
|
|
498
|
+
const after = readDaemonDiscovery(discoveryPath);
|
|
499
|
+
if (after) {
|
|
500
|
+
const health = await probeDaemonHealth({ port: after.port, token: after.token, timeoutMs: 3000 });
|
|
501
|
+
if (health) return await doAttach(after);
|
|
502
|
+
}
|
|
503
|
+
if (Date.now() > deadline) throw new Error('channels daemon did not become ready');
|
|
504
|
+
await daemonDelay(200);
|
|
505
|
+
}
|
|
506
|
+
})();
|
|
507
|
+
try {
|
|
508
|
+
return await attachPromise;
|
|
509
|
+
} finally {
|
|
510
|
+
attachPromise = null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function _startInProcess() {
|
|
338
514
|
if (inProcessMod) return status();
|
|
339
515
|
if (inProcessStartPromise) return inProcessStartPromise;
|
|
340
516
|
inProcessStartPromise = (async () => {
|
|
@@ -370,6 +546,25 @@ export function createStandaloneChannelWorker({
|
|
|
370
546
|
if (timer) clearTimeout(timer);
|
|
371
547
|
}
|
|
372
548
|
}
|
|
549
|
+
// Daemon path: dispatch over HTTP with bounded re-attach so a daemon
|
|
550
|
+
// death/restart is transparent — a transport failure drops the stale attach
|
|
551
|
+
// and ensureDaemonAttached re-reads discovery + respawns-if-dead.
|
|
552
|
+
let lastDaemonErr = null;
|
|
553
|
+
// One stable callId for this logical call, reused across retries so the
|
|
554
|
+
// daemon dedups a retried transport failure to a single side-effect.
|
|
555
|
+
const logicalCallId = `ch_${proxyId}_${nextCallId++}`;
|
|
556
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
557
|
+
const daemon = await ensureDaemonAttached();
|
|
558
|
+
try {
|
|
559
|
+
return await daemon.call(name, args || {}, { timeoutMs, callId: logicalCallId });
|
|
560
|
+
} catch (err) {
|
|
561
|
+
if (!err?.daemonTransportError) throw err; // tool error → surface as-is
|
|
562
|
+
lastDaemonErr = err;
|
|
563
|
+
invalidateDaemonClient('transport failure', daemon);
|
|
564
|
+
await daemonDelay(200 * (attempt + 1));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
throw lastDaemonErr || new Error('channels daemon call failed');
|
|
373
568
|
if (!child || !child.send) throw new Error('channels worker is not running');
|
|
374
569
|
const callId = `ch_${nextCallId++}`;
|
|
375
570
|
return await new Promise((resolve, reject) => {
|
|
@@ -449,6 +644,9 @@ export function createStandaloneChannelWorker({
|
|
|
449
644
|
if (stopPromise) return stopPromise;
|
|
450
645
|
if (!useProcessWorker) {
|
|
451
646
|
if (!inProcessMod && !inProcessStartPromise) {
|
|
647
|
+
// Nothing running, but a lock may still be held if a prior boot claimed
|
|
648
|
+
// then failed without clearing — release it so stop() never leaks it.
|
|
649
|
+
if (inProcessOwned) { try { releaseSingletonOwner(daemonOwnerPath, process.pid); } catch {} inProcessOwned = false; }
|
|
452
650
|
return Promise.resolve(false);
|
|
453
651
|
}
|
|
454
652
|
stopPromise = Promise.resolve(inProcessStartPromise)
|
|
@@ -456,6 +654,7 @@ export function createStandaloneChannelWorker({
|
|
|
456
654
|
.then(async () => {
|
|
457
655
|
try { await inProcessMod?.stop?.(reason); } catch {}
|
|
458
656
|
inProcessMod = null;
|
|
657
|
+
if (inProcessOwned) { try { releaseSingletonOwner(daemonOwnerPath, process.pid); } catch {} inProcessOwned = false; }
|
|
459
658
|
return true;
|
|
460
659
|
})
|
|
461
660
|
.finally(() => {
|
|
@@ -463,6 +662,16 @@ export function createStandaloneChannelWorker({
|
|
|
463
662
|
});
|
|
464
663
|
return stopPromise;
|
|
465
664
|
}
|
|
665
|
+
// Daemon path: detach this TUI's client only. The shared daemon reaps
|
|
666
|
+
// itself via client-grace once the last TUI leaves; never kill it here.
|
|
667
|
+
if (useProcessWorker) {
|
|
668
|
+
const client = daemonClient;
|
|
669
|
+
daemonClient = null;
|
|
670
|
+
attachPromise = null;
|
|
671
|
+
daemonPid = null;
|
|
672
|
+
if (!client) return Promise.resolve(false);
|
|
673
|
+
return client.close(reason).then(() => true).catch(() => true);
|
|
674
|
+
}
|
|
466
675
|
if (!child) {
|
|
467
676
|
return Promise.resolve(false);
|
|
468
677
|
}
|
|
@@ -352,11 +352,79 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
|
|
|
352
352
|
return saveRules(rules);
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// --- debounced hooks.json persist ------------------------------------------
|
|
356
|
+
// Rule state is flipped in the in-memory rulesCache synchronously so the
|
|
357
|
+
// picker reopen renders from memory (no disk re-read); the heavy file RMW
|
|
358
|
+
// (saveRules) is deferred so a burst of toggle key presses collapses into one
|
|
359
|
+
// write. Mirrors config-lifecycle's scheduleSkillsSave pattern.
|
|
360
|
+
const RULES_SAVE_DEBOUNCE_MS = 400;
|
|
361
|
+
// Pending flips are tracked as index→enabled patches (not a full snapshot) so
|
|
362
|
+
// that if hooks.json is edited externally during the debounce window the flush
|
|
363
|
+
// can reload the current disk rules and reapply only our enabled-flag changes
|
|
364
|
+
// instead of clobbering the external edit.
|
|
365
|
+
let pendingRulePatches = null;
|
|
366
|
+
let rulesBaseMtime = null;
|
|
367
|
+
let rulesSaveTimer = null;
|
|
368
|
+
|
|
369
|
+
function flushRules() {
|
|
370
|
+
if (rulesSaveTimer) {
|
|
371
|
+
clearTimeout(rulesSaveTimer);
|
|
372
|
+
rulesSaveTimer = null;
|
|
373
|
+
}
|
|
374
|
+
if (!pendingRulePatches || pendingRulePatches.size === 0) {
|
|
375
|
+
pendingRulePatches = null;
|
|
376
|
+
rulesBaseMtime = null;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const patches = pendingRulePatches;
|
|
380
|
+
const base = rulesBaseMtime;
|
|
381
|
+
pendingRulePatches = null;
|
|
382
|
+
rulesBaseMtime = null;
|
|
383
|
+
try {
|
|
384
|
+
let rules;
|
|
385
|
+
const diskChanged = rulesPath && existsSync(rulesPath) && statSync(rulesPath).mtimeMs !== base;
|
|
386
|
+
if (diskChanged) {
|
|
387
|
+
// External edit during the debounce window: reload from disk and reapply
|
|
388
|
+
// only our flips so the external change survives.
|
|
389
|
+
const parsed = JSON.parse(readFileSync(rulesPath, 'utf8'));
|
|
390
|
+
rules = normalizeRules(parsed).filter((rule) => rule && typeof rule === 'object');
|
|
391
|
+
} else {
|
|
392
|
+
rules = [...(rulesCache.rules || [])];
|
|
393
|
+
}
|
|
394
|
+
for (const [index, enabled] of patches) {
|
|
395
|
+
if (index >= 0 && index < rules.length) rules[index] = { ...rules[index], enabled };
|
|
396
|
+
}
|
|
397
|
+
saveRules(rules);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
emit('hook:error', { error: `debounced hooks save failed: ${error?.message || error}` });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function scheduleRulesSave() {
|
|
404
|
+
if (rulesSaveTimer) clearTimeout(rulesSaveTimer);
|
|
405
|
+
rulesSaveTimer = setTimeout(flushRules, RULES_SAVE_DEBOUNCE_MS);
|
|
406
|
+
rulesSaveTimer.unref?.();
|
|
407
|
+
}
|
|
408
|
+
|
|
355
409
|
function setRuleEnabled(index, enabled) {
|
|
356
410
|
const rules = [...loadRules()];
|
|
357
411
|
if (!Number.isInteger(index) || index < 0 || index >= rules.length) throw new Error(`hook rule not found: ${index}`);
|
|
358
|
-
|
|
359
|
-
|
|
412
|
+
const nextEnabled = enabled !== false;
|
|
413
|
+
rules[index] = { ...rules[index], enabled: nextEnabled };
|
|
414
|
+
// Adopt in memory immediately: keep the last-known disk mtime so loadRules
|
|
415
|
+
// returns this flipped cache (disk is untouched until the debounce flushes),
|
|
416
|
+
// and drop the config cache so a re-read reflects the change.
|
|
417
|
+
rulesCache = { ...rulesCache, rules };
|
|
418
|
+
configCache.key = '';
|
|
419
|
+
if (!pendingRulePatches) {
|
|
420
|
+
pendingRulePatches = new Map();
|
|
421
|
+
// mtime the in-memory cache was loaded from; flush compares against it to
|
|
422
|
+
// detect an external edit made during the debounce window.
|
|
423
|
+
rulesBaseMtime = rulesCache.mtimeMs;
|
|
424
|
+
}
|
|
425
|
+
pendingRulePatches.set(index, nextEnabled);
|
|
426
|
+
scheduleRulesSave();
|
|
427
|
+
return listRules();
|
|
360
428
|
}
|
|
361
429
|
|
|
362
430
|
function deleteRule(index) {
|
|
@@ -496,5 +564,5 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
|
|
|
496
564
|
return rewakeHandler;
|
|
497
565
|
}
|
|
498
566
|
|
|
499
|
-
return { addRule, beforeTool, deleteRule, dispatch, emit, listRules, setRewakeHandler, setRuleEnabled, status };
|
|
567
|
+
return { addRule, beforeTool, deleteRule, dispatch, emit, flushRules, listRules, setRewakeHandler, setRuleEnabled, status };
|
|
500
568
|
}
|