mixdog 0.9.22 → 0.9.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Machine-global channels daemon — attach client (TUI side).
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the memory proxy's attach pattern (POST /client/register + /health,
|
|
4
|
+
// 127.0.0.1 only). A TUI uses this to talk to the ONE shared channels daemon
|
|
5
|
+
// instead of forking its own worker: tool calls go over POST /call and the
|
|
6
|
+
// worker->parent notify path arrives on a persistent SSE stream (GET /events),
|
|
7
|
+
// replacing the old node-IPC `{type:'notify'}` messages.
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
function parsePid(value) {
|
|
12
|
+
const n = Number(value);
|
|
13
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parsePort(value) {
|
|
17
|
+
const n = Number(value);
|
|
18
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isPidAlive(pid) {
|
|
22
|
+
const n = parsePid(pid);
|
|
23
|
+
if (!n) return false;
|
|
24
|
+
try { process.kill(n, 0); return true; }
|
|
25
|
+
catch (error) { return error?.code === 'EPERM'; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Read + validate the discovery file. Returns null when missing/corrupt or when
|
|
29
|
+
// the recorded pid is dead (stale daemon) so the caller reclaims + respawns.
|
|
30
|
+
export function readDaemonDiscovery(discoveryPath) {
|
|
31
|
+
let raw;
|
|
32
|
+
try { raw = JSON.parse(readFileSync(discoveryPath, 'utf8')); }
|
|
33
|
+
catch { return null; }
|
|
34
|
+
const port = parsePort(raw?.port);
|
|
35
|
+
const pid = parsePid(raw?.pid);
|
|
36
|
+
if (!port || !pid || !raw?.token) return null;
|
|
37
|
+
if (!isPidAlive(pid)) return null; // dead daemon → treat as absent
|
|
38
|
+
return { port, pid, token: String(raw.token) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function request({ port, method = 'GET', path = '/', token, body = null, timeoutMs = 10_000 }) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const payload = body == null ? null : JSON.stringify(body);
|
|
44
|
+
const req = http.request({
|
|
45
|
+
hostname: '127.0.0.1',
|
|
46
|
+
port,
|
|
47
|
+
path,
|
|
48
|
+
method,
|
|
49
|
+
headers: {
|
|
50
|
+
...(token ? { 'X-Mixdog-Daemon-Token': token } : {}),
|
|
51
|
+
...(payload
|
|
52
|
+
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
|
53
|
+
: {}),
|
|
54
|
+
},
|
|
55
|
+
timeout: timeoutMs,
|
|
56
|
+
}, (res) => {
|
|
57
|
+
let data = '';
|
|
58
|
+
res.setEncoding('utf8');
|
|
59
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
let parsed = null;
|
|
62
|
+
try { parsed = data ? JSON.parse(data) : null; } catch {}
|
|
63
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
64
|
+
const err = new Error(parsed?.error || data || `HTTP ${res.statusCode}`);
|
|
65
|
+
err.statusCode = res.statusCode;
|
|
66
|
+
reject(err);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
resolve(parsed ?? {});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
req.on('error', reject);
|
|
73
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`daemon request timed out: ${method} ${path}`)); });
|
|
74
|
+
if (payload) req.write(payload);
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function probeDaemonHealth({ port, token, timeoutMs = 800 } = {}) {
|
|
80
|
+
try {
|
|
81
|
+
const health = await request({ port, token, path: '/health', timeoutMs });
|
|
82
|
+
return health?.status === 'ok' ? health : null;
|
|
83
|
+
} catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Attach to a live daemon described by `discovery` ({port, token}). Registers
|
|
87
|
+
// this client, opens the notify SSE stream, and returns a handle whose call()
|
|
88
|
+
// dispatches channel tools over HTTP. onNotify receives the SAME
|
|
89
|
+
// `{type:'notify', method, params}` shape the old IPC path delivered, so the
|
|
90
|
+
// TUI-side onNotify handler stays unchanged (thin glue).
|
|
91
|
+
export async function attachToDaemon({
|
|
92
|
+
discovery,
|
|
93
|
+
leadPid = process.pid,
|
|
94
|
+
cwd = process.cwd(),
|
|
95
|
+
onNotify = () => {},
|
|
96
|
+
log = () => {},
|
|
97
|
+
onFatal = () => {},
|
|
98
|
+
} = {}) {
|
|
99
|
+
if (!discovery?.port || !discovery?.token) throw new Error('daemon discovery {port, token} required');
|
|
100
|
+
const { port, token: serverToken } = discovery;
|
|
101
|
+
|
|
102
|
+
const reg = await request({
|
|
103
|
+
port,
|
|
104
|
+
token: serverToken,
|
|
105
|
+
method: 'POST',
|
|
106
|
+
path: '/client/register',
|
|
107
|
+
body: { leadPid, cwd },
|
|
108
|
+
timeoutMs: 3000,
|
|
109
|
+
});
|
|
110
|
+
let clientToken = reg?.token;
|
|
111
|
+
if (!clientToken) throw new Error('daemon register returned no client token');
|
|
112
|
+
|
|
113
|
+
let sseReq = null;
|
|
114
|
+
let closed = false;
|
|
115
|
+
let reconnectTimer = null;
|
|
116
|
+
// Bounded SSE reconnect: after repeated failures (daemon dead/restarted) give
|
|
117
|
+
// up and signal the owner via onFatal so it can invalidate the cached attach
|
|
118
|
+
// and re-attach fresh (re-read discovery, respawn-if-dead) rather than spin
|
|
119
|
+
// forever against a dead port.
|
|
120
|
+
let reconnectAttempts = 0;
|
|
121
|
+
const MAX_RECONNECTS = 5;
|
|
122
|
+
|
|
123
|
+
function openStream() {
|
|
124
|
+
if (closed) return;
|
|
125
|
+
const req = http.request({
|
|
126
|
+
hostname: '127.0.0.1',
|
|
127
|
+
port,
|
|
128
|
+
path: `/events?token=${encodeURIComponent(clientToken)}&server_token=${encodeURIComponent(serverToken)}`,
|
|
129
|
+
method: 'GET',
|
|
130
|
+
headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
|
|
131
|
+
}, (res) => {
|
|
132
|
+
if (res.statusCode !== 200) {
|
|
133
|
+
res.resume();
|
|
134
|
+
scheduleReconnect('bad sse status ' + res.statusCode);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
res.setEncoding('utf8');
|
|
138
|
+
reconnectAttempts = 0; // a live stream resets the failure budget
|
|
139
|
+
let buf = '';
|
|
140
|
+
res.on('data', (chunk) => {
|
|
141
|
+
buf += chunk;
|
|
142
|
+
let idx;
|
|
143
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
144
|
+
const raw = buf.slice(0, idx);
|
|
145
|
+
buf = buf.slice(idx + 2);
|
|
146
|
+
for (const line of raw.split('\n')) {
|
|
147
|
+
if (!line.startsWith('data:')) continue; // skip ': ka' keepalives
|
|
148
|
+
const json = line.slice(5).trim();
|
|
149
|
+
if (!json) continue;
|
|
150
|
+
let msg = null;
|
|
151
|
+
try { msg = JSON.parse(json); } catch { continue; }
|
|
152
|
+
if (msg?.type === 'notify') { try { onNotify(msg); } catch (e) { log(`onNotify threw: ${e?.message || e}`); } }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
res.on('end', () => scheduleReconnect('sse ended'));
|
|
157
|
+
res.on('error', () => scheduleReconnect('sse error'));
|
|
158
|
+
});
|
|
159
|
+
req.on('error', () => scheduleReconnect('sse req error'));
|
|
160
|
+
req.end();
|
|
161
|
+
sseReq = req;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scheduleReconnect(reason) {
|
|
165
|
+
if (closed || reconnectTimer) return;
|
|
166
|
+
if (++reconnectAttempts > MAX_RECONNECTS) {
|
|
167
|
+
log(`sse giving up after ${reconnectAttempts} attempts (${reason}); signalling re-attach`);
|
|
168
|
+
closed = true;
|
|
169
|
+
try { sseReq?.destroy?.(); } catch {}
|
|
170
|
+
try { onFatal(reason); } catch {}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
log(`sse reconnect scheduled (${reason}, attempt ${reconnectAttempts})`);
|
|
174
|
+
reconnectTimer = setTimeout(() => {
|
|
175
|
+
reconnectTimer = null;
|
|
176
|
+
// Re-register (the daemon may have pruned us) then reopen the stream.
|
|
177
|
+
// Re-register mints a fresh client token (the old one was pruned); adopt
|
|
178
|
+
// it so the reopened stream and subsequent calls target the live entry.
|
|
179
|
+
request({ port, token: serverToken, method: 'POST', path: '/client/register', body: { leadPid, cwd }, timeoutMs: 3000 })
|
|
180
|
+
.then((r) => { if (r?.token) clientToken = r.token; })
|
|
181
|
+
.catch(() => {})
|
|
182
|
+
.finally(() => openStream());
|
|
183
|
+
}, 1000);
|
|
184
|
+
reconnectTimer.unref?.();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
openStream();
|
|
188
|
+
|
|
189
|
+
async function call(name, args = {}, { timeoutMs = 120_000, callId = null } = {}) {
|
|
190
|
+
let out;
|
|
191
|
+
try {
|
|
192
|
+
out = await request({
|
|
193
|
+
port,
|
|
194
|
+
token: serverToken,
|
|
195
|
+
method: 'POST',
|
|
196
|
+
path: '/call',
|
|
197
|
+
// callId (stable across a logical call's retries) lets the daemon dedup
|
|
198
|
+
// a retried transport failure to a single side-effect.
|
|
199
|
+
body: { token: clientToken, name, args: args || {}, ...(callId ? { callId } : {}) },
|
|
200
|
+
timeoutMs,
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// Transport failure (daemon dead/restarted/unreachable) — tag so the
|
|
204
|
+
// worker's execute() drops this stale attach and re-attaches, instead of
|
|
205
|
+
// surfacing it as a tool error. Tool errors come back as {error} (200).
|
|
206
|
+
err.daemonTransportError = true;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
if (out && out.error) throw new Error(out.error);
|
|
210
|
+
return out?.result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function close(reason = 'client close') {
|
|
214
|
+
closed = true;
|
|
215
|
+
if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch {} reconnectTimer = null; }
|
|
216
|
+
try { sseReq?.destroy?.(); } catch {}
|
|
217
|
+
try {
|
|
218
|
+
await request({ port, token: serverToken, method: 'POST', path: '/client/deregister', body: { token: clientToken }, timeoutMs: 1500 });
|
|
219
|
+
} catch { /* best-effort; daemon sweep reaps us */ }
|
|
220
|
+
log(`detached (${reason})`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { call, close, clientToken, port };
|
|
224
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
} = {}) {
|
|
49
|
+
if (typeof handleCall !== 'function') throw new Error('handleCall is required');
|
|
50
|
+
|
|
51
|
+
// token -> { token, leadPid, cwd, sse, lastSeen, registeredAt }
|
|
52
|
+
const clients = new Map();
|
|
53
|
+
// leadPid of the last client to claim the bridge / repoint the transcript.
|
|
54
|
+
// Under a single-owner bridge there is exactly one bound transcript at a
|
|
55
|
+
// time, so the pointer client IS the transcript owner — this unifies notify
|
|
56
|
+
// targeting for both inbound-message notifies and proactive injects.
|
|
57
|
+
let pointerToken = null;
|
|
58
|
+
let boundPort = null;
|
|
59
|
+
// Idempotency cache: callId -> { promise }. A retried /call with the SAME
|
|
60
|
+
// callId awaits/returns the ORIGINAL run's result, so a transport-failure
|
|
61
|
+
// retry never double-runs a non-idempotent tool (e.g. reply). Short TTL.
|
|
62
|
+
const callCache = new Map();
|
|
63
|
+
const CALL_CACHE_TTL_MS = 60_000;
|
|
64
|
+
let server = null;
|
|
65
|
+
let graceTimer = null;
|
|
66
|
+
let sweepTimer = null;
|
|
67
|
+
let everHadClient = false;
|
|
68
|
+
let closed = false;
|
|
69
|
+
|
|
70
|
+
function nowMs() { return Date.now(); }
|
|
71
|
+
|
|
72
|
+
function pruneDeadClients() {
|
|
73
|
+
for (const [token, c] of clients) {
|
|
74
|
+
// A client is dead when its lead pid is gone OR its SSE stream closed and
|
|
75
|
+
// it has not re-registered within a grace window. pid death is the
|
|
76
|
+
// authoritative signal (mirrors memory daemon pruneDeadClients).
|
|
77
|
+
if (!isPidAlive(c.leadPid)) {
|
|
78
|
+
dropClient(token, 'pid dead');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function liveClients() {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const [token, c] of clients) {
|
|
86
|
+
if (isPidAlive(c.leadPid)) out.push([token, c]);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function dropClient(token, reason) {
|
|
92
|
+
const c = clients.get(token);
|
|
93
|
+
if (!c) return;
|
|
94
|
+
clients.delete(token);
|
|
95
|
+
try { c.sse?.end?.(); } catch {}
|
|
96
|
+
if (pointerToken === token) pointerToken = null;
|
|
97
|
+
log(`client ${token} (lead=${c.leadPid}) removed: ${reason}`);
|
|
98
|
+
maybeArmGrace('client removed');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function cancelGrace() {
|
|
102
|
+
if (graceTimer) { try { clearTimeout(graceTimer); } catch {} graceTimer = null; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function maybeArmGrace(reason) {
|
|
106
|
+
if (closed || graceTimer) return;
|
|
107
|
+
if (!everHadClient || clients.size > 0) return;
|
|
108
|
+
if (typeof onClientsEmpty !== 'function' || clientGraceMs <= 0) return;
|
|
109
|
+
graceTimer = setTimeout(() => {
|
|
110
|
+
graceTimer = null;
|
|
111
|
+
pruneDeadClients();
|
|
112
|
+
if (clients.size > 0) return;
|
|
113
|
+
log(`client grace elapsed (${reason}); no live clients — self-shutdown`);
|
|
114
|
+
try { onClientsEmpty(); } catch {}
|
|
115
|
+
}, clientGraceMs);
|
|
116
|
+
graceTimer.unref?.();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function startSweep() {
|
|
120
|
+
if (sweepTimer || typeof onClientsEmpty !== 'function') return;
|
|
121
|
+
sweepTimer = setInterval(() => {
|
|
122
|
+
pruneDeadClients();
|
|
123
|
+
if (everHadClient && clients.size === 0) maybeArmGrace('all clients gone (sweep)');
|
|
124
|
+
}, Math.max(1000, Math.min(sweepMs, clientGraceMs || sweepMs)));
|
|
125
|
+
sweepTimer.unref?.();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Resolve the ONE client that should receive a notify. Pointer client first
|
|
129
|
+
// (pid-verified at delivery), then most-recently-seen live client, else drop.
|
|
130
|
+
// Never broadcasts — a broadcast would inject one inbound Discord message into
|
|
131
|
+
// every attached TUI's Lead session.
|
|
132
|
+
function resolveTarget() {
|
|
133
|
+
if (pointerToken) {
|
|
134
|
+
const c = clients.get(pointerToken);
|
|
135
|
+
if (c && isPidAlive(c.leadPid)) return c;
|
|
136
|
+
if (pointerToken) pointerToken = null;
|
|
137
|
+
}
|
|
138
|
+
let best = null;
|
|
139
|
+
for (const [, c] of liveClients()) {
|
|
140
|
+
if (!best || c.lastSeen > best.lastSeen) best = c;
|
|
141
|
+
}
|
|
142
|
+
return best;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function notify(method, params) {
|
|
146
|
+
const target = resolveTarget();
|
|
147
|
+
if (!target) {
|
|
148
|
+
log(`notify dropped (no live target): ${method}`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (!target.sse) {
|
|
152
|
+
log(`notify dropped (target has no SSE stream): ${method}`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const frame = JSON.stringify({ type: 'notify', method, params });
|
|
156
|
+
try {
|
|
157
|
+
target.sse.write(`data: ${frame}\n\n`);
|
|
158
|
+
return true;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
log(`notify write failed for lead=${target.leadPid}: ${err?.message || err}`);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function registerClient({ leadPid, cwd }) {
|
|
166
|
+
const pid = parsePid(leadPid) ?? 0;
|
|
167
|
+
const token = randomUUID();
|
|
168
|
+
clients.set(token, {
|
|
169
|
+
token,
|
|
170
|
+
leadPid: pid,
|
|
171
|
+
cwd: cwd || null,
|
|
172
|
+
sse: null,
|
|
173
|
+
lastSeen: nowMs(),
|
|
174
|
+
registeredAt: nowMs(),
|
|
175
|
+
});
|
|
176
|
+
everHadClient = true;
|
|
177
|
+
cancelGrace();
|
|
178
|
+
startSweep();
|
|
179
|
+
// First client to attach becomes the routing pointer until an explicit
|
|
180
|
+
// bind call moves it. Ensures a lone TUI receives notifies immediately.
|
|
181
|
+
if (!pointerToken) pointerToken = token;
|
|
182
|
+
log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'}`);
|
|
183
|
+
return token;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function attachSse(token, res) {
|
|
187
|
+
const c = clients.get(token);
|
|
188
|
+
if (!c) return false;
|
|
189
|
+
res.writeHead(200, {
|
|
190
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
191
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
192
|
+
Connection: 'keep-alive',
|
|
193
|
+
'X-Accel-Buffering': 'no',
|
|
194
|
+
});
|
|
195
|
+
// Prelude comment flushes headers so the client's SSE reader resolves.
|
|
196
|
+
res.write(': attached\n\n');
|
|
197
|
+
c.sse = res;
|
|
198
|
+
c.lastSeen = nowMs();
|
|
199
|
+
const ka = setInterval(() => {
|
|
200
|
+
try { res.write(': ka\n\n'); } catch {}
|
|
201
|
+
}, 15_000);
|
|
202
|
+
ka.unref?.();
|
|
203
|
+
const cleanup = () => {
|
|
204
|
+
clearInterval(ka);
|
|
205
|
+
if (c.sse === res) c.sse = null;
|
|
206
|
+
// Stream loss alone does not drop the client (a TUI may reconnect); the
|
|
207
|
+
// sweep + pid check reaps genuinely dead clients.
|
|
208
|
+
maybeArmGrace('sse closed');
|
|
209
|
+
};
|
|
210
|
+
res.on('close', cleanup);
|
|
211
|
+
res.on('error', cleanup);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function handleRequest(req, res) {
|
|
216
|
+
// 127.0.0.1 bind already restricts reachability; still refuse anything
|
|
217
|
+
// without our server token except /health (liveness probe is unauthed).
|
|
218
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
219
|
+
const pathName = url.pathname;
|
|
220
|
+
try {
|
|
221
|
+
if (req.method === 'GET' && pathName === '/health') {
|
|
222
|
+
sendJson(res, { status: 'ok', pid: process.pid, clients: clients.size, ...getStatus() });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const token = req.headers['x-mixdog-daemon-token'] || url.searchParams.get('server_token');
|
|
226
|
+
if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
|
|
227
|
+
|
|
228
|
+
if (req.method === 'POST' && pathName === '/client/register') {
|
|
229
|
+
const body = await readBody(req);
|
|
230
|
+
const clientToken = registerClient({ leadPid: body.leadPid, cwd: body.cwd });
|
|
231
|
+
sendJson(res, { token: clientToken, pid: process.pid });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (req.method === 'POST' && pathName === '/client/deregister') {
|
|
235
|
+
const body = await readBody(req);
|
|
236
|
+
if (body.token) dropClient(body.token, 'deregister');
|
|
237
|
+
sendJson(res, { ok: true });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (req.method === 'GET' && pathName === '/events') {
|
|
241
|
+
const clientToken = url.searchParams.get('token');
|
|
242
|
+
if (!attachSse(clientToken, res)) { sendError(res, 'unknown client token', 404); return; }
|
|
243
|
+
return; // stream stays open
|
|
244
|
+
}
|
|
245
|
+
if (req.method === 'POST' && pathName === '/call') {
|
|
246
|
+
const body = await readBody(req);
|
|
247
|
+
const clientToken = body.token || null;
|
|
248
|
+
const c = clientToken ? clients.get(clientToken) : null;
|
|
249
|
+
if (c) c.lastSeen = nowMs();
|
|
250
|
+
const name = String(body.name || '');
|
|
251
|
+
// Bind-intent calls re-point routing at the caller BEFORE dispatch, so a
|
|
252
|
+
// notify emitted synchronously during the call already targets it.
|
|
253
|
+
if (c && POINTER_TOOLS.has(name)) {
|
|
254
|
+
pointerToken = clientToken;
|
|
255
|
+
log(`routing pointer -> token=${clientToken} lead=${c.leadPid} via ${name}`);
|
|
256
|
+
}
|
|
257
|
+
const callId = body.callId ? String(body.callId) : null;
|
|
258
|
+
let dispatch;
|
|
259
|
+
if (callId && callCache.has(callId)) {
|
|
260
|
+
// Replay of a retried call — dedup to the original run (exactly one
|
|
261
|
+
// side-effect) instead of dispatching handleCall a second time.
|
|
262
|
+
dispatch = callCache.get(callId).promise;
|
|
263
|
+
} else {
|
|
264
|
+
dispatch = Promise.resolve().then(() => handleCall(name, body.args || {}, {
|
|
265
|
+
clientToken,
|
|
266
|
+
leadPid: c?.leadPid ?? null,
|
|
267
|
+
cwd: c?.cwd ?? null,
|
|
268
|
+
}));
|
|
269
|
+
if (callId) {
|
|
270
|
+
callCache.set(callId, { promise: dispatch, at: nowMs() });
|
|
271
|
+
// Start the TTL only once the call SETTLES: an in-flight call can
|
|
272
|
+
// outlive a fixed-from-dispatch TTL (e.g. a slow reply upload past
|
|
273
|
+
// 60s), and expiring its entry mid-flight would let a transport
|
|
274
|
+
// retry replay-miss and dispatch a second real side-effect.
|
|
275
|
+
dispatch.then(() => {}, () => {}).then(() => {
|
|
276
|
+
const t = setTimeout(() => callCache.delete(callId), CALL_CACHE_TTL_MS);
|
|
277
|
+
t.unref?.();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const result = await dispatch;
|
|
283
|
+
sendJson(res, { result });
|
|
284
|
+
} catch (err) {
|
|
285
|
+
sendJson(res, { error: err?.message || String(err) }, 200);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (req.method === 'POST' && pathName === '/shutdown') {
|
|
290
|
+
sendJson(res, { ok: true });
|
|
291
|
+
if (typeof onClientsEmpty === 'function') { try { onClientsEmpty(); } catch {} }
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
sendError(res, 'not found', 404);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
try { sendError(res, err?.message || String(err), err?.statusCode || 500); } catch {}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function writeDiscovery() {
|
|
301
|
+
if (!discoveryPath) return;
|
|
302
|
+
try {
|
|
303
|
+
writeJsonAtomicSync(discoveryPath, {
|
|
304
|
+
pid: process.pid,
|
|
305
|
+
port: boundPort,
|
|
306
|
+
token: serverToken,
|
|
307
|
+
startedAt: Date.now(),
|
|
308
|
+
}, { compact: true });
|
|
309
|
+
} catch (err) {
|
|
310
|
+
log(`discovery write failed: ${err?.message || err}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function start() {
|
|
315
|
+
return new Promise((resolve, reject) => {
|
|
316
|
+
server = http.createServer(handleRequest);
|
|
317
|
+
server.on('error', reject);
|
|
318
|
+
// 127.0.0.1 ONLY — never expose the daemon off-box.
|
|
319
|
+
server.listen(0, '127.0.0.1', () => {
|
|
320
|
+
server.removeListener('error', reject);
|
|
321
|
+
boundPort = server.address().port;
|
|
322
|
+
server.on('error', (err) => log(`server error: ${err?.message || err}`));
|
|
323
|
+
writeDiscovery();
|
|
324
|
+
log(`daemon transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
|
|
325
|
+
resolve({ port: boundPort, token: serverToken });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function stop() {
|
|
331
|
+
closed = true;
|
|
332
|
+
cancelGrace();
|
|
333
|
+
if (sweepTimer) { try { clearInterval(sweepTimer); } catch {} sweepTimer = null; }
|
|
334
|
+
for (const [token] of clients) dropClient(token, 'transport stop');
|
|
335
|
+
if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
|
|
336
|
+
if (server) {
|
|
337
|
+
await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
|
|
338
|
+
server = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
start,
|
|
344
|
+
stop,
|
|
345
|
+
notify,
|
|
346
|
+
get port() { return boundPort; },
|
|
347
|
+
get token() { return serverToken; },
|
|
348
|
+
_clientsForTest: clients,
|
|
349
|
+
_resolveTargetForTest: resolveTarget,
|
|
350
|
+
};
|
|
351
|
+
}
|