@pasko70/pibo 2.5.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/context/pibo-native-tooling.md +1 -0
  2. package/dist/agent-runtime/contract.js +2 -1
  3. package/dist/agent-runtime/history.js +53 -1
  4. package/dist/agent-runtime/routed-session.js +100 -26
  5. package/dist/agent-runtimes/codex-native/adapter.js +304 -30
  6. package/dist/agent-runtimes/codex-native/client.js +22 -3
  7. package/dist/agent-runtimes/codex-native/first-use.js +294 -0
  8. package/dist/agent-runtimes/codex-native/history.js +26 -7
  9. package/dist/agent-runtimes/codex-native/process.js +14 -0
  10. package/dist/agent-runtimes/codex-native/thread.js +59 -25
  11. package/dist/agent-runtimes/codex-native/turn.js +27 -0
  12. package/dist/agent-runtimes/history-proof.js +8 -0
  13. package/dist/agent-runtimes/omp/adapter.js +61 -14
  14. package/dist/agent-runtimes/omp/history.js +189 -34
  15. package/dist/agent-runtimes/omp/thread.js +82 -36
  16. package/dist/agent-runtimes/pi/adapter.js +30 -3
  17. package/dist/agent-runtimes/pi/history.js +66 -5
  18. package/dist/agent-runtimes/pi/openai-response-observer.js +102 -0
  19. package/dist/agent-runtimes/pi/routed-session.js +344 -37
  20. package/dist/apps/chat/chat-api-routes.js +1 -1
  21. package/dist/apps/chat/chat-files.js +130 -9
  22. package/dist/apps/chat/chat-settings-routes.js +27 -2
  23. package/dist/apps/chat/chat-speech.js +100 -0
  24. package/dist/apps/chat/data/chat-data-mappers.js +25 -5
  25. package/dist/apps/chat/data/timeline-query-service.js +89 -1
  26. package/dist/apps/chat/trace-v2.js +210 -8
  27. package/dist/apps/chat/trace.js +3 -0
  28. package/dist/apps/chat/web-app.js +108 -3
  29. package/dist/apps/chat-ui/assets/{dist-DTWJf37A.js → dist-BBDMeU5-.js} +1 -1
  30. package/dist/apps/chat-ui/assets/{dist-CkYWKnvy.js → dist-BDIATCQt.js} +1 -1
  31. package/dist/apps/chat-ui/assets/{dist-DZ8Wpj-H.js → dist-JnW4v8UE.js} +1 -1
  32. package/dist/apps/chat-ui/assets/{dist-Dmblz15N.js → dist-lRiLP9rr.js} +1 -1
  33. package/dist/apps/chat-ui/assets/{dist-DjcwoyNY.js → dist-mqCviI-c.js} +1 -1
  34. package/dist/apps/chat-ui/assets/index-0XOOVxLP.js +228 -0
  35. package/dist/apps/chat-ui/assets/index-DV7_CgsC.css +1 -0
  36. package/dist/apps/chat-ui/index.html +2 -2
  37. package/dist/apps/chat-vscode-web/assets/index-Dtw2Z7jz.js +43 -0
  38. package/dist/apps/chat-vscode-web/assets/{index-CF1-9PKU.css → index-SluZr_-r.css} +1 -1
  39. package/dist/apps/chat-vscode-web/index.html +2 -2
  40. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  41. package/dist/apps/vscode-artifacts/pibo-vscode-ext-3.0.1.vsix +0 -0
  42. package/dist/cli.js +49 -5
  43. package/dist/config/config.js +59 -8
  44. package/dist/core/preview-server-settings.js +20 -0
  45. package/dist/core/session-router.js +464 -37
  46. package/dist/core/user-settings.js +10 -0
  47. package/dist/data/payload-store.js +57 -1
  48. package/dist/data/schema.js +18 -1
  49. package/dist/debug/trace.js +5 -0
  50. package/dist/gateway/client.js +368 -65
  51. package/dist/gateway/protocol.js +2 -0
  52. package/dist/gateway/server.js +8 -0
  53. package/dist/gateway/web.js +2 -0
  54. package/dist/index.js +1 -1
  55. package/dist/loops/store.js +6 -4
  56. package/dist/mcp/config.js +89 -17
  57. package/dist/mcp/daemon-client.js +473 -153
  58. package/dist/mcp/daemon.js +401 -37
  59. package/dist/plugins/builtin.js +2 -2
  60. package/dist/plugins/codex-native.js +2 -0
  61. package/dist/plugins/registry.js +259 -1
  62. package/dist/previews/cli.js +345 -0
  63. package/dist/previews/config.js +61 -0
  64. package/dist/previews/manager.js +644 -0
  65. package/dist/previews/network.js +198 -0
  66. package/dist/previews/plugin.js +11 -0
  67. package/dist/previews/process-supervisor.js +38 -0
  68. package/dist/previews/proxy.js +387 -0
  69. package/dist/previews/store.js +750 -0
  70. package/dist/previews/types.js +1 -0
  71. package/dist/previews/web-app.js +446 -0
  72. package/dist/runs/registry.js +22 -0
  73. package/dist/runs/tools.js +46 -7
  74. package/dist/session-ui/terminalRows.js +187 -13
  75. package/dist/sessions/pibo-data-store.js +37 -0
  76. package/dist/sessions/runtime-binding-persistence.js +34 -0
  77. package/dist/sessions/runtime-binding.js +6 -0
  78. package/dist/sessions/sqlite-store.js +37 -0
  79. package/dist/shared/trace-engine.js +12 -64
  80. package/dist/shared/trace-event-projection.js +16 -0
  81. package/dist/shared/trace-history.js +684 -184
  82. package/dist/shared/trace-limits.js +7 -0
  83. package/dist/shared/trace-live-patch.js +61 -0
  84. package/dist/shared/trace-tool-identity.js +43 -0
  85. package/dist/speech/openai-codex-realtime-call-proxy.js +248 -0
  86. package/dist/speech/openai-codex.js +407 -0
  87. package/dist/speech/types.js +8 -0
  88. package/dist/subagents/context.js +5 -3
  89. package/dist/subagents/observations.js +94 -6
  90. package/dist/subagents/tool.js +103 -20
  91. package/dist/tools/codex-image-artifacts.js +24 -0
  92. package/dist/tools/codex-image-generation.js +4 -22
  93. package/dist/web/channel.js +88 -4
  94. package/dist/web/http.js +2 -1
  95. package/npm-shrinkwrap.json +2 -2
  96. package/package.json +1 -1
  97. package/dist/apps/chat-ui/assets/index-0WZI2phJ.css +0 -1
  98. package/dist/apps/chat-ui/assets/index-DsgTm1Aw.js +0 -228
  99. package/dist/apps/chat-vscode-web/assets/index-DvTSSvzN.js +0 -43
  100. package/dist/apps/vscode-artifacts/pibo-vscode-ext-2.5.2.vsix +0 -0
