proteum 2.3.0 → 2.4.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 (49) hide show
  1. package/AGENTS.md +8 -3
  2. package/README.md +20 -15
  3. package/agents/project/AGENTS.md +15 -10
  4. package/agents/project/DOCUMENTATION.md +1326 -0
  5. package/agents/project/app-root/AGENTS.md +2 -2
  6. package/agents/project/diagnostics.md +9 -8
  7. package/agents/project/root/AGENTS.md +14 -8
  8. package/agents/project/tests/AGENTS.md +1 -0
  9. package/cli/commands/dev.ts +148 -25
  10. package/cli/commands/diagnose.ts +2 -0
  11. package/cli/commands/explain.ts +38 -9
  12. package/cli/commands/mcp.ts +126 -9
  13. package/cli/commands/orient.ts +44 -17
  14. package/cli/commands/runtime.ts +100 -17
  15. package/cli/mcp/router.ts +1010 -0
  16. package/cli/presentation/commands.ts +34 -24
  17. package/cli/presentation/help.ts +1 -1
  18. package/cli/runtime/commands.ts +129 -21
  19. package/cli/runtime/devSessions.ts +328 -2
  20. package/cli/runtime/mcpDaemon.ts +288 -0
  21. package/cli/runtime/ports.ts +151 -0
  22. package/cli/utils/agents.ts +93 -17
  23. package/cli/utils/appRoots.ts +232 -0
  24. package/common/dev/diagnostics.ts +1 -1
  25. package/common/dev/inspection.ts +8 -1
  26. package/common/dev/mcpPayloads.ts +431 -17
  27. package/common/dev/mcpServer.ts +33 -0
  28. package/docs/agent-routing.md +32 -21
  29. package/docs/dev-commands.md +1 -1
  30. package/docs/dev-sessions.md +3 -1
  31. package/docs/diagnostics.md +21 -20
  32. package/docs/mcp.md +109 -52
  33. package/docs/migrate-from-2.1.3.md +3 -5
  34. package/docs/request-tracing.md +3 -3
  35. package/package.json +10 -3
  36. package/server/app/devMcp.ts +45 -0
  37. package/server/services/router/request/ip.test.cjs +0 -1
  38. package/tests/agents-utils.test.cjs +58 -3
  39. package/tests/cli-mcp-command.test.cjs +262 -0
  40. package/tests/codex-mcp-usage.test.cjs +307 -0
  41. package/tests/dev-sessions.test.cjs +113 -0
  42. package/tests/dev-transpile-watch.test.cjs +0 -1
  43. package/tests/eslint-rules.test.cjs +0 -1
  44. package/tests/inspection.test.cjs +0 -1
  45. package/tests/mcp.test.cjs +748 -2
  46. package/tests/router-cache-config.test.cjs +0 -1
  47. package/vitest.config.mjs +9 -0
  48. package/cli/mcp/provider.ts +0 -365
  49. package/cli/mcp/stdio.ts +0 -16
@@ -1,8 +1,11 @@
1
+ import crypto from 'crypto';
2
+ import os from 'os';
1
3
  import path from 'path';
2
4
 
3
5
  import fs from 'fs-extra';
4
6
 
5
7
  export const devSessionRegistryVersion = 1 as const;
8
+ export const machineDevSessionRegistryVersion = 1 as const;
6
9
 
7
10
  export type TDevSessionState = 'starting' | 'ready';
8
11
 
@@ -18,6 +21,20 @@ export type TDevSessionRecord = {
18
21
  state: TDevSessionState;
19
22
  };
20
23
 
