mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -0,0 +1,545 @@
1
+ // Machine-global channels daemon — HTTP + SSE transport (server side).
2
+ //
3
+ // Replaces the per-TUI fork + node-IPC (`{type:'call'|'notify'}`) plumbing with
4
+ // ONE local HTTP server that many TUIs attach to. Design mirrors the memory
5
+ // daemon (src/runtime/memory/index.mjs): 127.0.0.1-only, /client/register +
6
+ // /health + client-grace self-shutdown. It adds an SSE fan-out for the
7
+ // worker->parent notify path so notifications reach the CORRECT attached TUI
8
+ // (targeted routing, never broadcast — see routeNotify below).
9
+ //
10
+ // This module owns ONLY the transport (sockets, client registry, notify
11
+ // routing, discovery file, lifecycle). The channels runtime (tool dispatch,
12
+ // Discord backend, transcript bind/steal) is injected via `handleCall` so the
13
+ // same transport is exercised by the real daemon entry AND the smoke harness
14
+ // (stub runtime, no Discord token).
15
+ import http from 'node:http';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { rmSync } from 'node:fs';
18
+ import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
19
+ import { readBody, sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
20
+
21
+ function parsePid(value) {
22
+ const n = Number(value);
23
+ return Number.isInteger(n) && n > 0 ? n : null;
24
+ }
25
+
26
+ function isPidAlive(pid) {
27
+ const n = parsePid(pid);
28
+ if (!n) return false;
29
+ try { process.kill(n, 0); return true; }
30
+ catch (error) { return error?.code === 'EPERM'; }
31
+ }
32
+
33
+ // Tool names whose successful call re-points the routing pointer at the caller.
34
+ // These are the Lead-only "I am now the bound TUI" signals (bridge claim on
35
+ // start, Lead-pushed transcript repoint). Per the routing ruling the pointer is
36
+ // NOT a lease: last-bind-wins, pid-verified at delivery, no heartbeat expiry.
37
+ const POINTER_TOOLS = new Set(['activate_channel_bridge', 'rebind_current_transcript']);
38
+
39
+ export function createChannelDaemonTransport({
40
+ handleCall,
41
+ discoveryPath,
42
+ serverToken = randomUUID(),
43
+ log = () => {},
44
+ clientGraceMs = 10_000,
45
+ sweepMs = 5_000,
46
+ onClientsEmpty = null,
47
+ getStatus = () => ({}),
48
+ dispatchBind = null,
49
+ } = {}) {
50
+ if (typeof handleCall !== 'function') throw new Error('handleCall is required');
51
+
52
+ // token -> { token, leadPid, cwd, sse, lastSeen, registeredAt }
53
+ const clients = new Map();
54
+ // leadPid of the last client to claim the bridge / repoint the transcript.
55
+ // Under a single-owner bridge there is exactly one bound transcript at a
56
+ // time, so the pointer client IS the transcript owner — this unifies notify
57
+ // targeting for both inbound-message notifies and proactive injects.
58
+ let pointerToken = null;
59
+ let boundPort = null;
60
+ // Sticky replay cache for the bridge remote-state notify. The daemon emits
61
+ // 'notifications/mixdog/remote' {state:'acquired'} at boot (and 'superseded'
62
+ // on repoint). That is a STATE signal, not an inbound message: every TUI must
63
+ // observe the current remote-enabled state, and a late/non-pointer TUI that
64
+ // attaches after the one-shot notify would otherwise never learn it. We stash
65
+ // the latest such frame and replay it to each client as it attaches. Only the
66
+ // remote-state notify is cached/replayed — inbound notifies
67
+ // (notifications/claude/channel) stay pointer-targeted, never broadcast.
68
+ const REMOTE_STATE_METHOD = 'notifications/mixdog/remote';
69
+ let stickyRemoteFrame = null;
70
+ // Idempotency cache: callId -> { promise }. A retried /call with the SAME
71
+ // callId awaits/returns the ORIGINAL run's result, so a transport-failure
72
+ // retry never double-runs a non-idempotent tool (e.g. reply). Short TTL.
73
+ const callCache = new Map();
74
+ const CALL_CACHE_TTL_MS = 60_000;
75
+ let server = null;
76
+ let graceTimer = null;
77
+ let sweepTimer = null;
78
+ let everHadClient = false;
79
+ let closed = false;
80
+
81
+ function nowMs() { return Date.now(); }
82
+
83
+ function pruneDeadClients() {
84
+ for (const [token, c] of clients) {
85
+ // A client is dead when its lead pid is gone OR its SSE stream closed and
86
+ // it has not re-registered within a grace window. pid death is the
87
+ // authoritative signal (mirrors memory daemon pruneDeadClients).
88
+ if (!isPidAlive(c.leadPid)) {
89
+ dropClient(token, 'pid dead');
90
+ }
91
+ }
92
+ }
93
+
94
+ function liveClients() {
95
+ const out = [];
96
+ for (const [token, c] of clients) {
97
+ if (isPidAlive(c.leadPid)) out.push([token, c]);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function dropClient(token, reason) {
103
+ const c = clients.get(token);
104
+ if (!c) return;
105
+ const wasPointer = pointerToken === token;
106
+ clients.delete(token);
107
+ try { c.sse?.end?.(); } catch {}
108
+ if (pointerToken === token) pointerToken = null;
109
+ log(`client ${token} (lead=${c.leadPid}) removed: ${reason}`);
110
+ // Pointer client death → hand the seat to a survivor (last-wins among the
111
+ // living), replay its badge, and rebind the output forwarder to it. Skipped
112
+ // during stop()/close (grace shutdown owns teardown) and when no live client
113
+ // remains (grace shutdown path).
114
+ if (wasPointer && !closed) failoverPointer(reason);
115
+ maybeArmGrace('client removed');
116
+ }
117
+
118
+ // Pointer failover on owner death. Move the pointer to the most-recently-seen
119
+ // LIVE client (reason 'failover'), deliver the sticky 'acquired' badge to it
120
+ // (pending-buffered if it has no SSE yet), and re-dispatch ITS stored bind
121
+ // intent so the output forwarder rebinds to the survivor's transcript. A
122
+ // survivor with no stored bind still gets the pointer + badge (no rebind).
123
+ function failoverPointer(reason) {
124
+ let best = null;
125
+ for (const [, c] of liveClients()) {
126
+ if (!best || c.lastSeen > best.lastSeen) best = c;
127
+ }
128
+ if (!best) return; // no live clients — let grace shutdown run
129
+ movePointer(best.token, 'failover');
130
+ // Persist the acquired badge as the sticky frame so a later attachSse (or a
131
+ // reconnect that follows the pointer) can replay it — matters when the
132
+ // runtime never emitted 'acquired' since daemon boot (sticky still null).
133
+ stickyRemoteFrame = JSON.stringify({ type: 'notify', method: REMOTE_STATE_METHOD, params: { state: 'acquired' } });
134
+ writeRemoteStateTo(best, 'acquired');
135
+ if (best.lastBind && typeof dispatchBind === 'function') {
136
+ const { name, args } = best.lastBind;
137
+ log(`failover rebind -> token=${best.token} lead=${best.leadPid} bind=${name} (${reason})`);
138
+ try {
139
+ Promise.resolve()
140
+ .then(() => dispatchBind(name, args || {}, { clientToken: best.token, leadPid: best.leadPid, cwd: best.cwd }))
141
+ .catch((err) => log(`failover rebind dispatch failed for lead=${best.leadPid}: ${err?.message || err}`));
142
+ } catch (err) { log(`failover rebind dispatch failed for lead=${best.leadPid}: ${err?.message || err}`); }
143
+ }
144
+ }
145
+
146
+ // Re-dispatch a client's stored bind intent so the output forwarder rebinds to
147
+ // ITS transcript — the same mechanism failoverPointer uses on owner death,
148
+ // applied when a client GAINS the pointer while already holding a bind (e.g.
149
+ // migrated by pointer-follows-reconnect). Guarded, logged, non-throwing; does
150
+ // not touch last-wins ownership or sticky replay.
151
+ function redispatchPointerBind(client, reason) {
152
+ if (!client?.lastBind || typeof dispatchBind !== 'function') return;
153
+ const { name, args } = client.lastBind;
154
+ log(`pointer rebind -> token=${client.token} lead=${client.leadPid} bind=${name} (${reason})`);
155
+ try {
156
+ Promise.resolve()
157
+ .then(() => dispatchBind(name, args || {}, { clientToken: client.token, leadPid: client.leadPid, cwd: client.cwd }))
158
+ .catch((err) => log(`pointer rebind dispatch failed for lead=${client.leadPid}: ${err?.message || err}`));
159
+ } catch (err) { log(`pointer rebind dispatch failed for lead=${client.leadPid}: ${err?.message || err}`); }
160
+ }
161
+
162
+ function cancelGrace() {
163
+ if (graceTimer) { try { clearTimeout(graceTimer); } catch {} graceTimer = null; }
164
+ }
165
+
166
+ function maybeArmGrace(reason) {
167
+ if (closed || graceTimer) return;
168
+ if (!everHadClient || clients.size > 0) return;
169
+ if (typeof onClientsEmpty !== 'function' || clientGraceMs <= 0) return;
170
+ graceTimer = setTimeout(() => {
171
+ graceTimer = null;
172
+ pruneDeadClients();
173
+ if (clients.size > 0) return;
174
+ log(`client grace elapsed (${reason}); no live clients — self-shutdown`);
175
+ try { onClientsEmpty(); } catch {}
176
+ }, clientGraceMs);
177
+ graceTimer.unref?.();
178
+ }
179
+
180
+ function startSweep() {
181
+ if (sweepTimer || typeof onClientsEmpty !== 'function') return;
182
+ sweepTimer = setInterval(() => {
183
+ pruneDeadClients();
184
+ if (everHadClient && clients.size === 0) maybeArmGrace('all clients gone (sweep)');
185
+ }, Math.max(1000, Math.min(sweepMs, clientGraceMs || sweepMs)));
186
+ sweepTimer.unref?.();
187
+ }
188
+
189
+ // Resolve the ONE client that should receive a notify. Pointer client first
190
+ // (pid-verified at delivery), then most-recently-seen live client, else drop.
191
+ // Never broadcasts — a broadcast would inject one inbound Discord message into
192
+ // every attached TUI's Lead session.
193
+ function resolveTarget() {
194
+ if (pointerToken) {
195
+ const c = clients.get(pointerToken);
196
+ if (c && isPidAlive(c.leadPid)) return c;
197
+ // Dead pointer discovered at notify time (i.e. BEFORE the sweep prunes
198
+ // it): route removal through dropClient so failoverPointer runs — the
199
+ // survivor gets the pointer, sticky 'acquired' badge, and its bind
200
+ // intent re-dispatched. Silently nulling the token here bypassed all of
201
+ // that (pointer stayed null; survivor never got badge/rebind).
202
+ if (c) dropClient(pointerToken, 'pid dead (notify-time)');
203
+ else pointerToken = null;
204
+ // failoverPointer (via dropClient) may have installed a new pointer.
205
+ if (pointerToken) {
206
+ const p = clients.get(pointerToken);
207
+ if (p && isPidAlive(p.leadPid)) return p;
208
+ }
209
+ }
210
+ let best = null;
211
+ for (const [, c] of liveClients()) {
212
+ if (!best || c.lastSeen > best.lastSeen) best = c;
213
+ }
214
+ return best;
215
+ }
216
+
217
+ // Write a targeted remote-state frame to ONE client's SSE. If that client has
218
+ // no live stream yet (e.g. displaced mid-reconnect), BUFFER the frame on its
219
+ // pending queue and flush it when the stream (re)attaches — otherwise the
220
+ // 'superseded' signal is silently lost and the old owner keeps its badge.
221
+ function writeRemoteStateTo(client, state) {
222
+ if (!client) return false;
223
+ const frame = JSON.stringify({ type: 'notify', method: REMOTE_STATE_METHOD, params: { state } });
224
+ if (!client.sse) { client.pending.push(frame); return true; }
225
+ try { client.sse.write(`data: ${frame}\n\n`); return true; }
226
+ catch (err) { log(`remote-state '${state}' write failed for lead=${client.leadPid}: ${err?.message || err}`); return false; }
227
+ }
228
+
229
+ // Unified last-wins ownership move. The pointer client owns ALL THREE of
230
+ // inbound notify routing, outbound transcript binding, and the remote badge.
231
+ // Every move (new TUI register OR a bind-intent /call) hands the seat to the
232
+ // newcomer AND tells the DISPLACED owner it lost via a targeted 'superseded'
233
+ // frame — so the old TUI drops its badge and stops pushing rebinds. Skip the
234
+ // superseded when the old/new client share a leadPid (same TUI reconnecting /
235
+ // re-binding to itself — it never "lost").
236
+ function movePointer(newToken, reason) {
237
+ const oldToken = pointerToken;
238
+ if (oldToken === newToken) { pointerToken = newToken; return; }
239
+ pointerToken = newToken;
240
+ const oldClient = oldToken ? clients.get(oldToken) : null;
241
+ const newClient = clients.get(newToken);
242
+ log(`routing pointer -> token=${newToken} lead=${newClient?.leadPid ?? '?'} via ${reason}`);
243
+ if (!oldClient || oldClient === newClient) return;
244
+ if (newClient && oldClient.leadPid === newClient.leadPid) return; // same TUI
245
+ if (isPidAlive(oldClient.leadPid)) {
246
+ if (writeRemoteStateTo(oldClient, 'superseded')) {
247
+ log(`superseded -> displaced pointer token=${oldToken} lead=${oldClient.leadPid}`);
248
+ }
249
+ }
250
+ // Newcomer gaining the seat with a stored bind must rebind the output
251
+ // forwarder to ITS transcript, mirroring failoverPointer. Only for moves
252
+ // that carry NO fresh bind of their own: a POINTER_TOOLS /call already
253
+ // dispatches the new bind right after this returns (redispatching the stale
254
+ // lastBind first would fire an out-of-date rebind), and 'failover'
255
+ // self-dispatches best.lastBind. Last-wins ordering above is untouched.
256
+ if (reason !== 'failover' && !POINTER_TOOLS.has(reason)) redispatchPointerBind(newClient, reason);
257
+ }
258
+
259
+ function notify(method, params) {
260
+ if (method === REMOTE_STATE_METHOD) {
261
+ const frame = JSON.stringify({ type: 'notify', method, params });
262
+ if (params?.state === 'acquired') {
263
+ // 'acquired' is the standing badge state of the CURRENT owner. It is
264
+ // sticky (cached even with zero live clients, e.g. boot-time notify) so
265
+ // a late attach that IS the pointer can replay it. Under last-wins the
266
+ // owner is exactly the pointer client, so deliver ONLY there — a
267
+ // broadcast would light the badge on displaced/non-owner TUIs.
268
+ stickyRemoteFrame = frame;
269
+ const target = resolveTarget();
270
+ if (!target?.sse) { log('remote-state acquired not delivered (no live pointer SSE); sticky set'); return false; }
271
+ try { target.sse.write(`data: ${frame}\n\n`); return true; }
272
+ catch (err) { log(`remote-state acquired write failed for lead=${target.leadPid}: ${err?.message || err}`); return false; }
273
+ }
274
+ // 'superseded' (seat lost to ANOTHER daemon, owned-runtime.mjs) and any
275
+ // other transition CLEAR the sticky and broadcast to every live client —
276
+ // whoever holds the badge must drop it; replaying it to a future attach
277
+ // would wrongly stop a fresh remote client.
278
+ stickyRemoteFrame = null;
279
+ let delivered = false;
280
+ for (const [, c] of liveClients()) {
281
+ if (!c.sse) continue;
282
+ try { c.sse.write(`data: ${frame}\n\n`); delivered = true; }
283
+ catch (err) { log(`remote-state write failed for lead=${c.leadPid}: ${err?.message || err}`); }
284
+ }
285
+ if (!delivered) log('remote-state superseded not delivered live (no live SSE); sticky cleared');
286
+ return delivered;
287
+ }
288
+ const target = resolveTarget();
289
+ if (!target) {
290
+ log(`notify dropped (no live target): ${method}`);
291
+ return false;
292
+ }
293
+ if (!target.sse) {
294
+ log(`notify dropped (target has no SSE stream): ${method}`);
295
+ return false;
296
+ }
297
+ const frame = JSON.stringify({ type: 'notify', method, params });
298
+ try {
299
+ target.sse.write(`data: ${frame}\n\n`);
300
+ return true;
301
+ } catch (err) {
302
+ log(`notify write failed for lead=${target.leadPid}: ${err?.message || err}`);
303
+ return false;
304
+ }
305
+ }
306
+
307
+ function registerClient({ leadPid, cwd, reattach = false }) {
308
+ const pid = parsePid(leadPid) ?? 0;
309
+ const token = randomUUID();
310
+ clients.set(token, {
311
+ token,
312
+ leadPid: pid,
313
+ cwd: cwd || null,
314
+ sse: null,
315
+ pending: [],
316
+ lastSeen: nowMs(),
317
+ registeredAt: nowMs(),
318
+ });
319
+ everHadClient = true;
320
+ cancelGrace();
321
+ startSweep();
322
+ log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'}`);
323
+ // Pointer-follows-pid: if the current pointer client shares this leadPid AND
324
+ // has no live SSE, it is the SAME terminal re-registering after its stream
325
+ // dropped. That old entry is a dead-stream blackhole — pid-prune never reaps
326
+ // it (pid still alive) and sse-close never drops entries, so resolveTarget
327
+ // would write to a dead stream forever. Transfer the seat to the fresh
328
+ // token, migrate its buffered pending frames + last bind intent, and remove
329
+ // the old entry WITHOUT a failover or a self-'superseded'. A live-stream
330
+ // pointer that happens to share the pid (e.g. co-located clients) is left
331
+ // alone — last-wins handles a genuine fresh register below.
332
+ if (pointerToken && pointerToken !== token) {
333
+ const old = clients.get(pointerToken);
334
+ if (old && old.leadPid === pid && !old.sse) {
335
+ const fresh = clients.get(token);
336
+ if (old.pending?.length) fresh.pending.push(...old.pending.splice(0));
337
+ if (old.lastBind) fresh.lastBind = old.lastBind;
338
+ clients.delete(pointerToken);
339
+ try { old.sse?.end?.(); } catch {}
340
+ pointerToken = token;
341
+ log(`pointer follows reconnect -> token=${token} lead=${pid} (old entry dropped)`);
342
+ // The fresh token inherited the old entry's bind intent but never went
343
+ // through movePointer — re-dispatch it so the forwarder rebinds to this
344
+ // reconnected terminal's transcript (guarded/no-op when no lastBind).
345
+ redispatchPointerBind(fresh, 'reconnect');
346
+ return token;
347
+ }
348
+ }
349
+ if (reattach) {
350
+ // SSE-reconnect re-register: a pruned client re-registers with a FRESH
351
+ // token but it is NOT a new terminal — it must never steal ownership.
352
+ // Only adopt the pointer when none exists (avoid a notify blackhole).
353
+ if (!pointerToken) pointerToken = token;
354
+ } else {
355
+ // Last-wins: the NEWEST registered TUI (fresh terminal) always steals the
356
+ // ownership seat, and the displaced owner is told it lost (superseded).
357
+ movePointer(token, 'register');
358
+ }
359
+ return token;
360
+ }
361
+
362
+ function attachSse(token, res) {
363
+ const c = clients.get(token);
364
+ if (!c) return false;
365
+ res.writeHead(200, {
366
+ 'Content-Type': 'text/event-stream; charset=utf-8',
367
+ 'Cache-Control': 'no-cache, no-transform',
368
+ Connection: 'keep-alive',
369
+ 'X-Accel-Buffering': 'no',
370
+ });
371
+ // Prelude comment flushes headers so the client's SSE reader resolves.
372
+ res.write(': attached\n\n');
373
+ c.sse = res;
374
+ c.lastSeen = nowMs();
375
+ // Flush any targeted frames buffered while this client had no stream (e.g. a
376
+ // 'superseded' emitted at the moment it was reconnecting). Drop-with-client.
377
+ if (c.pending?.length) {
378
+ for (const frame of c.pending.splice(0)) {
379
+ try { res.write(`data: ${frame}\n\n`); } catch {}
380
+ }
381
+ }
382
+ // Replay the sticky 'acquired' badge ONLY when THIS attaching client is the
383
+ // current pointer (the owner). A non-pointer late attach must NOT light the
384
+ // badge — under last-wins only the newest owner holds it. The TUI-side
385
+ // handler is idempotent, so re-delivery to the owner is safe.
386
+ if (stickyRemoteFrame && token === pointerToken) {
387
+ try { res.write(`data: ${stickyRemoteFrame}\n\n`); } catch {}
388
+ }
389
+ const ka = setInterval(() => {
390
+ try { res.write(': ka\n\n'); } catch {}
391
+ }, 15_000);
392
+ ka.unref?.();
393
+ const cleanup = () => {
394
+ clearInterval(ka);
395
+ if (c.sse === res) c.sse = null;
396
+ // Stream loss alone does not drop the client (a TUI may reconnect); the
397
+ // sweep + pid check reaps genuinely dead clients.
398
+ maybeArmGrace('sse closed');
399
+ };
400
+ res.on('close', cleanup);
401
+ res.on('error', cleanup);
402
+ return true;
403
+ }
404
+
405
+ async function handleRequest(req, res) {
406
+ // 127.0.0.1 bind already restricts reachability; still refuse anything
407
+ // without our server token except /health (liveness probe is unauthed).
408
+ const url = new URL(req.url, 'http://127.0.0.1');
409
+ const pathName = url.pathname;
410
+ try {
411
+ if (req.method === 'GET' && pathName === '/health') {
412
+ sendJson(res, { status: 'ok', pid: process.pid, clients: clients.size, ...getStatus() });
413
+ return;
414
+ }
415
+ const token = req.headers['x-mixdog-daemon-token'] || url.searchParams.get('server_token');
416
+ if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
417
+
418
+ if (req.method === 'POST' && pathName === '/client/register') {
419
+ const body = await readBody(req);
420
+ const clientToken = registerClient({ leadPid: body.leadPid, cwd: body.cwd, reattach: body.reattach === true });
421
+ sendJson(res, { token: clientToken, pid: process.pid });
422
+ return;
423
+ }
424
+ if (req.method === 'POST' && pathName === '/client/deregister') {
425
+ const body = await readBody(req);
426
+ if (body.token) dropClient(body.token, 'deregister');
427
+ sendJson(res, { ok: true });
428
+ return;
429
+ }
430
+ if (req.method === 'GET' && pathName === '/events') {
431
+ const clientToken = url.searchParams.get('token');
432
+ if (!attachSse(clientToken, res)) { sendError(res, 'unknown client token', 404); return; }
433
+ return; // stream stays open
434
+ }
435
+ if (req.method === 'POST' && pathName === '/call') {
436
+ const body = await readBody(req);
437
+ const clientToken = body.token || null;
438
+ const c = clientToken ? clients.get(clientToken) : null;
439
+ if (c) c.lastSeen = nowMs();
440
+ const name = String(body.name || '');
441
+ // Bind-intent calls re-point routing at the caller BEFORE dispatch, so a
442
+ // notify emitted synchronously during the call already targets it.
443
+ if (c && POINTER_TOOLS.has(name)) {
444
+ // Last-wins bind claim (/remote or boot). Steals the seat AND tells
445
+ // the displaced owner it lost (targeted superseded).
446
+ movePointer(clientToken, name);
447
+ }
448
+ const callId = body.callId ? String(body.callId) : null;
449
+ let dispatch;
450
+ if (callId && callCache.has(callId)) {
451
+ // Replay of a retried call — dedup to the original run (exactly one
452
+ // side-effect) instead of dispatching handleCall a second time.
453
+ dispatch = callCache.get(callId).promise;
454
+ } else {
455
+ dispatch = Promise.resolve().then(() => handleCall(name, body.args || {}, {
456
+ clientToken,
457
+ leadPid: c?.leadPid ?? null,
458
+ cwd: c?.cwd ?? null,
459
+ }));
460
+ if (callId) {
461
+ callCache.set(callId, { promise: dispatch, at: nowMs() });
462
+ // Start the TTL only once the call SETTLES: an in-flight call can
463
+ // outlive a fixed-from-dispatch TTL (e.g. a slow reply upload past
464
+ // 60s), and expiring its entry mid-flight would let a transport
465
+ // retry replay-miss and dispatch a second real side-effect.
466
+ dispatch.then(() => {}, () => {}).then(() => {
467
+ const t = setTimeout(() => callCache.delete(callId), CALL_CACHE_TTL_MS);
468
+ t.unref?.();
469
+ });
470
+ }
471
+ }
472
+ try {
473
+ const result = await dispatch;
474
+ // Record the caller's last bind intent so a failover can rebind the
475
+ // output forwarder to THIS client's transcript when it becomes owner.
476
+ if (c && POINTER_TOOLS.has(name)) c.lastBind = { name, args: body.args || {} };
477
+ sendJson(res, { result });
478
+ } catch (err) {
479
+ sendJson(res, { error: err?.message || String(err) }, 200);
480
+ }
481
+ return;
482
+ }
483
+ if (req.method === 'POST' && pathName === '/shutdown') {
484
+ sendJson(res, { ok: true });
485
+ if (typeof onClientsEmpty === 'function') { try { onClientsEmpty(); } catch {} }
486
+ return;
487
+ }
488
+ sendError(res, 'not found', 404);
489
+ } catch (err) {
490
+ try { sendError(res, err?.message || String(err), err?.statusCode || 500); } catch {}
491
+ }
492
+ }
493
+
494
+ function writeDiscovery() {
495
+ if (!discoveryPath) return;
496
+ try {
497
+ writeJsonAtomicSync(discoveryPath, {
498
+ pid: process.pid,
499
+ port: boundPort,
500
+ token: serverToken,
501
+ startedAt: Date.now(),
502
+ }, { compact: true });
503
+ } catch (err) {
504
+ log(`discovery write failed: ${err?.message || err}`);
505
+ }
506
+ }
507
+
508
+ function start() {
509
+ return new Promise((resolve, reject) => {
510
+ server = http.createServer(handleRequest);
511
+ server.on('error', reject);
512
+ // 127.0.0.1 ONLY — never expose the daemon off-box.
513
+ server.listen(0, '127.0.0.1', () => {
514
+ server.removeListener('error', reject);
515
+ boundPort = server.address().port;
516
+ server.on('error', (err) => log(`server error: ${err?.message || err}`));
517
+ writeDiscovery();
518
+ log(`daemon transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
519
+ resolve({ port: boundPort, token: serverToken });
520
+ });
521
+ });
522
+ }
523
+
524
+ async function stop() {
525
+ closed = true;
526
+ cancelGrace();
527
+ if (sweepTimer) { try { clearInterval(sweepTimer); } catch {} sweepTimer = null; }
528
+ for (const [token] of clients) dropClient(token, 'transport stop');
529
+ if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
530
+ if (server) {
531
+ await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
532
+ server = null;
533
+ }
534
+ }
535
+
536
+ return {
537
+ start,
538
+ stop,
539
+ notify,
540
+ get port() { return boundPort; },
541
+ get token() { return serverToken; },
542
+ _clientsForTest: clients,
543
+ _resolveTargetForTest: resolveTarget,
544
+ };
545
+ }