@pasko70/pibo 2.5.2 → 3.0.0
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/context/pibo-native-tooling.md +1 -0
- package/dist/agent-runtime/contract.js +2 -1
- package/dist/agent-runtime/history.js +53 -1
- package/dist/agent-runtime/routed-session.js +100 -26
- package/dist/agent-runtimes/codex-native/adapter.js +304 -30
- package/dist/agent-runtimes/codex-native/client.js +22 -3
- package/dist/agent-runtimes/codex-native/first-use.js +294 -0
- package/dist/agent-runtimes/codex-native/history.js +26 -7
- package/dist/agent-runtimes/codex-native/process.js +14 -0
- package/dist/agent-runtimes/codex-native/thread.js +59 -25
- package/dist/agent-runtimes/codex-native/turn.js +27 -0
- package/dist/agent-runtimes/history-proof.js +8 -0
- package/dist/agent-runtimes/omp/adapter.js +61 -14
- package/dist/agent-runtimes/omp/history.js +189 -34
- package/dist/agent-runtimes/omp/thread.js +82 -36
- package/dist/agent-runtimes/pi/adapter.js +30 -3
- package/dist/agent-runtimes/pi/history.js +66 -5
- package/dist/agent-runtimes/pi/openai-response-observer.js +102 -0
- package/dist/agent-runtimes/pi/routed-session.js +344 -37
- package/dist/apps/chat/chat-api-routes.js +1 -1
- package/dist/apps/chat/chat-files.js +130 -9
- package/dist/apps/chat/chat-settings-routes.js +27 -2
- package/dist/apps/chat/chat-speech.js +100 -0
- package/dist/apps/chat/data/chat-data-mappers.js +25 -5
- package/dist/apps/chat/data/timeline-query-service.js +89 -1
- package/dist/apps/chat/trace-v2.js +210 -8
- package/dist/apps/chat/trace.js +3 -0
- package/dist/apps/chat/web-app.js +108 -3
- package/dist/apps/chat-ui/assets/{dist-DTWJf37A.js → dist-BBDMeU5-.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CkYWKnvy.js → dist-BDIATCQt.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DZ8Wpj-H.js → dist-JnW4v8UE.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-Dmblz15N.js → dist-lRiLP9rr.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DjcwoyNY.js → dist-mqCviI-c.js} +1 -1
- package/dist/apps/chat-ui/assets/index-0XOOVxLP.js +228 -0
- package/dist/apps/chat-ui/assets/index-DV7_CgsC.css +1 -0
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/index-Dtw2Z7jz.js +43 -0
- package/dist/apps/chat-vscode-web/assets/{index-CF1-9PKU.css → index-SluZr_-r.css} +1 -1
- package/dist/apps/chat-vscode-web/index.html +2 -2
- package/dist/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-3.0.0.vsix +0 -0
- package/dist/cli.js +49 -5
- package/dist/config/config.js +59 -8
- package/dist/core/preview-server-settings.js +20 -0
- package/dist/core/session-router.js +457 -37
- package/dist/core/user-settings.js +10 -0
- package/dist/data/payload-store.js +57 -1
- package/dist/data/schema.js +18 -1
- package/dist/debug/trace.js +5 -0
- package/dist/gateway/client.js +368 -65
- package/dist/gateway/protocol.js +2 -0
- package/dist/gateway/server.js +8 -0
- package/dist/gateway/web.js +2 -0
- package/dist/index.js +1 -1
- package/dist/loops/store.js +6 -4
- package/dist/mcp/config.js +89 -17
- package/dist/mcp/daemon-client.js +473 -153
- package/dist/mcp/daemon.js +401 -37
- package/dist/plugins/builtin.js +2 -2
- package/dist/plugins/codex-native.js +2 -0
- package/dist/plugins/registry.js +259 -1
- package/dist/previews/cli.js +345 -0
- package/dist/previews/config.js +61 -0
- package/dist/previews/manager.js +644 -0
- package/dist/previews/network.js +198 -0
- package/dist/previews/plugin.js +11 -0
- package/dist/previews/process-supervisor.js +38 -0
- package/dist/previews/proxy.js +387 -0
- package/dist/previews/store.js +750 -0
- package/dist/previews/types.js +1 -0
- package/dist/previews/web-app.js +446 -0
- package/dist/runs/registry.js +22 -0
- package/dist/runs/tools.js +46 -7
- package/dist/session-ui/terminalRows.js +187 -13
- package/dist/sessions/pibo-data-store.js +37 -0
- package/dist/sessions/runtime-binding-persistence.js +34 -0
- package/dist/sessions/runtime-binding.js +6 -0
- package/dist/sessions/sqlite-store.js +37 -0
- package/dist/shared/trace-engine.js +12 -64
- package/dist/shared/trace-event-projection.js +16 -0
- package/dist/shared/trace-history.js +684 -184
- package/dist/shared/trace-limits.js +7 -0
- package/dist/shared/trace-live-patch.js +61 -0
- package/dist/shared/trace-tool-identity.js +43 -0
- package/dist/speech/openai-codex-realtime-call-proxy.js +248 -0
- package/dist/speech/openai-codex.js +407 -0
- package/dist/speech/types.js +8 -0
- package/dist/subagents/context.js +5 -3
- package/dist/subagents/observations.js +94 -6
- package/dist/subagents/tool.js +103 -20
- package/dist/tools/codex-image-artifacts.js +24 -0
- package/dist/tools/codex-image-generation.js +4 -22
- package/dist/web/channel.js +88 -4
- package/dist/web/http.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-0WZI2phJ.css +0 -1
- package/dist/apps/chat-ui/assets/index-DsgTm1Aw.js +0 -228
- package/dist/apps/chat-vscode-web/assets/index-DvTSSvzN.js +0 -43
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-2.5.2.vsix +0 -0
|
@@ -1,26 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP
|
|
3
|
-
*
|
|
4
|
-
* Handles spawning daemons, detecting stale connections, and forwarding requests.
|
|
2
|
+
* MCP daemon client and cross-process lifecycle coordination.
|
|
5
3
|
*/
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, } from 'node:child_process';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
7
6
|
import { existsSync } from 'node:fs';
|
|
8
7
|
import { readdir } from 'node:fs/promises';
|
|
9
8
|
import { createConnection } from 'node:net';
|
|
10
|
-
import { dirname, extname, join } from 'node:path';
|
|
9
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
11
10
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { debug, getConfigHash, getDaemonRequestTimeoutMs, getSocketDir, getSocketPath, } from './config.js';
|
|
13
|
-
import { isProcessRunning,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
import { debug, getConfigHash, getDaemonClaimPath, getDaemonLeasePrefix, getDaemonRequestTimeoutMs, getPidPath, getSocketDir, getSocketPath, usesFilesystemSocket, } from './config.js';
|
|
12
|
+
import { daemonProcessIdentityMatches, daemonIdentityMatches, getOwnershipFileAgeMs, isProcessRunning, pidFileIdentityMatches, processIdentityMatches, readOwnershipFilePath, readPidFile, readPidFilePath, readProcessIdentity, refreshOwnershipFile, removeDaemonState, removeMalformedOwnershipFile, removeOwnershipFile, removePidFilePath, removeUntrackedEndpoint, terminateDaemonProcess, writeOwnershipFileExclusive, } from './daemon.js';
|
|
13
|
+
const OWNERSHIP_POLL_MS = 25;
|
|
14
|
+
const DEAD_OWNER_GRACE_MS = 100;
|
|
15
|
+
const OWNERSHIP_HEARTBEAT_MS = 25;
|
|
16
|
+
const MAX_DAEMON_MESSAGE_BYTES = 1024 * 1024;
|
|
17
17
|
function generateRequestId() {
|
|
18
|
-
return `${Date.now()}-${
|
|
18
|
+
return `${Date.now()}-${randomUUID()}`;
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
function isDaemonIdentity(value) {
|
|
24
|
+
if (!value || typeof value !== 'object')
|
|
25
|
+
return false;
|
|
26
|
+
const candidate = value;
|
|
27
|
+
return (Number.isInteger(candidate.pid) &&
|
|
28
|
+
(candidate.pid ?? 0) > 0 &&
|
|
29
|
+
typeof candidate.configHash === 'string' &&
|
|
30
|
+
typeof candidate.generation === 'string' &&
|
|
31
|
+
candidate.generation.length > 0 &&
|
|
32
|
+
typeof candidate.startedAt === 'string');
|
|
33
|
+
}
|
|
34
|
+
function isCompletePidInfo(value) {
|
|
35
|
+
return isDaemonIdentity(value);
|
|
36
|
+
}
|
|
37
|
+
async function sendRequest(socketPath, request, timeoutMs = getDaemonRequestTimeoutMs()) {
|
|
24
38
|
return new Promise((resolve, reject) => {
|
|
25
39
|
let settled = false;
|
|
26
40
|
let responseText = '';
|
|
@@ -32,23 +46,30 @@ async function sendRequest(socketPath, request) {
|
|
|
32
46
|
fn();
|
|
33
47
|
};
|
|
34
48
|
const socket = createConnection(socketPath, () => {
|
|
35
|
-
socket.write(JSON.stringify(request));
|
|
49
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
36
50
|
});
|
|
37
51
|
socket.on('data', (data) => {
|
|
38
52
|
responseText += data.toString();
|
|
39
|
-
if (responseText.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
reject(new Error('Invalid response from daemon'));
|
|
46
|
-
}
|
|
47
|
-
finally {
|
|
48
|
-
socket.end();
|
|
49
|
-
}
|
|
50
|
-
});
|
|
53
|
+
if (responseText.length > MAX_DAEMON_MESSAGE_BYTES) {
|
|
54
|
+
socket.destroy();
|
|
55
|
+
settle(() => reject(new Error('Daemon response exceeds 1 MiB')));
|
|
56
|
+
return;
|
|
51
57
|
}
|
|
58
|
+
const newlineIndex = responseText.indexOf('\n');
|
|
59
|
+
if (newlineIndex === -1)
|
|
60
|
+
return;
|
|
61
|
+
const responseLine = responseText.slice(0, newlineIndex);
|
|
62
|
+
settle(() => {
|
|
63
|
+
try {
|
|
64
|
+
resolve(JSON.parse(responseLine));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
reject(new Error('Invalid response from daemon'));
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
socket.end();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
52
73
|
});
|
|
53
74
|
socket.on('end', () => {
|
|
54
75
|
if (settled)
|
|
@@ -68,141 +89,367 @@ async function sendRequest(socketPath, request) {
|
|
|
68
89
|
const timeoutId = setTimeout(() => {
|
|
69
90
|
socket.destroy();
|
|
70
91
|
settle(() => reject(new Error('Daemon request timeout')));
|
|
71
|
-
},
|
|
92
|
+
}, timeoutMs);
|
|
72
93
|
});
|
|
73
94
|
}
|
|
74
|
-
|
|
75
|
-
* Check if daemon is running and has matching config
|
|
76
|
-
*/
|
|
77
|
-
function isDaemonValid(serverName, config) {
|
|
95
|
+
async function pingDaemon(serverName, timeoutMs) {
|
|
78
96
|
const socketPath = getSocketPath(serverName);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return
|
|
97
|
+
if (usesFilesystemSocket() && !existsSync(socketPath))
|
|
98
|
+
return null;
|
|
99
|
+
try {
|
|
100
|
+
const response = await sendRequest(socketPath, { id: generateRequestId(), type: 'ping' }, timeoutMs);
|
|
101
|
+
return response.success && isDaemonIdentity(response.data)
|
|
102
|
+
? response.data
|
|
103
|
+
: null;
|
|
84
104
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
debug(`[daemon-client] Process ${pidInfo.pid} not running, cleaning up`);
|
|
88
|
-
removePidFile(serverName);
|
|
89
|
-
removeSocketFile(serverName);
|
|
90
|
-
return false;
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
91
107
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
}
|
|
109
|
+
async function waitForDaemonReady(serverName, expected, timeoutMs, isAlive) {
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
while (Date.now() < deadline && isAlive()) {
|
|
112
|
+
const remainingMs = deadline - Date.now();
|
|
113
|
+
const identity = await pingDaemon(serverName, Math.max(1, Math.min(500, remainingMs)));
|
|
114
|
+
if (daemonIdentityMatches(identity, expected))
|
|
115
|
+
return identity;
|
|
116
|
+
await sleep(OWNERSHIP_POLL_MS);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
121
|
+
const deadline = Date.now() + timeoutMs;
|
|
122
|
+
while (Date.now() < deadline && isProcessRunning(pid)) {
|
|
123
|
+
await sleep(50);
|
|
124
|
+
}
|
|
125
|
+
return !isProcessRunning(pid);
|
|
126
|
+
}
|
|
127
|
+
async function waitForDaemonExit(expected, timeoutMs) {
|
|
128
|
+
const deadline = Date.now() + timeoutMs;
|
|
129
|
+
const isExpectedProcessRunning = () => expected.processIdentity
|
|
130
|
+
? daemonProcessIdentityMatches(expected)
|
|
131
|
+
: isProcessRunning(expected.pid);
|
|
132
|
+
while (Date.now() < deadline && isExpectedProcessRunning()) {
|
|
133
|
+
await sleep(50);
|
|
100
134
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
135
|
+
return !isExpectedProcessRunning();
|
|
136
|
+
}
|
|
137
|
+
async function stopDaemon(serverName, expected, ownership) {
|
|
138
|
+
try {
|
|
139
|
+
await sendRequest(getSocketPath(serverName), {
|
|
140
|
+
id: generateRequestId(),
|
|
141
|
+
type: 'close',
|
|
142
|
+
generation: expected.generation,
|
|
143
|
+
}, 1000);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// The daemon may still be starting or may already have crashed.
|
|
147
|
+
}
|
|
148
|
+
let exited = await waitForDaemonExit(expected, 2000);
|
|
149
|
+
if (!exited) {
|
|
150
|
+
const current = readPidFile(serverName);
|
|
151
|
+
if (!pidFileIdentityMatches(current, expected))
|
|
152
|
+
return false;
|
|
153
|
+
if (!terminateDaemonProcess(expected))
|
|
154
|
+
return false;
|
|
155
|
+
exited = await waitForDaemonExit(expected, 1000);
|
|
156
|
+
}
|
|
157
|
+
return exited && removeDaemonState(serverName, expected, ownership);
|
|
158
|
+
}
|
|
159
|
+
async function stopLegacyDaemon(serverName, pidInfo, ownership) {
|
|
160
|
+
try {
|
|
161
|
+
await sendRequest(getSocketPath(serverName), { id: generateRequestId(), type: 'close' }, 1000);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Continue to the bounded process check.
|
|
165
|
+
}
|
|
166
|
+
const exited = await waitForProcessExit(pidInfo.pid, 2000);
|
|
167
|
+
return exited && removeDaemonState(serverName, pidInfo, ownership);
|
|
168
|
+
}
|
|
169
|
+
async function stopUntrackedEndpoint(serverName, ownership) {
|
|
170
|
+
const identity = await pingDaemon(serverName, 500);
|
|
171
|
+
if (!identity) {
|
|
172
|
+
return removeUntrackedEndpoint(serverName, ownership);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await sendRequest(getSocketPath(serverName), {
|
|
176
|
+
id: generateRequestId(),
|
|
177
|
+
type: 'close',
|
|
178
|
+
generation: identity.generation,
|
|
179
|
+
}, 1000);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
106
182
|
return false;
|
|
107
183
|
}
|
|
108
|
-
|
|
184
|
+
const exited = await waitForDaemonExit(identity, 2000);
|
|
185
|
+
return exited && removeUntrackedEndpoint(serverName, ownership);
|
|
109
186
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
...process.execArgv,
|
|
187
|
+
export function getDaemonSpawnOptions() {
|
|
188
|
+
return {
|
|
189
|
+
detached: true,
|
|
190
|
+
stdio: 'ignore',
|
|
191
|
+
windowsHide: true,
|
|
192
|
+
env: { ...process.env },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function getDaemonSpawnArguments(daemonScript, serverName, config, generation, execArgv = process.execArgv) {
|
|
196
|
+
return [
|
|
197
|
+
...filterDaemonExecArgv(execArgv),
|
|
122
198
|
daemonScript,
|
|
123
199
|
'--daemon',
|
|
124
200
|
serverName,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
-
env: { ...process.env },
|
|
130
|
-
});
|
|
131
|
-
// Wait for daemon to signal readiness or fail
|
|
132
|
-
return new Promise((resolve) => {
|
|
133
|
-
let resolved = false;
|
|
134
|
-
proc.stdout?.on('data', (data) => {
|
|
135
|
-
const text = data.toString();
|
|
136
|
-
if (text.includes('DAEMON_READY') && !resolved) {
|
|
137
|
-
resolved = true;
|
|
138
|
-
// Don't await the process, let it run in the background.
|
|
139
|
-
proc.stdout?.unref?.();
|
|
140
|
-
proc.stderr?.unref?.();
|
|
141
|
-
proc.unref();
|
|
142
|
-
resolve(true);
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
// Timeout after 5 seconds (fast fallback to direct connection)
|
|
146
|
-
setTimeout(() => {
|
|
147
|
-
if (!resolved) {
|
|
148
|
-
resolved = true;
|
|
149
|
-
debug(`[daemon-client] Daemon spawn timeout for ${serverName}`);
|
|
150
|
-
resolve(false);
|
|
151
|
-
}
|
|
152
|
-
}, 5000);
|
|
153
|
-
// Check for early exit
|
|
154
|
-
proc.on('exit', (code) => {
|
|
155
|
-
if (!resolved && code !== 0) {
|
|
156
|
-
resolved = true;
|
|
157
|
-
debug(`[daemon-client] Daemon exited with code ${code}`);
|
|
158
|
-
resolve(false);
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
});
|
|
201
|
+
JSON.stringify(config),
|
|
202
|
+
generation,
|
|
203
|
+
];
|
|
162
204
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
205
|
+
const EXECUTION_FLAGS_WITH_VALUE = new Set([
|
|
206
|
+
'-e',
|
|
207
|
+
'--eval',
|
|
208
|
+
'-p',
|
|
209
|
+
'--print',
|
|
210
|
+
'--run',
|
|
211
|
+
'--input-type',
|
|
212
|
+
'--entry-url',
|
|
213
|
+
'--test-name-pattern',
|
|
214
|
+
'--test-skip-pattern',
|
|
215
|
+
'--test-reporter',
|
|
216
|
+
'--test-reporter-destination',
|
|
217
|
+
'--test-shard',
|
|
218
|
+
'--watch-path',
|
|
219
|
+
]);
|
|
220
|
+
const EXECUTION_FLAGS = new Set([
|
|
221
|
+
'-c',
|
|
222
|
+
'--check',
|
|
223
|
+
'-i',
|
|
224
|
+
'--interactive',
|
|
225
|
+
'--test',
|
|
226
|
+
'--watch',
|
|
227
|
+
]);
|
|
228
|
+
/** Preserve runtime flags while removing selectors that execute something
|
|
229
|
+
* other than the daemon script or turn the detached child into a test/watch
|
|
230
|
+
* runner. */
|
|
231
|
+
export function filterDaemonExecArgv(execArgv) {
|
|
232
|
+
const filtered = [];
|
|
233
|
+
for (let index = 0; index < execArgv.length; index += 1) {
|
|
234
|
+
const argument = execArgv[index];
|
|
235
|
+
if (EXECUTION_FLAGS_WITH_VALUE.has(argument)) {
|
|
236
|
+
index += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (EXECUTION_FLAGS.has(argument) ||
|
|
240
|
+
argument.startsWith('--eval=') ||
|
|
241
|
+
argument.startsWith('--print=') ||
|
|
242
|
+
argument.startsWith('--run=') ||
|
|
243
|
+
argument.startsWith('--input-type=') ||
|
|
244
|
+
argument.startsWith('--entry-url=') ||
|
|
245
|
+
argument.startsWith('--test-') ||
|
|
246
|
+
argument.startsWith('--watch-') ||
|
|
247
|
+
/^-[ep](?:=|.)/.test(argument)) {
|
|
248
|
+
continue;
|
|
176
249
|
}
|
|
177
|
-
|
|
178
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
250
|
+
filtered.push(argument);
|
|
179
251
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
252
|
+
return filtered;
|
|
253
|
+
}
|
|
254
|
+
async function spawnDaemon(serverName, config, configHash, generation, timeoutMs, ownership) {
|
|
255
|
+
debug(`[daemon-client] Spawning daemon for ${serverName}`);
|
|
256
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
257
|
+
const daemonScript = join(dirname(modulePath), `daemon${extname(modulePath)}`);
|
|
258
|
+
const proc = spawn(process.execPath, getDaemonSpawnArguments(daemonScript, serverName, config, generation), getDaemonSpawnOptions());
|
|
259
|
+
let spawnError;
|
|
260
|
+
let exited = false;
|
|
261
|
+
proc.once('error', (error) => {
|
|
262
|
+
spawnError = error;
|
|
263
|
+
});
|
|
264
|
+
proc.once('exit', () => {
|
|
265
|
+
exited = true;
|
|
266
|
+
});
|
|
267
|
+
proc.unref();
|
|
268
|
+
if (proc.pid === undefined)
|
|
183
269
|
return null;
|
|
270
|
+
const expected = {
|
|
271
|
+
pid: proc.pid,
|
|
272
|
+
configHash,
|
|
273
|
+
generation,
|
|
274
|
+
startedAt: '',
|
|
275
|
+
processIdentity: readProcessIdentity(proc.pid) ?? undefined,
|
|
276
|
+
};
|
|
277
|
+
const ready = await waitForDaemonReady(serverName, expected, timeoutMs, () => !spawnError && !exited);
|
|
278
|
+
if (ready)
|
|
279
|
+
return ready;
|
|
280
|
+
if (spawnError) {
|
|
281
|
+
debug(`[daemon-client] Spawn failed for ${serverName}: ${spawnError.message}`);
|
|
282
|
+
}
|
|
283
|
+
else if (exited) {
|
|
284
|
+
debug(`[daemon-client] Daemon exited before readiness for ${serverName}`);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
debug(`[daemon-client] Daemon startup timed out for ${serverName}`);
|
|
288
|
+
}
|
|
289
|
+
const pidInfo = readPidFile(serverName);
|
|
290
|
+
if (pidInfo && daemonIdentityMatches(pidInfo, expected)) {
|
|
291
|
+
await stopDaemon(serverName, pidInfo, ownership);
|
|
184
292
|
}
|
|
185
|
-
|
|
293
|
+
else if (!exited && isProcessRunning(proc.pid)) {
|
|
294
|
+
terminateDaemonProcess(expected);
|
|
295
|
+
await waitForDaemonExit(expected, 1000);
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
function acquireClaim(serverName, configHash) {
|
|
300
|
+
const claim = {
|
|
301
|
+
ownerPid: process.pid,
|
|
302
|
+
ownerProcessIdentity: readProcessIdentity(process.pid) ?? undefined,
|
|
303
|
+
ownerNonce: randomUUID(),
|
|
304
|
+
generation: randomUUID(),
|
|
305
|
+
configHash,
|
|
306
|
+
startedAt: new Date().toISOString(),
|
|
307
|
+
serverName,
|
|
308
|
+
};
|
|
309
|
+
return writeOwnershipFileExclusive(getDaemonClaimPath(serverName), claim)
|
|
310
|
+
? claim
|
|
311
|
+
: null;
|
|
312
|
+
}
|
|
313
|
+
function ownershipIsLive(path, ownership) {
|
|
314
|
+
if (processIdentityMatches(readProcessIdentity(ownership.ownerPid), ownership.ownerProcessIdentity)) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
const ageMs = getOwnershipFileAgeMs(path);
|
|
318
|
+
return Boolean(ownership.ownerNonce &&
|
|
319
|
+
ageMs !== null &&
|
|
320
|
+
ageMs < DEAD_OWNER_GRACE_MS);
|
|
321
|
+
}
|
|
322
|
+
function startOwnershipHeartbeat(path, ownership) {
|
|
323
|
+
if (ownership.ownerProcessIdentity)
|
|
324
|
+
return () => { };
|
|
325
|
+
const timer = setInterval(() => {
|
|
326
|
+
refreshOwnershipFile(path, ownership.generation, ownership.ownerNonce);
|
|
327
|
+
}, OWNERSHIP_HEARTBEAT_MS);
|
|
328
|
+
timer.unref();
|
|
329
|
+
return () => clearInterval(timer);
|
|
330
|
+
}
|
|
331
|
+
function removeStaleOwnershipFile(path) {
|
|
332
|
+
const ownership = readOwnershipFilePath(path);
|
|
333
|
+
const ageMs = getOwnershipFileAgeMs(path);
|
|
334
|
+
if (ageMs === null)
|
|
335
|
+
return false;
|
|
336
|
+
if (!ownership) {
|
|
337
|
+
return removeMalformedOwnershipFile(path, DEAD_OWNER_GRACE_MS);
|
|
338
|
+
}
|
|
339
|
+
if (ownershipIsLive(path, ownership) || ageMs < DEAD_OWNER_GRACE_MS) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
return removeOwnershipFile(path, ownership.generation);
|
|
343
|
+
}
|
|
344
|
+
function claimIsOwned(path, claim) {
|
|
345
|
+
return readOwnershipFilePath(path)?.generation === claim.generation;
|
|
346
|
+
}
|
|
347
|
+
async function listActiveLeases(serverName) {
|
|
348
|
+
const prefix = getDaemonLeasePrefix(serverName);
|
|
349
|
+
const directory = dirname(prefix);
|
|
350
|
+
const filePrefix = basename(prefix);
|
|
351
|
+
let files;
|
|
186
352
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
353
|
+
files = (await readdir(directory)).filter((file) => file.startsWith(filePrefix));
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
const active = [];
|
|
359
|
+
for (const file of files) {
|
|
360
|
+
const path = join(directory, file);
|
|
361
|
+
const ownership = readOwnershipFilePath(path);
|
|
362
|
+
if (!ownership || !('daemonGeneration' in ownership)) {
|
|
363
|
+
removeStaleOwnershipFile(path);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (!ownershipIsLive(path, ownership)) {
|
|
367
|
+
const removed = removeStaleOwnershipFile(path);
|
|
368
|
+
if (!removed && existsSync(path))
|
|
369
|
+
active.push(ownership);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
active.push(ownership);
|
|
373
|
+
}
|
|
374
|
+
return active;
|
|
375
|
+
}
|
|
376
|
+
function createLease(serverName, identity) {
|
|
377
|
+
for (;;) {
|
|
378
|
+
const lease = {
|
|
379
|
+
ownerPid: process.pid,
|
|
380
|
+
ownerProcessIdentity: readProcessIdentity(process.pid) ?? undefined,
|
|
381
|
+
ownerNonce: randomUUID(),
|
|
382
|
+
generation: randomUUID(),
|
|
383
|
+
daemonGeneration: identity.generation,
|
|
384
|
+
configHash: identity.configHash,
|
|
385
|
+
startedAt: new Date().toISOString(),
|
|
386
|
+
serverName,
|
|
387
|
+
};
|
|
388
|
+
const path = `${getDaemonLeasePrefix(serverName)}${lease.generation}`;
|
|
389
|
+
if (writeOwnershipFileExclusive(path, lease))
|
|
390
|
+
return { path, lease };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function ensureDaemonAsOwner(serverName, config, configHash, claim, deadline) {
|
|
394
|
+
const remaining = () => Math.max(0, deadline - Date.now());
|
|
395
|
+
const claimPath = getDaemonClaimPath(serverName);
|
|
396
|
+
const ownership = {
|
|
397
|
+
path: claimPath,
|
|
398
|
+
generation: claim.generation,
|
|
399
|
+
};
|
|
400
|
+
if (!claimIsOwned(claimPath, claim))
|
|
401
|
+
return null;
|
|
402
|
+
let pidInfo = readPidFile(serverName);
|
|
403
|
+
if (pidInfo &&
|
|
404
|
+
(!isProcessRunning(pidInfo.pid) ||
|
|
405
|
+
(pidInfo.processIdentity && !daemonProcessIdentityMatches(pidInfo)))) {
|
|
406
|
+
const removed = removeDaemonState(serverName, pidInfo, ownership);
|
|
407
|
+
if (!removed)
|
|
193
408
|
return null;
|
|
409
|
+
pidInfo = null;
|
|
410
|
+
}
|
|
411
|
+
if (pidInfo && isProcessRunning(pidInfo.pid)) {
|
|
412
|
+
if (isCompletePidInfo(pidInfo) && pidInfo.configHash === configHash) {
|
|
413
|
+
const ready = await waitForDaemonReady(serverName, pidInfo, remaining(), () => isProcessRunning(pidInfo?.pid ?? 0));
|
|
414
|
+
if (ready)
|
|
415
|
+
return ready;
|
|
416
|
+
}
|
|
417
|
+
while ((await listActiveLeases(serverName)).length > 0) {
|
|
418
|
+
if (remaining() <= 0 || !claimIsOwned(claimPath, claim))
|
|
419
|
+
return null;
|
|
420
|
+
await sleep(OWNERSHIP_POLL_MS);
|
|
194
421
|
}
|
|
422
|
+
if (!claimIsOwned(claimPath, claim))
|
|
423
|
+
return null;
|
|
424
|
+
const stopped = isCompletePidInfo(pidInfo)
|
|
425
|
+
? await stopDaemon(serverName, pidInfo, ownership)
|
|
426
|
+
: await stopLegacyDaemon(serverName, pidInfo, ownership);
|
|
427
|
+
if (!stopped)
|
|
428
|
+
return null;
|
|
195
429
|
}
|
|
196
|
-
|
|
197
|
-
debug(`[daemon-client] Connection test failed for ${serverName}: ${error.message}`);
|
|
430
|
+
else if (!(await stopUntrackedEndpoint(serverName, ownership))) {
|
|
198
431
|
return null;
|
|
199
432
|
}
|
|
200
|
-
|
|
201
|
-
|
|
433
|
+
if (remaining() <= 0 || !claimIsOwned(claimPath, claim))
|
|
434
|
+
return null;
|
|
435
|
+
const generation = randomUUID();
|
|
436
|
+
return spawnDaemon(serverName, config, configHash, generation, remaining(), ownership);
|
|
437
|
+
}
|
|
438
|
+
function createDaemonConnection(serverName, identity, leasePath, lease, stopLeaseHeartbeat) {
|
|
439
|
+
const socketPath = getSocketPath(serverName);
|
|
440
|
+
let closed = false;
|
|
441
|
+
const request = async (requestValue) => {
|
|
442
|
+
if (closed)
|
|
443
|
+
throw new Error('Daemon connection is closed');
|
|
444
|
+
return sendRequest(socketPath, {
|
|
445
|
+
...requestValue,
|
|
446
|
+
generation: identity.generation,
|
|
447
|
+
});
|
|
448
|
+
};
|
|
202
449
|
return {
|
|
203
450
|
serverName,
|
|
204
451
|
async listTools() {
|
|
205
|
-
const response = await
|
|
452
|
+
const response = await request({
|
|
206
453
|
id: generateRequestId(),
|
|
207
454
|
type: 'listTools',
|
|
208
455
|
});
|
|
@@ -212,7 +459,7 @@ export async function getDaemonConnection(serverName, config) {
|
|
|
212
459
|
return response.data;
|
|
213
460
|
},
|
|
214
461
|
async callTool(toolName, args) {
|
|
215
|
-
const response = await
|
|
462
|
+
const response = await request({
|
|
216
463
|
id: generateRequestId(),
|
|
217
464
|
type: 'callTool',
|
|
218
465
|
toolName,
|
|
@@ -224,7 +471,7 @@ export async function getDaemonConnection(serverName, config) {
|
|
|
224
471
|
return response.data;
|
|
225
472
|
},
|
|
226
473
|
async getInstructions() {
|
|
227
|
-
const response = await
|
|
474
|
+
const response = await request({
|
|
228
475
|
id: generateRequestId(),
|
|
229
476
|
type: 'getInstructions',
|
|
230
477
|
});
|
|
@@ -234,33 +481,106 @@ export async function getDaemonConnection(serverName, config) {
|
|
|
234
481
|
return response.data;
|
|
235
482
|
},
|
|
236
483
|
async close() {
|
|
237
|
-
|
|
238
|
-
|
|
484
|
+
if (closed)
|
|
485
|
+
return;
|
|
486
|
+
closed = true;
|
|
487
|
+
stopLeaseHeartbeat();
|
|
488
|
+
removeOwnershipFile(leasePath, lease.generation);
|
|
489
|
+
debug(`[daemon-client] Released ${serverName} daemon lease`);
|
|
239
490
|
},
|
|
240
491
|
};
|
|
241
492
|
}
|
|
242
493
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
494
|
+
* Converge independent callers on one matching daemon, or return null so the
|
|
495
|
+
* caller can use its existing direct-connection fallback.
|
|
245
496
|
*/
|
|
497
|
+
export async function getDaemonConnection(serverName, config) {
|
|
498
|
+
const configHash = getConfigHash(config);
|
|
499
|
+
const deadline = Date.now() + getDaemonRequestTimeoutMs();
|
|
500
|
+
const claimPath = getDaemonClaimPath(serverName);
|
|
501
|
+
while (Date.now() < deadline) {
|
|
502
|
+
const claim = acquireClaim(serverName, configHash);
|
|
503
|
+
if (!claim) {
|
|
504
|
+
removeStaleOwnershipFile(claimPath);
|
|
505
|
+
await sleep(OWNERSHIP_POLL_MS);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const stopClaimHeartbeat = startOwnershipHeartbeat(claimPath, claim);
|
|
509
|
+
try {
|
|
510
|
+
const identity = await ensureDaemonAsOwner(serverName, config, configHash, claim, deadline);
|
|
511
|
+
if (!identity) {
|
|
512
|
+
if (Date.now() < deadline)
|
|
513
|
+
continue;
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
if (!claimIsOwned(claimPath, claim))
|
|
517
|
+
continue;
|
|
518
|
+
const { path, lease } = createLease(serverName, identity);
|
|
519
|
+
const stopLeaseHeartbeat = startOwnershipHeartbeat(path, lease);
|
|
520
|
+
debug(`[daemon-client] Connected to ${serverName} generation ${identity.generation}`);
|
|
521
|
+
return createDaemonConnection(serverName, identity, path, lease, stopLeaseHeartbeat);
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
stopClaimHeartbeat();
|
|
525
|
+
removeOwnershipFile(claimPath, claim.generation);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
debug(`[daemon-client] Timed out waiting for daemon ownership: ${serverName}`);
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
/** Clean dead daemon metadata and abandoned ownership files on CLI startup. */
|
|
246
532
|
export async function cleanupOrphanedDaemons() {
|
|
247
533
|
const socketDir = getSocketDir();
|
|
248
|
-
if (!existsSync(socketDir))
|
|
534
|
+
if (!existsSync(socketDir))
|
|
249
535
|
return;
|
|
250
|
-
}
|
|
251
536
|
try {
|
|
252
|
-
const files =
|
|
537
|
+
const files = await readdir(socketDir);
|
|
253
538
|
for (const file of files) {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
539
|
+
const path = join(socketDir, file);
|
|
540
|
+
if (file.endsWith('.lock') ||
|
|
541
|
+
file.includes('.lease-') ||
|
|
542
|
+
file.includes('.delete-') ||
|
|
543
|
+
file.includes('.publish-')) {
|
|
544
|
+
removeStaleOwnershipFile(path);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (!file.endsWith('.pid'))
|
|
548
|
+
continue;
|
|
549
|
+
const pidInfo = readPidFilePath(path);
|
|
550
|
+
if (!pidInfo ||
|
|
551
|
+
(isProcessRunning(pidInfo.pid) &&
|
|
552
|
+
(!pidInfo.processIdentity || daemonProcessIdentityMatches(pidInfo)))) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const serverName = pidInfo.serverName;
|
|
556
|
+
if (serverName) {
|
|
557
|
+
const claim = acquireClaim(serverName, pidInfo.configHash);
|
|
558
|
+
if (!claim)
|
|
559
|
+
continue;
|
|
560
|
+
const ownership = {
|
|
561
|
+
path: getDaemonClaimPath(serverName),
|
|
562
|
+
generation: claim.generation,
|
|
563
|
+
};
|
|
564
|
+
try {
|
|
565
|
+
const current = readPidFilePath(path);
|
|
566
|
+
if (!pidFileIdentityMatches(current, pidInfo))
|
|
567
|
+
continue;
|
|
568
|
+
if (path === getPidPath(serverName)) {
|
|
569
|
+
removeDaemonState(serverName, pidInfo, ownership);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
// Legacy endpoint names cannot be bound to a modern generation.
|
|
573
|
+
// Remove only their atomically acquired dead PID metadata.
|
|
574
|
+
removePidFilePath(path, pidInfo);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
removeOwnershipFile(ownership.path, ownership.generation);
|
|
579
|
+
}
|
|
260
580
|
}
|
|
261
581
|
}
|
|
262
582
|
}
|
|
263
583
|
catch {
|
|
264
|
-
//
|
|
584
|
+
// Cleanup is opportunistic; connection setup performs exact recovery.
|
|
265
585
|
}
|
|
266
586
|
}
|