24
+ export type TMachineDevSessionRecord = {
25
+ version: typeof machineDevSessionRegistryVersion;
26
+ projectId: string;
27
+ appRoot: string;
28
+ pid: number;
29
+ routerPort: number;
30
+ publicUrl: string;
31
+ mcpUrl: string;
32
+ startedAt: string;
33
+ updatedAt: string;
34
+ sessionFilePath: string;
35
+ state: TDevSessionState;
36
+ };
37
+
21
38
  export type TDevSessionInspection = {
22
39
  sessionFilePath: string;
23
40
  record: TDevSessionRecord | null;
@@ -27,6 +44,15 @@ export type TDevSessionInspection = {
27
44
  parseError: string;
28
45
  };
29
46
 
47
+ export type TMachineDevSessionInspection = {
48
+ registryFilePath: string;
49
+ record: TMachineDevSessionRecord | null;
50
+ live: boolean;
51
+ stale: boolean;
52
+ invalid: boolean;
53
+ parseError: string;
54
+ };
55
+
30
56
  export type TStopDevSessionResult = {
31
57
  sessionFilePath: string;
32
58
  pid: number | null;
@@ -42,7 +68,14 @@ export type TStopDevSessionResult = {
42
68
  parseError: string;
43
69
  };
44
70
 
71
+ export type TPrepareDevSessionStartResult = {
72
+ blocking: TDevSessionInspection[];
73
+ cleaned: TStopDevSessionResult[];
74
+ replaced: TStopDevSessionResult | null;
75
+ };
76
+
45
77
  const defaultRegistryDirectoryParts = ['var', 'run', 'proteum', 'dev'];
78
+ const defaultMachineRegistryDirectoryParts = ['.proteum', 'dev-sessions'];
46
79
 
47
80
  const sleep = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs));
48
81
 
@@ -70,6 +103,33 @@ const isRecordShape = (value: unknown): value is TDevSessionRecord => {
70
103
  );
71
104
  };
72
105
 
