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.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +150 -6
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
|
@@ -0,0 +1,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
|
+
}
|