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.
Files changed (91) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +165 -0
  5. package/scripts/channel-daemon-stub.mjs +69 -0
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  9. package/scripts/tool-efficiency-diag.mjs +1 -1
  10. package/scripts/tool-smoke.mjs +30 -17
  11. package/src/rules/lead/02-channels.md +3 -3
  12. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  20. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  21. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  28. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  29. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  30. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  31. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  32. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  33. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  34. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  35. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  36. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  37. package/src/runtime/memory/index.mjs +54 -7
  38. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  39. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  40. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  41. package/src/runtime/shared/atomic-file.mjs +20 -4
  42. package/src/runtime/shared/tool-primitives.mjs +31 -1
  43. package/src/runtime/shared/tool-surface.mjs +23 -13
  44. package/src/session-runtime/config-lifecycle.mjs +48 -7
  45. package/src/session-runtime/lifecycle-api.mjs +9 -0
  46. package/src/session-runtime/mcp-glue.mjs +63 -1
  47. package/src/session-runtime/resource-api.mjs +62 -8
  48. package/src/session-runtime/runtime-core.mjs +32 -2
  49. package/src/session-runtime/session-text.mjs +41 -0
  50. package/src/session-runtime/session-turn-api.mjs +24 -0
  51. package/src/session-runtime/settings-api.mjs +8 -1
  52. package/src/session-runtime/tool-catalog.mjs +306 -38
  53. package/src/session-runtime/tool-defs.mjs +7 -7
  54. package/src/session-runtime/workflow.mjs +2 -1
  55. package/src/standalone/channel-daemon-client.mjs +224 -0
  56. package/src/standalone/channel-daemon-transport.mjs +351 -0
  57. package/src/standalone/channel-daemon.mjs +139 -0
  58. package/src/standalone/channel-worker.mjs +213 -4
  59. package/src/standalone/hook-bus.mjs +71 -3
  60. package/src/tui/App.jsx +105 -17
  61. package/src/tui/app/clipboard.mjs +39 -19
  62. package/src/tui/app/doctor.mjs +57 -0
  63. package/src/tui/app/extension-pickers.mjs +53 -9
  64. package/src/tui/app/maintenance-pickers.mjs +0 -5
  65. package/src/tui/app/slash-dispatch.mjs +4 -4
  66. package/src/tui/app/text-layout.mjs +11 -0
  67. package/src/tui/app/use-mouse-input.mjs +235 -51
  68. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  69. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  70. package/src/tui/app/use-transcript-window.mjs +55 -1
  71. package/src/tui/components/Message.jsx +1 -1
  72. package/src/tui/components/PromptInput.jsx +3 -1
  73. package/src/tui/components/QueuedCommands.jsx +21 -10
  74. package/src/tui/components/ToolExecution.jsx +2 -2
  75. package/src/tui/dist/index.mjs +635 -306
  76. package/src/tui/engine/session-api.mjs +17 -7
  77. package/src/tui/engine/session-flow.mjs +6 -0
  78. package/src/tui/engine.mjs +8 -0
  79. package/src/tui/index.jsx +62 -18
  80. package/src/tui/paste-attachments.mjs +26 -0
  81. package/src/ui/statusline.mjs +45 -5
  82. package/src/ui/tool-card.mjs +8 -1
  83. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  84. package/src/workflows/bench/WORKFLOW.md +46 -0
  85. package/src/workflows/default/WORKFLOW.md +6 -0
  86. package/src/workflows/solo/WORKFLOW.md +5 -0
  87. package/vendor/ink/build/ink.js +23 -1
  88. package/vendor/ink/build/output.js +154 -71
  89. package/vendor/ink/build/render-node-to-output.js +44 -2
  90. package/vendor/ink/build/render.js +4 -0
  91. 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(child && child.exitCode == null && !child.killed),
163
- pid: child?.pid || null,
164
- pending: pending.size,
165
- mode: 'runtime',
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
- rules[index] = { ...rules[index], enabled: enabled !== false };
359
- return saveRules(rules);
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
  }