106
+ const isMachineRecordShape = (value: unknown): value is TMachineDevSessionRecord => {
107
+ if (!value || typeof value !== 'object') return false;
108
+
109
+ const candidate = value as Partial<TMachineDevSessionRecord>;
110
+
111
+ return (
112
+ candidate.version === machineDevSessionRegistryVersion &&
113
+ typeof candidate.projectId === 'string' &&
114
+ candidate.projectId.startsWith('prj_') &&
115
+ typeof candidate.appRoot === 'string' &&
116
+ candidate.appRoot.length > 0 &&
117
+ typeof candidate.pid === 'number' &&
118
+ Number.isInteger(candidate.pid) &&
119
+ candidate.pid > 0 &&
120
+ typeof candidate.routerPort === 'number' &&
121
+ Number.isInteger(candidate.routerPort) &&
122
+ candidate.routerPort > 0 &&
123
+ typeof candidate.publicUrl === 'string' &&
124
+ typeof candidate.mcpUrl === 'string' &&
125
+ typeof candidate.startedAt === 'string' &&
126
+ typeof candidate.updatedAt === 'string' &&
127
+ typeof candidate.sessionFilePath === 'string' &&
128
+ candidate.sessionFilePath.length > 0 &&
129
+ (candidate.state === 'starting' || candidate.state === 'ready')
130
+ );
131
+ };
132
+
73
133
  const canSignalProcess = (pid: number, signal: NodeJS.Signals | 0) => {
74
134
  try {
75
135
  process.kill(pid, signal);
@@ -99,6 +159,39 @@ const waitForProcessExit = async (pid: number, timeoutMs: number) => {
99
159
 
100
160
  export const getDevSessionRegistryDirectory = (appRoot: string) => path.join(appRoot, ...defaultRegistryDirectoryParts);
101
161
 
162
+ export const getMachineDevSessionRegistryDirectory = () =>
163
+ process.env.PROTEUM_MACHINE_DEV_SESSION_DIR && process.env.PROTEUM_MACHINE_DEV_SESSION_DIR.trim()
164
+ ? path.resolve(process.env.PROTEUM_MACHINE_DEV_SESSION_DIR)
165
+ : path.join(os.homedir(), ...defaultMachineRegistryDirectoryParts);
166
+
167
+ const createProjectIdFromCanonicalAppRoot = (canonicalAppRoot: string) =>
168
+ `prj_${crypto.createHash('sha256').update(canonicalAppRoot).digest('hex').slice(0, 12)}`;
169
+
170
+ export const resolveCanonicalAppRoot = async (appRoot: string) => {
171
+ try {
172
+ return path.normalize(await fs.realpath(appRoot));
173
+ } catch {
174
+ return path.resolve(appRoot);
175
+ }
176
+ };
177
+
178
+ export const resolveCanonicalAppRootSync = (appRoot: string) => {
179
+ try {
180
+ return path.normalize(fs.realpathSync(appRoot));
181
+ } catch {
182
+ return path.resolve(appRoot);
183
+ }
184
+ };
185
+
186
+ export const resolveProteumProjectId = async (appRoot: string) =>
187
+ createProjectIdFromCanonicalAppRoot(await resolveCanonicalAppRoot(appRoot));
188
+
189
+ export const resolveProteumProjectIdSync = (appRoot: string) =>
190
+ createProjectIdFromCanonicalAppRoot(resolveCanonicalAppRootSync(appRoot));
191
+
192
+ export const resolveMachineDevSessionFilePath = (projectId: string) =>
193
+ path.join(getMachineDevSessionRegistryDirectory(), `${projectId}.json`);
194
+
102
195
  export const resolveDevSessionFilePath = ({
103
196
  appRoot,
104
197
  port,
@@ -141,11 +234,184 @@ export const createDevSessionRecord = ({
141
234
  };
142
235
  };
143
236
 
237
+ export const createMachineDevSessionRecord = async (record: TDevSessionRecord): Promise<TMachineDevSessionRecord> => {
238
+ const appRoot = await resolveCanonicalAppRoot(record.appRoot);
239
+ const projectId = createProjectIdFromCanonicalAppRoot(appRoot);
240
+ const publicUrl = record.publicUrl || `http://localhost:${record.routerPort}`;
241
+
242
+ return {
243
+ version: machineDevSessionRegistryVersion,
244
+ projectId,
245
+ appRoot,
246
+ pid: record.pid,
247
+ routerPort: record.routerPort,
248
+ publicUrl,
249
+ mcpUrl: `${publicUrl.replace(/\/+$/, '')}/__proteum/mcp`,
250
+ startedAt: record.startedAt,
251
+ updatedAt: record.updatedAt,
252
+ sessionFilePath: record.sessionFilePath,
253
+ state: record.state,
254
+ };
255
+ };
256
+
144
257
  export const writeDevSessionRecord = async (record: TDevSessionRecord) => {
145
258
  await fs.ensureDir(path.dirname(record.sessionFilePath));
146
259
  await fs.writeJson(record.sessionFilePath, record, { spaces: 2 });
147
260
  };
148
261
 
262
+ export const writeMachineDevSessionRecord = async (record: TDevSessionRecord) => {
263
+ const machineRecord = await createMachineDevSessionRecord(record);
264
+ const registryFilePath = resolveMachineDevSessionFilePath(machineRecord.projectId);
265
+
266
+ await fs.ensureDir(path.dirname(registryFilePath));
267
+ await fs.writeJson(registryFilePath, machineRecord, { spaces: 2 });
268
+
269
+ return machineRecord;
270
+ };
271
+
272
+ export const inspectMachineDevSessionFile = async (registryFilePath: string): Promise<TMachineDevSessionInspection | null> => {
273
+ if (!(await fs.pathExists(registryFilePath))) return null;
274
+
275
+ try {
276
+ const rawValue = await fs.readJson(registryFilePath);
277
+ if (!isMachineRecordShape(rawValue)) {
278
+ return {
279
+ registryFilePath,
280
+ record: null,
281
+ live: false,
282
+ stale: true,
283
+ invalid: true,
284
+ parseError: 'Machine session file contents do not match the Proteum dev session schema.',
285
+ };
286
+ }
287
+
288
+ const record = rawValue;
289
+ const live = isProcessAlive(record.pid);
290
+
291
+ return {
292
+ registryFilePath,
293
+ record,
294
+ live,
295
+ stale: !live,
296
+ invalid: false,
297
+ parseError: '',
298
+ };
299
+ } catch (error) {
300
+ return {
301
+ registryFilePath,
302
+ record: null,
303
+ live: false,
304
+ stale: true,
305
+ invalid: true,
306
+ parseError: error instanceof Error ? error.message : String(error),
307
+ };
308
+ }
309
+ };
310
+
311
+ export const listMachineDevSessionFiles = async () => {
312
+ const registryDirectory = getMachineDevSessionRegistryDirectory();
313
+ if (!(await fs.pathExists(registryDirectory))) return [];
314
+
315
+ const entries = await fs.readdir(registryDirectory);
316
+
317
+ return entries
318
+ .filter((entry) => entry.endsWith('.json'))
319
+ .sort((left, right) => left.localeCompare(right))
320
+ .map((entry) => path.join(registryDirectory, entry));
321
+ };
322
+
323
+ export const listMachineDevSessionInspections = async ({
324
+ cleanStale = true,
325
+ }: {
326
+ cleanStale?: boolean;
327
+ } = {}) => {
328
+ const registryFilePaths = await listMachineDevSessionFiles();
329
+ const inspections = (
330
+ await Promise.all(registryFilePaths.map((registryFilePath) => inspectMachineDevSessionFile(registryFilePath)))
331
+ ).filter((inspection): inspection is TMachineDevSessionInspection => inspection !== null);
332
+
333
+ if (cleanStale) {
334
+ await Promise.all(
335
+ inspections
336
+ .filter((inspection) => inspection.invalid || inspection.stale || !inspection.record)
337
+ .map((inspection) => fs.remove(inspection.registryFilePath)),
338
+ );
339
+ }
340
+
341
+ return cleanStale ? inspections.filter((inspection) => inspection.record && inspection.live) : inspections;
342
+ };
343
+
344
+ export const resolveMachineDevSessionInspection = async (projectId: string) => {
345
+ const inspection = await inspectMachineDevSessionFile(resolveMachineDevSessionFilePath(projectId));
346
+
347
+ if (!inspection) return null;
348
+
349
+ if (inspection.invalid || inspection.stale || !inspection.record) {
350
+ await fs.remove(inspection.registryFilePath);
351
+ return null;
352
+ }
353
+
354
+ return inspection;
355
+ };
356
+
357
+ const normalizeMaybeRelativePath = (value: string) => path.normalize(path.resolve(value));
358
+
359
+ export const removeMachineDevSessionRecordForDevSession = async ({
360
+ appRoot,
361
+ pid = process.pid,
362
+ sessionFilePath,
363
+ }: {
364
+ appRoot: string;
365
+ pid?: number;
366
+ sessionFilePath: string;
367
+ }) => {
368
+ const projectId = await resolveProteumProjectId(appRoot);
369
+ const registryFilePath = resolveMachineDevSessionFilePath(projectId);
370
+ const inspection = await inspectMachineDevSessionFile(registryFilePath);
371
+ if (!inspection?.record) {
372
+ if (inspection?.invalid) await fs.remove(registryFilePath);
373
+ return;
374
+ }
375
+
376
+ if (
377
+ inspection.record.pid === pid &&
378
+ normalizeMaybeRelativePath(inspection.record.sessionFilePath) === normalizeMaybeRelativePath(sessionFilePath)
379
+ ) {
380
+ await fs.remove(registryFilePath);
381
+ }
382
+ };
383
+
384
+ export const removeMachineDevSessionRecordForDevSessionSync = ({
385
+ appRoot,
386
+ pid = process.pid,
387
+ sessionFilePath,
388
+ }: {
389
+ appRoot: string;
390
+ pid?: number;
391
+ sessionFilePath: string;
392
+ }) => {
393
+ try {
394
+ const projectId = resolveProteumProjectIdSync(appRoot);
395
+ const registryFilePath = resolveMachineDevSessionFilePath(projectId);
396
+ if (!fs.pathExistsSync(registryFilePath)) return;
397
+
398
+ const record = fs.readJsonSync(registryFilePath);
399
+ if (!isMachineRecordShape(record)) {
400
+ fs.removeSync(registryFilePath);
401
+ return;
402
+ }
403
+
404
+ if (
405
+ record.pid === pid &&
406
+ normalizeMaybeRelativePath(record.sessionFilePath) === normalizeMaybeRelativePath(sessionFilePath)
407
+ ) {
408
+ fs.removeSync(registryFilePath);
409
+ }
410
+ } catch {
411
+ // Best-effort cleanup during process exit.
412
+ }
413
+ };
414
+
149
415
  export const updateDevSessionRecord = async ({
150
416
  sessionFilePath,
151
417
  patch,
@@ -156,19 +422,38 @@ export const updateDevSessionRecord = async ({
156
422
  const inspection = await inspectDevSessionFile(sessionFilePath);
157
423
  if (!inspection || !inspection.record) return;
158
424
 
159
- await writeDevSessionRecord({
425
+ const updatedRecord = {
160
426
  ...inspection.record,
161
427
  ...patch,
162
428
  updatedAt: new Date().toISOString(),
163
- });
429
+ };
430
+
431
+ await writeDevSessionRecord(updatedRecord);
432
+ await writeMachineDevSessionRecord(updatedRecord);
164
433
  };
165
434
 
166
435
  export const removeDevSessionRecord = async (sessionFilePath: string) => {
436
+ const inspection = await inspectDevSessionFile(sessionFilePath);
437
+ if (inspection?.record) {
438
+ await removeMachineDevSessionRecordForDevSession({
439
+ appRoot: inspection.record.appRoot,
440
+ pid: inspection.record.pid,
441
+ sessionFilePath,
442
+ });
443
+ }
167
444
  await fs.remove(sessionFilePath);
168
445
  };
169
446
 
170
447
  export const removeDevSessionRecordSync = (sessionFilePath: string) => {
171
448
  try {
449
+ const inspection = fs.pathExistsSync(sessionFilePath) ? fs.readJsonSync(sessionFilePath) : undefined;
450
+ if (isRecordShape(inspection)) {
451
+ removeMachineDevSessionRecordForDevSessionSync({
452
+ appRoot: inspection.appRoot,
453
+ pid: inspection.pid,
454
+ sessionFilePath,
455
+ });
456
+ }
172
457
  fs.removeSync(sessionFilePath);
173
458
  } catch {
174
459
  // Best-effort cleanup during process exit.
@@ -248,6 +533,47 @@ export const listDevSessionInspections = async ({
248
533
  return inspections.filter((inspection): inspection is TDevSessionInspection => inspection !== null);
249
534
  };
250
535
 
536
+ const normalizeSessionFilePath = (sessionFilePath: string) => path.normalize(sessionFilePath);
537
+
538
+ export const prepareDevSessionStart = async ({
539
+ appRoot,
540
+ currentPid = process.pid,
541
+ replaceExisting,
542
+ sessionFilePath,
543
+ }: {
544
+ appRoot: string;
545
+ currentPid?: number;
546
+ replaceExisting: boolean;
547
+ sessionFilePath: string;
548
+ }): Promise<TPrepareDevSessionStartResult> => {
549
+ const inspections = await listDevSessionInspections({ appRoot });
550
+ const normalizedSessionFilePath = normalizeSessionFilePath(sessionFilePath);
551
+ const blocking: TDevSessionInspection[] = [];
552
+ const cleaned: TStopDevSessionResult[] = [];
553
+ let replaced: TStopDevSessionResult | null = null;
554
+
555
+ for (const inspection of inspections) {
556
+ const isCurrentSessionFile = normalizeSessionFilePath(inspection.sessionFilePath) === normalizedSessionFilePath;
557
+
558
+ if (!inspection.record || !inspection.live) {
559
+ cleaned.push(await stopDevSessionFile(inspection.sessionFilePath));
560
+ continue;
561
+ }
562
+
563
+ if (inspection.record.pid === currentPid) continue;
564
+
565
+ if (isCurrentSessionFile && replaceExisting) {
566
+ replaced = await stopDevSessionFile(inspection.sessionFilePath);
567
+ if (!replaced.stopped) blocking.push(inspection);
568
+ continue;
569
+ }
570
+
571
+ blocking.push(inspection);
572
+ }
573
+
574
+ return { blocking, cleaned, replaced };
575
+ };
576
+
251
577
  export const stopDevSessionFile = async (sessionFilePath: string): Promise<TStopDevSessionResult> => {
252
578
  const inspection = await inspectDevSessionFile(sessionFilePath);
253
579
 
@@ -0,0 +1,288 @@
1
+ import { spawn } from 'child_process';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ import fs from 'fs-extra';
6
+
7
+ export const machineMcpDaemonRegistryVersion = 1 as const;
8
+
9
+ export type TMachineMcpDaemonRecord = {
10
+ version: typeof machineMcpDaemonRegistryVersion;
11
+ pid: number;
12
+ port: number;
13
+ host: string;
14
+ mcpUrl: string;
15
+ healthUrl: string;
16
+ startedAt: string;
17
+ updatedAt: string;
18
+ command: string[];
19
+ };
20
+
21
+ export type TMachineMcpDaemonInspection = {
22
+ registryFilePath: string;
23
+ record: TMachineMcpDaemonRecord | null;
24
+ live: boolean;
25
+ stale: boolean;
26
+ invalid: boolean;
27
+ parseError: string;
28
+ };
29
+
30
+ export type TEnsureMachineMcpDaemonResult = {
31
+ inspection: TMachineMcpDaemonInspection;
32
+ started: boolean;
33
+ };
34
+
35
+ const defaultMachineMcpDaemonDirectoryParts = ['.proteum', 'mcp'];
36
+ const defaultMachineMcpDaemonPort = 3769;
37
+ const machineMcpDaemonHost = '127.0.0.1';
38
+
39
+ const sleep = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs));
40
+
41
+ const isRecordShape = (value: unknown): value is TMachineMcpDaemonRecord => {
42
+ if (!value || typeof value !== 'object') return false;
43
+
44
+ const candidate = value as Partial<TMachineMcpDaemonRecord>;
45
+
46
+ return (
47
+ candidate.version === machineMcpDaemonRegistryVersion &&
48
+ typeof candidate.pid === 'number' &&
49
+ Number.isInteger(candidate.pid) &&
50
+ candidate.pid > 0 &&
51
+ typeof candidate.port === 'number' &&
52
+ Number.isInteger(candidate.port) &&
53
+ candidate.port > 0 &&
54
+ typeof candidate.host === 'string' &&
55
+ candidate.host.length > 0 &&
56
+ typeof candidate.mcpUrl === 'string' &&
57
+ candidate.mcpUrl.length > 0 &&
58
+ typeof candidate.healthUrl === 'string' &&
59
+ candidate.healthUrl.length > 0 &&
60
+ typeof candidate.startedAt === 'string' &&
61
+ typeof candidate.updatedAt === 'string' &&
62
+ Array.isArray(candidate.command) &&
63
+ candidate.command.every((entry) => typeof entry === 'string')
64
+ );
65
+ };
66
+
67
+ const canSignalProcess = (pid: number, signal: NodeJS.Signals | 0) => {
68
+ try {
69
+ process.kill(pid, signal);
70
+ return true;
71
+ } catch (error) {
72
+ const errno = error as NodeJS.ErrnoException;
73
+
74
+ if (errno.code === 'ESRCH') return false;
75
+ if (errno.code === 'EPERM') return true;
76
+
77
+ throw error;
78
+ }
79
+ };
80
+
81
+ const isProcessAlive = (pid: number) => canSignalProcess(pid, 0);
82
+
83
+ const waitForProcessExit = async (pid: number, timeoutMs: number) => {
84
+ const deadline = Date.now() + timeoutMs;
85
+
86
+ while (Date.now() < deadline) {
87
+ if (!isProcessAlive(pid)) return true;
88
+ await sleep(100);
89
+ }
90
+
91
+ return !isProcessAlive(pid);
92
+ };
93
+
94
+ export const getMachineMcpDaemonRegistryDirectory = () =>
95
+ process.env.PROTEUM_MACHINE_MCP_DIR && process.env.PROTEUM_MACHINE_MCP_DIR.trim()
96
+ ? path.resolve(process.env.PROTEUM_MACHINE_MCP_DIR)
97
+ : path.join(os.homedir(), ...defaultMachineMcpDaemonDirectoryParts);
98
+
99
+ export const resolveMachineMcpDaemonRecordFilePath = () =>
100
+ path.join(getMachineMcpDaemonRegistryDirectory(), 'router.json');
101
+
102
+ export const resolveMachineMcpDaemonPort = (port?: string | number) => {
103
+ const rawPort =
104
+ port !== undefined && String(port).trim()
105
+ ? String(port).trim()
106
+ : process.env.PROTEUM_MCP_PORT && process.env.PROTEUM_MCP_PORT.trim()
107
+ ? process.env.PROTEUM_MCP_PORT.trim()
108
+ : String(defaultMachineMcpDaemonPort);
109
+ const parsedPort = Number(rawPort);
110
+
111
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
112
+ throw new Error(`Invalid Proteum MCP daemon port: ${rawPort}.`);
113
+ }
114
+
115
+ return parsedPort;
116
+ };
117
+
118
+ export const createMachineMcpDaemonRecord = ({
119
+ command,
120
+ host = machineMcpDaemonHost,
121
+ pid = process.pid,
122
+ port,
123
+ }: {
124
+ command: string[];
125
+ host?: string;
126
+ pid?: number;
127
+ port: number;
128
+ }): TMachineMcpDaemonRecord => {
129
+ const timestamp = new Date().toISOString();
130
+ const baseUrl = `http://${host}:${port}`;
131
+
132
+ return {
133
+ version: machineMcpDaemonRegistryVersion,
134
+ pid,
135
+ port,
136
+ host,
137
+ mcpUrl: `${baseUrl}/mcp`,
138
+ healthUrl: `${baseUrl}/health`,
139
+ startedAt: timestamp,
140
+ updatedAt: timestamp,
141
+ command,
142
+ };
143
+ };
144
+
145
+ export const writeMachineMcpDaemonRecord = async (record: TMachineMcpDaemonRecord) => {
146
+ const registryFilePath = resolveMachineMcpDaemonRecordFilePath();
147
+
148
+ await fs.ensureDir(path.dirname(registryFilePath));
149
+ await fs.writeJson(registryFilePath, record, { spaces: 2 });
150
+
151
+ return record;
152
+ };
153
+
154
+ export const removeMachineMcpDaemonRecord = async (pid = process.pid) => {
155
+ const inspection = await inspectMachineMcpDaemonRecord({ cleanStale: false });
156
+
157
+ if (!inspection?.record || inspection.record.pid === pid) {
158
+ await fs.remove(resolveMachineMcpDaemonRecordFilePath());
159
+ }
160
+ };
161
+
162
+ export const removeMachineMcpDaemonRecordSync = (pid = process.pid) => {
163
+ try {
164
+ const registryFilePath = resolveMachineMcpDaemonRecordFilePath();
165
+ if (!fs.pathExistsSync(registryFilePath)) return;
166
+
167
+ const record = fs.readJsonSync(registryFilePath);
168
+ if (!isRecordShape(record) || record.pid === pid) fs.removeSync(registryFilePath);
169
+ } catch {
170
+ // Best-effort cleanup during process exit.
171
+ }
172
+ };
173
+
174
+ export const inspectMachineMcpDaemonRecord = async ({
175
+ cleanStale = true,
176
+ }: {
177
+ cleanStale?: boolean;
178
+ } = {}): Promise<TMachineMcpDaemonInspection | null> => {
179
+ const registryFilePath = resolveMachineMcpDaemonRecordFilePath();
180
+ if (!(await fs.pathExists(registryFilePath))) return null;
181
+
182
+ try {
183
+ const rawValue = await fs.readJson(registryFilePath);
184
+ if (!isRecordShape(rawValue)) {
185
+ const inspection = {
186
+ registryFilePath,
187
+ record: null,
188
+ live: false,
189
+ stale: true,
190
+ invalid: true,
191
+ parseError: 'Machine MCP daemon file contents do not match the Proteum MCP daemon schema.',
192
+ };
193
+
194
+ if (cleanStale) await fs.remove(registryFilePath);
195
+ return inspection;
196
+ }
197
+
198
+ const record = rawValue;
199
+ const live = isProcessAlive(record.pid);
200
+ const inspection = {
201
+ registryFilePath,
202
+ record,
203
+ live,
204
+ stale: !live,
205
+ invalid: false,
206
+ parseError: '',
207
+ };
208
+
209
+ if (cleanStale && !live) await fs.remove(registryFilePath);
210
+
211
+ return inspection;
212
+ } catch (error) {
213
+ const inspection = {
214
+ registryFilePath,
215
+ record: null,
216
+ live: false,
217
+ stale: true,
218
+ invalid: true,
219
+ parseError: error instanceof Error ? error.message : String(error),
220
+ };
221
+
222
+ if (cleanStale) await fs.remove(registryFilePath);
223
+
224
+ return inspection;
225
+ }
226
+ };
227
+
228
+ export const ensureMachineMcpDaemonProcess = async ({
229
+ coreRoot,
230
+ port,
231
+ }: {
232
+ coreRoot: string;
233
+ port?: string | number;
234
+ }): Promise<TEnsureMachineMcpDaemonResult> => {
235
+ const existing = await inspectMachineMcpDaemonRecord();
236
+ if (existing?.record && existing.live) return { inspection: existing, started: false };
237
+
238
+ const resolvedPort = resolveMachineMcpDaemonPort(port);
239
+ const cliBin = path.join(coreRoot, 'cli', 'bin.js');
240
+ const args = [cliBin, 'mcp', '--daemon', '--port', String(resolvedPort)];
241
+ const child = spawn(process.execPath, args, {
242
+ cwd: coreRoot,
243
+ detached: true,
244
+ stdio: 'ignore',
245
+ });
246
+
247
+ child.unref();
248
+
249
+ for (let attempt = 0; attempt < 30; attempt += 1) {
250
+ await sleep(100);
251
+ const inspection = await inspectMachineMcpDaemonRecord({ cleanStale: false });
252
+ if (inspection?.record && inspection.live && inspection.record.pid === child.pid) {
253
+ return { inspection, started: true };
254
+ }
255
+ if (child.exitCode !== null || child.signalCode !== null) break;
256
+ }
257
+
258
+ const latest = await inspectMachineMcpDaemonRecord({ cleanStale: false });
259
+ if (latest?.record && latest.live) return { inspection: latest, started: false };
260
+
261
+ throw new Error('Proteum could not start the machine MCP daemon.');
262
+ };
263
+
264
+ export const stopMachineMcpDaemonProcess = async () => {
265
+ const inspection = await inspectMachineMcpDaemonRecord({ cleanStale: false });
266
+
267
+ if (!inspection?.record) {
268
+ if (inspection?.invalid) await fs.remove(inspection.registryFilePath);
269
+ return { inspection, stopped: true };
270
+ }
271
+
272
+ if (!inspection.live) {
273
+ await fs.remove(inspection.registryFilePath);
274
+ return { inspection, stopped: true };
275
+ }
276
+
277
+ if (canSignalProcess(inspection.record.pid, 'SIGTERM')) {
278
+ const exitedAfterTerm = await waitForProcessExit(inspection.record.pid, 5000);
279
+ if (!exitedAfterTerm && canSignalProcess(inspection.record.pid, 'SIGKILL')) {
280
+ await waitForProcessExit(inspection.record.pid, 2000);
281
+ }
282
+ }
283
+
284
+ const live = isProcessAlive(inspection.record.pid);
285
+ if (!live) await fs.remove(inspection.registryFilePath);
286
+
287
+ return { inspection, stopped: !live };
288
+ };