@@ -1,26 +1,40 @@
1
1
  /**
2
- * MCP-CLI Daemon Client - IPC client for communicating with daemon workers
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, killProcess, readPidFile, removePidFile, removeSocketFile, } from './daemon.js';
14
- /**
15
- * Generate a unique request ID
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()}-${Math.random().toString(36).slice(2, 9)}`;
18
+ return `${Date.now()}-${randomUUID()}`;
19
19
  }
20
- /**
21
- * Send a request to the daemon and wait for response
22
- */
23
- async function sendRequest(socketPath, request) {
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.includes('\n')) {
40
- settle(() => {
41
- try {
42
- resolve(JSON.parse(responseText.trim()));
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
- }, getDaemonRequestTimeoutMs());
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
- const pidInfo = readPidFile(serverName);
80
- // No PID file = no daemon
81
- if (!pidInfo) {
82
- debug(`[daemon-client] No PID file for ${serverName}`);
83
- return false;
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
- // Check if process is actually running
86
- if (!isProcessRunning(pidInfo.pid)) {
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
- // Check if config matches
93
- const currentHash = getConfigHash(config);
94
- if (pidInfo.configHash !== currentHash) {
95
- debug(`[daemon-client] Config hash mismatch for ${serverName}, killing old daemon`);
96
- killProcess(pidInfo.pid);
97
- removePidFile(serverName);
98
- removeSocketFile(serverName);
99
- return false;
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
- // Check if socket exists
102
- if (!existsSync(socketPath)) {
103
- debug(`[daemon-client] Socket missing for ${serverName}, cleaning up`);
104
- killProcess(pidInfo.pid);
105
- removePidFile(serverName);
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
- return true;
184
+ const exited = await waitForDaemonExit(identity, 2000);
185
+ return exited && removeUntrackedEndpoint(serverName, ownership);
109
186
  }
110
- /**
111
- * Spawn a new daemon process for a server
112
- */
113
- async function spawnDaemon(serverName, config) {
114
- debug(`[daemon-client] Spawning daemon for ${serverName}`);
115
- // Find the daemon script next to this module. This is .ts under tsx and .js after build.
116
- const modulePath = fileURLToPath(import.meta.url);
117
- const daemonScript = join(dirname(modulePath), `daemon${extname(modulePath)}`);
118
- const configJson = JSON.stringify(config);
119
- // Spawn detached process
120
- const proc = spawn(process.execPath, [
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
- configJson,
126
- ], {
127
- detached: true,
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
- * Get or create a daemon connection for a server
165
- * Returns null if daemon mode fails (caller should fallback to direct connection)
166
- */
167
- export async function getDaemonConnection(serverName, config) {
168
- const socketPath = getSocketPath(serverName);
169
- // Check if valid daemon exists
170
- if (!isDaemonValid(serverName, config)) {
171
- // Spawn new daemon
172
- const spawned = await spawnDaemon(serverName, config);
173
- if (!spawned) {
174
- debug(`[daemon-client] Failed to spawn daemon for ${serverName}`);
175
- return null;
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
- // Wait a bit for socket to be ready
178
- await new Promise((r) => setTimeout(r, 100));
250
+ filtered.push(argument);
179
251
  }
180
- // Verify socket exists
181
- if (!existsSync(socketPath)) {
182
- debug(`[daemon-client] Socket not found after spawn for ${serverName}`);
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
- // Test connection with ping
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
- const pingResponse = await sendRequest(socketPath, {
188
- id: generateRequestId(),
189
- type: 'ping',
190
- });
191
- if (!pingResponse.success) {
192
- debug(`[daemon-client] Ping failed for ${serverName}`);
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
- catch (error) {
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
- debug(`[daemon-client] Connected to daemon for ${serverName}`);
201
- // Return connection interface
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 sendRequest(socketPath, {
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 sendRequest(socketPath, {
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 sendRequest(socketPath, {
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
- // Just disconnect, don't tell daemon to close (let it idle timeout)
238
- debug(`[daemon-client] Disconnecting from ${serverName} daemon`);
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
- * Clean up any orphaned daemon processes and sockets
244
- * Call this on CLI startup
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 = (await readdir(socketDir)).filter((file) => file.endsWith('.pid'));
537
+ const files = await readdir(socketDir);
253
538
  for (const file of files) {
254
- const serverName = file.replace('.pid', '');
255
- const pidInfo = readPidFile(serverName);
256
- if (pidInfo && !isProcessRunning(pidInfo.pid)) {
257
- debug(`[daemon-client] Cleaning up orphaned daemon: ${serverName}`);
258
- removePidFile(serverName);
259
- removeSocketFile(serverName);
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
- // Ignore errors during cleanup scan
584
+ // Cleanup is opportunistic; connection setup performs exact recovery.
265
585
  }
266
586
  }