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
@@ -1,16 +1,14 @@
1
- // Bridge ownership snapshot + owner heartbeat cluster. Extracted verbatim from
2
- // channels/index.mjs (behavior-preserving). Self-contained: owns its own
3
- // heartbeat timer + last-note dedup state, and depends only on the
4
- // active-instance read/refresh primitives + identity, threaded as injected
5
- // deps so the module reads live file-level references at call time.
1
+ // Bridge ownership snapshot + ownership logging. Ownership TRUTH now comes from
2
+ // the OS-enforced seat lock (lib/seat-lock.mjs): this process owns the bridge
3
+ // iff it holds the named-pipe/unix-socket listener. The old 5s file heartbeat +
4
+ // last-wins active-instance CAS is retired a holder crash auto-releases the
5
+ // pipe and takeover is an explicit release message, so no periodic refresh or
6
+ // staleness eviction is needed. active-instance.json is now a pure metadata
7
+ // advert written elsewhere; it is no longer read to decide ownership.
6
8
  function createOwnerHeartbeat({
7
- getInstanceId,
8
- readActiveInstance,
9
- refreshActiveInstance,
10
- OWNER_HEARTBEAT_INTERVAL_MS = 5e3,
9
+ isSeatHeld,
11
10
  }) {
12
11
  let lastOwnershipNote = "";
13
- let ownerHeartbeatTimer = null;
14
12
 
15
13
  function logOwnership(note) {
16
14
  if (lastOwnershipNote === note) return;
@@ -19,65 +17,18 @@ function createOwnerHeartbeat({
19
17
  `);
20
18
  }
21
19
  function currentOwnerState() {
22
- const active = readActiveInstance();
23
- return {
24
- active,
25
- // Strict last-wins: this process owns the bridge ONLY when active-instance
26
- // names exactly this INSTANCE_ID. A newer remote session that claims the
27
- // seat overwrites instanceId, so the old owner immediately reads owned=false
28
- // and disconnects on its next refresh tick. No PID/terminal fallback —
29
- // that used to let a co-terminal worker wrongly self-claim.
30
- owned: active?.instanceId === getInstanceId()
31
- };
20
+ // Ownership is exactly "do we hold the seat lock". No file read, no PID
21
+ // heuristic, no staleness window.
22
+ return { owned: isSeatHeld() === true };
32
23
  }
33
24
  function getBridgeOwnershipSnapshot() {
34
25
  return currentOwnerState();
35
26
  }
36
- function claimBridgeOwnership(reason, options = {}) {
37
- // Returns true only when THIS instance actually holds the seat after the
38
- // write. With options.timeoutMs:0 (try-once) a contended lock throws
39
- // ELOCKCONTENDED — the caller catches it and treats the seat as busy.
40
- const refreshOpts = Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : undefined;
41
- const res = refreshActiveInstance(getInstanceId(), undefined, refreshOpts);
42
- const claimed = res?.instanceId === getInstanceId();
43
- if (claimed) logOwnership(`claimed owner (${reason})`);
44
- return claimed;
45
- }
46
- function startOwnerHeartbeat() {
47
- if (ownerHeartbeatTimer) return;
48
- ownerHeartbeatTimer = setInterval(() => {
49
- try {
50
- // Last-wins guard: only refresh the seat if we STILL own it. If a newer
51
- // remote session claimed active-instance.json since our last tick, do
52
- // NOT overwrite it back — that would re-steal ownership and cause
53
- // ping-pong / double backend connections. The bridgeOwnershipTimer's
54
- // refreshBridgeOwnership() will observe owned=false and disconnect us.
55
- // onlyIfOwned: re-checks ownership INSIDE the file lock so a newer
56
- // owner claiming the seat between currentOwnerState() and the locked
57
- // write is never overwritten (see refreshActiveInstance CAS guard).
58
- // Try-once (timeoutMs:0): the heartbeat must never block on the
59
- // active-instance lock. On contention refreshActiveInstance throws and
60
- // we simply skip this tick — the next tick catches up.
61
- if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true, timeoutMs: 0 });
62
- } catch (e) {
63
- process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
64
- }
65
- }, OWNER_HEARTBEAT_INTERVAL_MS);
66
- ownerHeartbeatTimer.unref?.();
67
- }
68
- function stopOwnerHeartbeat() {
69
- if (!ownerHeartbeatTimer) return;
70
- clearInterval(ownerHeartbeatTimer);
71
- ownerHeartbeatTimer = null;
72
- }
73
27
 
74
28
  return {
75
29
  logOwnership,
76
30
  currentOwnerState,
77
31
  getBridgeOwnershipSnapshot,
78
- claimBridgeOwnership,
79
- startOwnerHeartbeat,
80
- stopOwnerHeartbeat,
81
32
  };
82
33
  }
83
34
 
@@ -14,6 +14,16 @@ function normalizeChannelNotifyParams(method, params) {
14
14
  return params;
15
15
  }
16
16
 
17
+ // Pluggable notify sink. Under the per-TUI fork model notifies flow over
18
+ // node-IPC to the parent (process.send). Under the machine-global daemon there
19
+ // is no IPC parent: the daemon entry installs a sink that routes each notify to
20
+ // the CORRECT attached TUI over the transport's SSE fan-out (targeted, never
21
+ // broadcast). When a sink is installed it fully replaces the process.send path.
22
+ let _notifySink = null;
23
+ function setChannelNotifySink(fn) {
24
+ _notifySink = typeof fn === 'function' ? fn : null;
25
+ }
26
+
17
27
  function createParentBridge({ getInstanceId }) {
18
28
  function sendNotifyToParent(method, params) {
19
29
  // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
@@ -22,6 +32,11 @@ function createParentBridge({ getInstanceId }) {
22
32
  // silent_to_agent stays boolean — an internal routing flag the daemon
23
33
  // router / agentNotify consume (=== true) before the CC zod boundary.
24
34
  const outParams = normalizeChannelNotifyParams(method, params);
35
+ if (_notifySink) {
36
+ try { _notifySink(method, outParams); }
37
+ catch (err) { try { process.stderr.write(`mixdog channels: notify sink failed: ${err && err.message || err}\n`); } catch {} }
38
+ return;
39
+ }
25
40
  if (!process.send) {
26
41
  try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
27
42
  return;
@@ -85,4 +100,4 @@ function createParentBridge({ getInstanceId }) {
85
100
  };
86
101
  }
87
102
 
88
- export { createParentBridge, normalizeChannelNotifyParams };
103
+ export { createParentBridge, normalizeChannelNotifyParams, setChannelNotifySink };
@@ -38,6 +38,24 @@ function parsePositivePid(value) {
38
38
  function getTerminalLeadPid() {
39
39
  return parsePositivePid(process.env.MIXDOG_SUPERVISOR_PID) ?? process.pid;
40
40
  }
41
+ // Daemon pointer-owner override. The machine-global daemon is spawned once, so
42
+ // its intrinsic terminalLeadPid pins to the FIRST spawner. When the routing
43
+ // pointer moves (a TUI claims the bridge / rebinds its transcript) the daemon
44
+ // calls setOwnerContext({leadPid,cwd}) so active-instance.json owner fields
45
+ // track the CURRENT bound TUI — statusline/webhook/memory resolve the pointer
46
+ // TUI (and its session-cwd), not the spawner.
47
+ let _ownerContextOverride = null;
48
+ function setOwnerContext(ctx) {
49
+ const leadPid = parsePositivePid(ctx?.leadPid);
50
+ const next = leadPid ? { leadPid, cwd: ctx?.cwd ?? null } : null;
51
+ const changed = JSON.stringify(next) !== JSON.stringify(_ownerContextOverride);
52
+ _ownerContextOverride = next;
53
+ // Synchronously re-advertise so external readers (statusline/webhook/memory)
54
+ // see the new owner/cwd immediately, not on the next incidental refresh.
55
+ if (changed) {
56
+ try { refreshActiveInstance(makeInstanceId(), {}); } catch {}
57
+ }
58
+ }
41
59
  function getServerPid() {
42
60
  return parsePositivePid(process.env.MIXDOG_SERVER_PID) ?? (process.env.MIXDOG_WORKER_MODE === "1" ? null : process.pid);
43
61
  }
@@ -57,7 +75,6 @@ function isPidAlive(pid) {
57
75
  return e?.code === "EPERM";
58
76
  }
59
77
  }
60
- const UI_HEARTBEAT_STALE_MS = 5 * 60 * 1e3;
61
78
  function activeInstanceStaleReason(state) {
62
79
  const ownerPid = getActiveOwnerPid(state);
63
80
  if (!isPidAlive(ownerPid)) return `owner PID ${ownerPid ?? "unknown"} is dead`;
@@ -67,39 +84,16 @@ function activeInstanceStaleReason(state) {
67
84
  if (workerPid && !isPidAlive(workerPid)) return `worker PID ${workerPid} is dead`;
68
85
  const serverPid = parsePositivePid(state?.server_pid);
69
86
  if (serverPid && !isPidAlive(serverPid)) return `server PID ${serverPid} is dead`;
70
- // Zombie-Lead repro (2026-07-02): a Lead's owner/channels/worker/server
71
- // PIDs can all still be alive (process not killed) while the TUI's render
72
- // loop is dead in the water no signal ever fires, so pid-only staleness
73
- // never trips. If the TUI is heartbeating (field present), treat a stale
74
- // heartbeat as stale ownership too. Backward-compat: state written by an
75
- // older/non-TUI process (or before the first heartbeat tick) simply omits
76
- // ui_heartbeat_at, so this branch is a no-op and pid-only judgment stands.
77
- const uiHeartbeatAt = Number(state?.ui_heartbeat_at);
78
- if (Number.isFinite(uiHeartbeatAt) && uiHeartbeatAt > 0) {
79
- const age = Date.now() - uiHeartbeatAt;
80
- if (age > UI_HEARTBEAT_STALE_MS) {
81
- return `ui heartbeat stale (${Math.round(age / 1000)}s since last tick)`;
82
- }
83
- }
87
+ // NOTE: ownership is no longer decided here — the OS seat lock
88
+ // (lib/seat-lock.mjs) is the authority. This PID-death check only marks a
89
+ // metadata advert whose owning process is gone so refreshActiveInstance can
90
+ // avoid preserving its owner fields. The former ui_heartbeat_at staleness
91
+ // branch was removed: its false-stale eviction was the Discord-flapping root
92
+ // cause, and a crashed holder now auto-releases the seat instead.
84
93
  return null;
85
94
  }
86
- // Called from src/tui on a 30s timer while the render loop is alive. Only
87
- // touches ui_heartbeat_at (and updatedAt) so it never races/clobbers the
88
- // channels worker's own refreshActiveInstance() writes.
89
- function touchUiHeartbeat(instanceId) {
90
- ensureRuntimeDirs();
91
- try {
92
- updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
93
- if (!curRaw) return undefined;
94
- if (instanceId && curRaw.instanceId !== instanceId) return undefined;
95
- return { ...curRaw, ui_heartbeat_at: Date.now(), updatedAt: Date.now() };
96
- }, { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate', timeoutMs: 0 });
97
- } catch { /* best-effort try-once (timeoutMs:0): on lock contention we skip
98
- this tick without ever blocking the render loop on Atomics.wait;
99
- the next 30s tick catches up. */ }
100
- }
101
95
  function buildRuntimeIdentity() {
102
- const terminalLeadPid = getTerminalLeadPid();
96
+ const terminalLeadPid = _ownerContextOverride?.leadPid ?? getTerminalLeadPid();
103
97
  const serverPid = getServerPid();
104
98
  return {
105
99
  ownerLeadPid: terminalLeadPid,
@@ -108,6 +102,7 @@ function buildRuntimeIdentity() {
108
102
  server_pid: serverPid,
109
103
  worker_pid: process.pid,
110
104
  ...process.env.MIXDOG_WORKER_MODE === "1" ? { channels_pid: process.pid } : {},
105
+ ...(_ownerContextOverride?.cwd ? { sessionCwd: _ownerContextOverride.cwd } : {}),
111
106
  };
112
107
  }
113
108
  function getTurnEndPath(instanceId) {
@@ -140,71 +135,11 @@ function readActiveInstance() {
140
135
  state = readJsonFile(ACTIVE_INSTANCE_FILE, null);
141
136
  if (!state) return null;
142
137
  }
143
- const staleReason = activeInstanceStaleReason(state);
144
- if (staleReason) {
145
- // Owner is dead but the file may carry process-independent runtime
146
- // state (memory_port, pg_*) that next callers want to preserve. Wiping
147
- // the whole file forced the next refresh to rebuild from {} and drop
148
- // memory_port, which broke the scheduler and MCP memory lookup
149
- // until the memory worker re-advertised. Instead, clear only the
150
- // owner-identity fields and keep the rest as a stale-but-useful prev
151
- // for the next refresh. refreshActiveInstance still treats the
152
- // returned state as "no live owner" via getActiveOwnerPid downstream.
153
- const {
154
- pinned: _stalePinned,
155
- instanceId: _staleId,
156
- ownerLeadPid: _staleOwner,
157
- terminalLeadPid: _staleTerm,
158
- supervisor_pid: _staleSup,
159
- server_pid: _staleServer,
160
- worker_pid: _staleWorker,
161
- channels_pid: _staleChannels,
162
- supervisor_started_at: _staleStart,
163
- server_started_at: _staleServerStart,
164
- httpPort: _staleHttpPort,
165
- backendReady: _staleBackendReady,
166
- turnEndFile: _staleTurnEnd,
167
- statusFile: _staleStatus,
168
- } = state ?? {};
169
- const ownerFieldsAlreadyEmpty = _staleId === undefined
170
- && _staleOwner === undefined
171
- && _staleTerm === undefined
172
- && _staleSup === undefined;
173
- if (!ownerFieldsAlreadyEmpty) {
174
- process.stderr.write(`mixdog: stale active-instance.json (${staleReason}), clearing owner fields\n`);
175
- try {
176
- updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
177
- if (!curRaw) return undefined;
178
- const liveReason = activeInstanceStaleReason(curRaw);
179
- if (!liveReason) return undefined;
180
- const {
181
- pinned: _stalePinned2,
182
- instanceId: _staleId2,
183
- ownerLeadPid: _staleOwner2,
184
- terminalLeadPid: _staleTerm2,
185
- supervisor_pid: _staleSup2,
186
- server_pid: _staleServer2,
187
- worker_pid: _staleWorker2,
188
- channels_pid: _staleChannels2,
189
- supervisor_started_at: _staleStart2,
190
- server_started_at: _staleServerStart2,
191
- httpPort: _staleHttpPort2,
192
- backendReady: _staleBackendReady2,
193
- turnEndFile: _staleTurnEnd2,
194
- statusFile: _staleStatus2,
195
- ...stableRestLocked
196
- } = curRaw ?? {};
197
- const ownerEmpty = _staleId2 === undefined
198
- && _staleOwner2 === undefined
199
- && _staleTerm2 === undefined
200
- && _staleSup2 === undefined;
201
- if (ownerEmpty) return undefined;
202
- return { ...stableRestLocked, updatedAt: Date.now() };
203
- }, { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' });
204
- } catch {}
205
- }
206
- return null;
207
- }
138
+ // Pure metadata-advert read: ownership is the OS seat lock now, so this no
139
+ // longer evicts a "stale" owner (the false-stale ui_heartbeat eviction was
140
+ // the Discord-flapping root cause). A crashed holder auto-releases the seat;
141
+ // its leftover advert is harmless and refreshActiveInstance's own
142
+ // preservation logic decides which fields to carry forward.
208
143
  return state;
209
144
  }
210
145
  function writeActiveInstance(state) {
@@ -401,22 +336,6 @@ function refreshActiveInstance(instanceId, meta, options) {
401
336
  }
402
337
  }
403
338
  }
404
- // Headless worker-owned seat: THIS process is the channels worker AND no
405
- // distinct terminal lead owns the seat (ownerLeadPid resolves to our own
406
- // pid). A worker never runs the TUI render loop, so it owns no heartbeat —
407
- // it must NOT keep refreshing a ui_heartbeat_at it does not own. An
408
- // inherited value (written by a prior TUI session, carried forward in
409
- // prevRest) would otherwise be perpetually renewed here and falsely mask a
410
- // dead render loop, so DROP it and let pid-only judgment stand.
411
- // When a distinct terminal lead owns the seat (ownerLeadPid !== our pid),
412
- // this branch is a no-op: the TUI's own heartbeat is carried forward
413
- // untouched, so a stale (zombie) render loop still evicts the seat even
414
- // while the worker pid is alive.
415
- const workerOwnsSeat = process.env.MIXDOG_WORKER_MODE === "1"
416
- && identity.ownerLeadPid === process.pid;
417
- if (workerOwnsSeat) {
418
- delete next.ui_heartbeat_at;
419
- }
420
339
  return { ...preservedExtra, ...next };
421
340
  }, writeOpts);
422
341
  }
@@ -572,6 +491,6 @@ export {
572
491
  probeActiveOwner,
573
492
  refreshActiveInstance,
574
493
  releaseOwnedChannelLocks,
575
- touchUiHeartbeat,
494
+ setOwnerContext,
576
495
  writeServerPid
577
496
  };
@@ -0,0 +1,196 @@
1
+ import net from "net";
2
+ import { createHash } from "crypto";
3
+ import { existsSync, unlinkSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ // OS-enforced bridge-seat singleton. The seat is a named-pipe (win32) / unix
7
+ // domain socket (posix) server: acquiring the seat == successfully listen()ing
8
+ // on the address. Because the OS binds exactly one listener per address, at
9
+ // most one process can hold the seat at a time — no file heartbeat / staleness
10
+ // heuristics, and a holder crash auto-releases (win32 removes the pipe; posix
11
+ // leaves a stale socket file that a connect-probe detects and unlinks).
12
+ //
13
+ // Takeover is an EXPLICIT release message, not a last-wins file overwrite: a
14
+ // contender that finds the seat occupied connects to the holder and sends
15
+ // {"op":"takeover",...}; the holder runs its teardown (onTakeover -> the
16
+ // worker's stopOwnedRuntime) then closes the server, letting the contender's
17
+ // retried listen() succeed within a bounded backoff window.
18
+ function hashRoot(runtimeRoot) {
19
+ return createHash("sha1").update(String(runtimeRoot)).digest("hex").slice(0, 16);
20
+ }
21
+ function delay(ms) {
22
+ return new Promise((r) => setTimeout(r, ms));
23
+ }
24
+ export function createSeatLock({ runtimeRoot, instanceId }) {
25
+ const isWin = process.platform === "win32";
26
+ const address = isWin
27
+ ? `\\\\.\\pipe\\mixdog-seat-${hashRoot(runtimeRoot)}`
28
+ : join(runtimeRoot, "seat.sock");
29
+ let server = null;
30
+ let held = false;
31
+ let takeoverHandler = null;
32
+ let releasing = null;
33
+
34
+ function isSeatHeld() {
35
+ // Daemon model: the machine-global channels daemon is the permanent
36
+ // singleton bridge owner (pid-verified channel-daemon-owner lock). Ownership
37
+ // is unconditional — the OS seat lease/heartbeat/steal is retired.
38
+ return true;
39
+ }
40
+ function seatAddress() {
41
+ return address;
42
+ }
43
+ // Register the teardown to run when a takeover message arrives (or on an
44
+ // explicit releaseSeat). Runs BEFORE the server closes so the contender only
45
+ // wins the seat once we've released the backend.
46
+ function onTakeover(cb) {
47
+ takeoverHandler = cb;
48
+ }
49
+ function closeServer() {
50
+ // Capture holder status BEFORE nulling: only the process that actually held
51
+ // the listener may unlink the posix socket file. A non-holder close must
52
+ // never unlink — otherwise it races away a NEW holder's freshly-bound
53
+ // socket, re-opening the seat as falsely vacant (double-owner).
54
+ const wasListener = server !== null;
55
+ return new Promise((resolve) => {
56
+ const s = server;
57
+ server = null;
58
+ held = false;
59
+ if (!s) return resolve();
60
+ try {
61
+ s.close(() => resolve());
62
+ } catch {
63
+ resolve();
64
+ }
65
+ }).then(() => {
66
+ // posix: remove OUR socket file so the next listen() is clean — but only
67
+ // when we were the confirmed listener (see wasListener above).
68
+ if (!isWin && wasListener) {
69
+ try { if (existsSync(address)) unlinkSync(address); } catch {}
70
+ }
71
+ });
72
+ }
73
+ async function runTeardownThenClose(reason) {
74
+ // Coalesce concurrent release paths (takeover message + explicit stop).
75
+ if (releasing) return releasing;
76
+ releasing = (async () => {
77
+ try { await takeoverHandler?.(reason); } catch {}
78
+ await closeServer();
79
+ })();
80
+ try { await releasing; } finally { releasing = null; }
81
+ }
82
+ function handleLine(line, sock) {
83
+ let msg = null;
84
+ try { msg = JSON.parse(line); } catch { return; }
85
+ if (msg?.op === "takeover") {
86
+ // Ack first so the contender knows we received the takeover and is not
87
+ // left waiting the full timeout, then release.
88
+ try { sock.write(JSON.stringify({ op: "ack", instanceId }) + "\n"); } catch {}
89
+ void runTeardownThenClose({ reason: "takeover", byInstanceId: msg.instanceId });
90
+ }
91
+ }
92
+ function attachServer(s) {
93
+ s.on("connection", (sock) => {
94
+ let buf = "";
95
+ sock.on("data", (chunk) => {
96
+ buf += chunk.toString("utf8");
97
+ let idx;
98
+ while ((idx = buf.indexOf("\n")) >= 0) {
99
+ const line = buf.slice(0, idx);
100
+ buf = buf.slice(idx + 1);
101
+ if (line) handleLine(line, sock);
102
+ }
103
+ });
104
+ sock.on("error", () => {});
105
+ });
106
+ // A server-level error after listen (e.g. pipe invalidated) must not crash
107
+ // the worker; drop the dead handle so the next acquire re-listens.
108
+ s.on("error", () => {});
109
+ }
110
+ function tryListen() {
111
+ return new Promise((resolve, reject) => {
112
+ const s = net.createServer();
113
+ const onError = (err) => { s.removeListener("listening", onListening); reject(err); };
114
+ const onListening = () => {
115
+ s.removeListener("error", onError);
116
+ server = s;
117
+ held = true;
118
+ attachServer(s);
119
+ s.unref?.();
120
+ resolve(true);
121
+ };
122
+ s.once("error", onError);
123
+ s.once("listening", onListening);
124
+ s.listen(address);
125
+ });
126
+ }
127
+ // Connect to the current holder. When takeover=true, send the takeover
128
+ // message. Resolves:
129
+ // 'alive' — a live holder answered (or we connected) -> back off
130
+ // 'stale' — connect refused/ENOENT (posix crashed holder) -> unlink+retry
131
+ function connectHolder({ takeover }) {
132
+ return new Promise((resolve) => {
133
+ const sock = net.createConnection(address);
134
+ let settled = false;
135
+ const done = (r) => {
136
+ if (settled) return;
137
+ settled = true;
138
+ try { sock.destroy(); } catch {}
139
+ resolve(r);
140
+ };
141
+ sock.on("connect", () => {
142
+ if (takeover) {
143
+ try { sock.write(JSON.stringify({ op: "takeover", instanceId }) + "\n"); } catch { done("alive"); }
144
+ } else {
145
+ done("alive");
146
+ }
147
+ });
148
+ sock.on("data", () => done("alive")); // ack
149
+ sock.on("error", (err) => {
150
+ const code = err?.code;
151
+ done(code === "ECONNREFUSED" || code === "ENOENT" ? "stale" : "alive");
152
+ });
153
+ sock.on("close", () => done("alive"));
154
+ const t = setTimeout(() => done("alive"), 1500);
155
+ t.unref?.();
156
+ });
157
+ }
158
+ // Acquire the seat. force=true (default) takes over a live holder via the
159
+ // takeover message; force=false (claim-if-vacant) backs off when a live
160
+ // holder is present but still reclaims a stale (crashed) posix socket.
161
+ // Returns true on success, false on timeout / vacant-only backoff.
162
+ // Deletion of the seat lease/steal protocol. Under the daemon model there is
163
+ // exactly one channels runtime per machine (enforced upstream by the
164
+ // singleton daemon-owner lock), so acquiring the bridge seat is unconditional
165
+ // and never contends — no listen()/EADDRINUSE probe, no explicit takeover
166
+ // message, no claim-if-vacant backoff, no false-stale steal. The former
167
+ // named-pipe / unix-socket server machinery (tryListen/connectHolder/
168
+ // handleLine/attachServer) is now unreachable and kept only until the file is
169
+ // pruned in a follow-up cleanup.
170
+ async function acquireSeat(_opts = {}) {
171
+ held = true;
172
+ return true;
173
+ }
174
+ // Graceful release: run teardown (if any) then close the server. Idempotent.
175
+ async function releaseSeat(reason = { reason: "release" }) {
176
+ if (!server && !held) {
177
+ // Not the holder: nothing to close and — critically — do NOT unlink; the
178
+ // socket file may belong to a NEW holder that bound after we released.
179
+ return;
180
+ }
181
+ await runTeardownThenClose(reason);
182
+ }
183
+ // Close the server WITHOUT running the teardown callback. Used by the worker's
184
+ // stopOwnedRuntime itself so the teardown (which calls this) does not recurse.
185
+ async function closeSeatServer() {
186
+ await closeServer();
187
+ }
188
+ return {
189
+ acquireSeat,
190
+ releaseSeat,
191
+ closeSeatServer,
192
+ onTakeover,
193
+ isSeatHeld,
194
+ seatAddress,
195
+ };
196
+ }
@@ -44,10 +44,11 @@ function getParentPid(pid) {
44
44
  // which fires per transcript watchDebounce tick, so without these flags users
45
45
  // see a powershell.exe console window pop in/out repeatedly during
46
46
  // any chat activity — including while the config UI is loading.
47
+ // NO `-WindowStyle Hidden` CLI switch: windowsHide already covers it,
48
+ // and the token triggers Defender's PowhidSubExec false positive.
47
49
  const out2 = execFileSync("powershell.exe", [
48
50
  "-NoProfile",
49
51
  "-NonInteractive",
50
- "-WindowStyle", "Hidden",
51
52
  "-Command",
52
53
  `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ParentProcessId`
53
54
  ], {
@@ -23,10 +23,10 @@ function createToolDispatch({
23
23
  setChannelBridgeActive,
24
24
  writeBridgeState,
25
25
  stopServerTyping,
26
- claimBridgeOwnership,
27
26
  notifyRemoteAcquired,
28
27
  refreshBridgeOwnership,
29
28
  bindPersistedTranscriptIfAny,
29
+ rebindCurrentTranscript,
30
30
  stopOwnedRuntime,
31
31
  reloadRuntimeConfig,
32
32
  } = lifecycle;
@@ -59,14 +59,15 @@ function createToolDispatch({
59
59
  // double reconnect). refreshBridgeOwnership below still re-pins
60
60
  // the forwarder binding onto THIS session either way.
61
61
  if (getOwned?.() !== true) {
62
- claimBridgeOwnership(wasActive ? "re-activate takeover" : "bridge activated");
63
- // Genuine acquire transition (we were NOT the owner) tell the
64
- // parent to flip remote ON. Not fired when we already own the
65
- // seat, so an idempotent re-activate never re-notifies.
62
+ // Genuine acquire transition (we do NOT hold the seat) — tell the
63
+ // parent to flip remote ON. The seat itself is acquired inside
64
+ // refreshBridgeOwnership({ claim: true }) below (explicit
65
+ // takeover). An idempotent re-activate (already own the seat)
66
+ // skips both the notify and the takeover.
66
67
  notifyRemoteAcquired?.();
67
68
  }
68
69
  try {
69
- await refreshBridgeOwnership({ restoreBinding: true });
70
+ await refreshBridgeOwnership({ restoreBinding: true, claim: true });
70
71
  // An already-connected owner returns early from
71
72
  // startOwnedRuntime(), so rebind explicitly to follow the
72
73
  // current (parent-chain) session transcript.
@@ -104,6 +105,16 @@ function createToolDispatch({
104
105
  result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
105
106
  break;
106
107
  }
108
+ case "rebind_current_transcript": {
109
+ // Lead-pushed repoint to the transcript it just created/rebound.
110
+ // Best-effort + idempotent: absent channelId or path => no-op; a
111
+ // bind failure is swallowed by the outer try so lead paths never
112
+ // throw. Same binding path as the inbound steal.
113
+ const transcriptPath = typeof args.transcriptPath === "string" ? args.transcriptPath.trim() : "";
114
+ if (transcriptPath) await rebindCurrentTranscript(transcriptPath);
115
+ result = { content: [{ type: "text", text: `transcript rebind ${transcriptPath ? "pushed" : "skipped (no path)"}` }] };
116
+ break;
117
+ }
107
118
  // memory — handled by memory-service.mjs MCP
108
119
  default:
109
120
  result = {
@@ -132,7 +143,10 @@ function createToolDispatch({
132
143
  // Debounce: only forward when ≥250 ms have elapsed since the last forward,
133
144
  // to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
134
145
  const now = Date.now();
135
- if (now - _lastForwardMs >= 250) {
146
+ // The transcript rebind op must repoint the forwarder BEFORE any flush;
147
+ // running the pre-flush first would send stale-transcript text ahead of the
148
+ // rebind. Skip the pre-flush for it so rebinding always precedes forwarding.
149
+ if (toolName !== 'rebind_current_transcript' && now - _lastForwardMs >= 250) {
136
150
  _lastForwardMs = now;
137
151
  await forwarder.forwardNewText();
138
152
  }
@@ -140,7 +154,9 @@ function createToolDispatch({
140
154
  // Remote-owner startup: ensure this owner's backend is connected.
141
155
  for (let i = 0; i < 2 && !getBridgeRuntimeConnected(); i++) {
142
156
  try {
143
- await refreshBridgeOwnership();
157
+ // Auto-connect a remote owner. claimIfVacant: acquire the seat only
158
+ // when vacant/stale — never steal a live holder from this retry path.
159
+ await refreshBridgeOwnership({ claim: true, claimIfVacant: true });
144
160
  } catch {
145
161
  }
146
162
  if (!getBridgeRuntimeConnected()) await new Promise((r) => setTimeout(r, 300));