machine-bridge-mcp 0.11.0 → 0.12.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/src/local/cli.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
4
4
  import path, { join, resolve } from "node:path";
5
5
  import process from "node:process";
6
6
  import readline from "node:readline/promises";
7
7
  import { LocalRuntime } from "./runtime.mjs";
8
+ import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
8
9
  import { runStdioServer } from "./stdio.mjs";
9
10
  import { assertCanonicalFullPolicy, DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile, toolsForPolicy } from "./tools.mjs";
10
11
  import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
@@ -13,9 +14,12 @@ import { runWrangler } from "./shell.mjs";
13
14
  import { generateRegisteredSshKey } from "./resource-operations.mjs";
14
15
  import { runFullAccessTest } from "./full-access-test.mjs";
15
16
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
17
+ import { inspectProcessInstance } from "./process-identity.mjs";
18
+ import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
19
+ import { createExclusiveFileSync } from "./exclusive-file.mjs";
16
20
  import {
17
- acquireDaemonLock,
18
- acquireStartupLock,
21
+ acquireMaintenanceLock,
22
+ acquireStartupLockWithWait,
19
23
  appName,
20
24
  daemonLockPathForState,
21
25
  defaultStateRoot,
@@ -47,8 +51,6 @@ const BOOLEAN_OPTIONS = new Set([
47
51
  const VALUE_OPTIONS = new Set([
48
52
  "workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
49
53
  ]);
50
- const DAEMON_TAKEOVER_TIMEOUT_MS = 15_000;
51
- const DAEMON_TAKEOVER_POLL_MS = 100;
52
54
 
53
55
  const COMMAND_HANDLERS = Object.freeze({
54
56
  start: startCommand,
@@ -146,7 +148,7 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
146
148
  },
147
149
  service(args) {
148
150
  const action = String(args._[0] || "status");
149
- return { max: action === "install" ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
151
+ return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
150
152
  },
151
153
  autostart(args) {
152
154
  return ACTION_POSITIONAL_RULES.service(args);
@@ -315,16 +317,12 @@ async function startCommand(args) {
315
317
  const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "cli" });
316
318
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
317
319
  const state = loadState(workspace, { stateDir: args.stateDir });
318
- const startupLock = acquireStartupLock(state);
319
- if (!startupLock.acquired) {
320
- const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
321
- throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
322
- }
320
+ const startupLock = await acquireStartupLockWithWait(state, { operation: "start", logger });
323
321
 
324
322
  try {
325
323
  const startMode = await prepareStartMode(args, state, logger);
326
324
  const daemonLock = await acquireDaemonLockWithTakeover(state, {
327
- waitForServiceExit: startMode.waitForServiceExit,
325
+ takeOverServiceOwner: startMode.takeOverServiceOwner,
328
326
  ownerMetadata: {
329
327
  mode: args.daemonOnly ? "service" : "foreground",
330
328
  version: currentPackageVersion(),
@@ -345,43 +343,15 @@ async function prepareStartMode(args, state, logger) {
345
343
  if (args.daemonOnly) {
346
344
  const { trimAutostartLogs } = await import("./service.mjs");
347
345
  trimAutostartLogs(state.paths.stateRoot);
348
- return { waitForServiceExit: false };
346
+ return { takeOverServiceOwner: false };
349
347
  }
350
- // A normal foreground start takes over from the installed background
351
- // service. Service managers terminate asynchronously, so wait for the
352
- // existing daemon to release its workspace lock before declaring conflict.
348
+ // A normal foreground start first asks the platform service manager to
349
+ // unload the job, then independently reclaims a verified daemon-only
350
+ // process. The second step handles legacy/orphan daemons that launchd or
351
+ // another service manager no longer tracks.
353
352
  return stopAutostartBestEffort(logger);
354
353
  }
355
354
 
356
- export async function acquireDaemonLockWithTakeover(state, options = {}) {
357
- const ownerMetadata = options.ownerMetadata || {};
358
- let lock = acquireDaemonLock(state, ownerMetadata);
359
- if (lock.acquired || !options.waitForServiceExit) return lock;
360
-
361
- const timeoutMs = Number.isFinite(Number(options.timeoutMs))
362
- ? Math.max(1, Number(options.timeoutMs))
363
- : DAEMON_TAKEOVER_TIMEOUT_MS;
364
- const pollMs = Number.isFinite(Number(options.pollMs))
365
- ? Math.max(1, Number(options.pollMs))
366
- : DAEMON_TAKEOVER_POLL_MS;
367
- const logger = options.logger || { info() {} };
368
- const priorPid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "the existing process";
369
- logger.info(`waiting for the background daemon (${priorPid}) to stop before foreground startup`);
370
-
371
- const deadline = Date.now() + timeoutMs;
372
- while (Date.now() < deadline) {
373
- await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
374
- lock = acquireDaemonLock(state, ownerMetadata);
375
- if (lock.acquired) {
376
- logger.info("background daemon stopped; foreground startup is taking over the workspace");
377
- return lock;
378
- }
379
- }
380
-
381
- const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
382
- throw new Error(`background daemon did not release the workspace within ${Math.ceil(timeoutMs / 1000)} seconds (${pid}); run \`machine-mcp service stop\`, verify \`machine-mcp service status\`, and retry`);
383
- }
384
-
385
355
  function reportExistingDaemon(args, state, owner, logger) {
386
356
  const pid = owner?.pid ? `pid ${owner.pid}` : "unknown pid";
387
357
  if (isIdempotentDaemonOnlyStart(args)) {
@@ -401,9 +371,9 @@ function reportExistingDaemon(args, state, owner, logger) {
401
371
  return;
402
372
  }
403
373
  if (owner?.mode === "foreground") {
404
- logger.plain(" Stop the existing foreground process with Ctrl+C in its terminal, then retry.");
374
+ logger.safePlain(" Stop the existing foreground process with Ctrl+C in its terminal, then retry.");
405
375
  } else {
406
- logger.plain(" Run `machine-mcp service stop`, verify `machine-mcp service status`, then retry.");
376
+ logger.safePlain(" Run `machine-mcp service stop`, verify `machine-mcp service status`, then retry.");
407
377
  }
408
378
  logger.plain(` Workspace: ${state.workspace.path}`);
409
379
  }
@@ -632,12 +602,11 @@ function resourceListAction({ args, workspace, state }) {
632
602
  }
633
603
  }
634
604
 
635
- function resourceAddAction({ args, workspace, state }) {
605
+ async function resourceAddAction({ args, workspace, state }) {
636
606
  const name = validateResourceName(args._[1]);
637
607
  const inputPath = args._[2];
638
608
  if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
639
- const lock = acquireStartupLock(state);
640
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
609
+ const lock = await acquireStartupLockWithWait(state, { operation: "resource-add" });
641
610
  try {
642
611
  const latest = loadState(workspace, { stateDir: args.stateDir });
643
612
  latest.resources ||= {};
@@ -701,10 +670,9 @@ async function resourceGenerateSshKeyAction({ args, workspace }) {
701
670
  }
702
671
  }
703
672
 
704
- function resourceRemoveAction({ args, workspace, state }) {
673
+ async function resourceRemoveAction({ args, workspace, state }) {
705
674
  const name = validateResourceName(args._[1]);
706
- const lock = acquireStartupLock(state);
707
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
675
+ const lock = await acquireStartupLockWithWait(state, { operation: "resource-remove" });
708
676
  try {
709
677
  const latest = loadState(workspace, { stateDir: args.stateDir });
710
678
  latest.resources ||= {};
@@ -832,6 +800,7 @@ async function jobCommand(args) {
832
800
  policy: resolvePolicy({}, state.policy),
833
801
  resources: state.resources,
834
802
  resourceStatePath: state.paths.statePath,
803
+ stateRoot: state.paths.stateRoot,
835
804
  logger: createLogger({ level: "warn", component: "job" }),
836
805
  });
837
806
  let result;
@@ -951,7 +920,7 @@ async function withSecretsFile(state, callback) {
951
920
  DAEMON_SHARED_SECRET: state.worker.daemonSecret,
952
921
  OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
953
922
  };
954
- writeFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
923
+ createExclusiveFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
955
924
  ownerOnlyFile(tempPath);
956
925
  try {
957
926
  return await callback(tempPath);
@@ -960,7 +929,7 @@ async function withSecretsFile(state, callback) {
960
929
  }
961
930
  }
962
931
 
963
- function cleanupStaleSecretFiles(dir) {
932
+ export function cleanupStaleSecretFiles(dir) {
964
933
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
965
934
  if (!entry.isFile()) continue;
966
935
  const match = /^worker-secrets-(\d+)-(\d+)(?:-[a-f0-9]+)?\.json$/.exec(entry.name);
@@ -968,8 +937,9 @@ function cleanupStaleSecretFiles(dir) {
968
937
  const file = resolve(dir, entry.name);
969
938
  try {
970
939
  const pid = Number(match[1]);
971
- const ageMs = Date.now() - statSync(file).mtimeMs;
972
- if (!isPidAlive(pid) || ageMs > 60 * 60 * 1000) unlinkSync(file);
940
+ const createdAt = Number(match[2]);
941
+ const identity = inspectProcessInstance({ pid, startedAt: new Date(createdAt).toISOString() });
942
+ if (!identity.current) unlinkSync(file);
973
943
  } catch {}
974
944
  }
975
945
  }
@@ -1103,13 +1073,13 @@ function printMcpConnection(state, {
1103
1073
  else logger.plain(` MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
1104
1074
  } else {
1105
1075
  logger.success("Remote MCP bridge is ready");
1106
- logger.plain(" Connection credentials unchanged; use --print-mcp-credentials only when reconnecting a client.");
1076
+ logger.safePlain(" Connection credentials unchanged; use --print-mcp-credentials only when reconnecting a client.");
1107
1077
  }
1108
1078
  if (policyMigrated) {
1109
1079
  logger.warn("Legacy implicit policy migrated to full access; use --profile agent, edit, or review to narrow it.");
1110
1080
  }
1111
1081
  logger.plain(` Workspace: ${payload.workspace}`);
1112
- logger.plain(` Policy: ${formatPolicySummary(payload.policy)}`);
1082
+ logger.safePlain(` Policy: ${formatPolicySummary(payload.policy)}`);
1113
1083
  if (verbose) logger.plain(` State: ${payload.state_path}`);
1114
1084
  }
1115
1085
 
@@ -1215,16 +1185,19 @@ async function fullTestCommand(args) {
1215
1185
  async function rotateSecretsCommand(args) {
1216
1186
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
1217
1187
  const state = loadState(workspace, { stateDir: args.stateDir });
1218
- const startupLock = acquireStartupLock(state);
1219
- if (!startupLock.acquired) {
1220
- const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
1221
- throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
1222
- }
1188
+ const operationLogger = createLogger({ level: args.quiet ? "error" : "warn", component: "service" });
1189
+ const startupLock = await acquireStartupLockWithWait(state, { operation: "rotate-secrets", logger: operationLogger });
1223
1190
  try {
1224
- await stopAutostartBestEffort(createLogger({ level: args.quiet ? "error" : "warn", component: "service" }));
1225
- await sleep(500);
1191
+ await stopAutostartBestEffort(operationLogger);
1192
+ const stopped = await stopWorkspaceServiceDaemon(state, { logger: operationLogger, reason: "secret rotation" });
1193
+ if (stopped.found && !stopped.ok) {
1194
+ const pid = stopped.pid ? `pid ${stopped.pid}` : "unknown pid";
1195
+ throw new Error(`refusing to rotate secrets while a daemon cannot be safely stopped (${pid}; ${stopped.reason})`);
1196
+ }
1197
+ await sleep(100);
1226
1198
  const daemonOwner = readDaemonLockOwner(daemonLockPathForState(state));
1227
- if (daemonOwner?.pid && isPidAlive(daemonOwner.pid)) {
1199
+ const daemonIdentity = daemonOwner ? inspectProcessInstance(daemonOwner) : null;
1200
+ if (daemonIdentity?.current) {
1228
1201
  throw new Error(`refusing to rotate secrets while the daemon is active (pid ${daemonOwner.pid}); stop the foreground daemon and retry`);
1229
1202
  }
1230
1203
  ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
@@ -1244,7 +1217,15 @@ async function serviceCommand(args) {
1244
1217
  const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
1245
1218
  if (action === "status") {
1246
1219
  const status = await autostartStatus({ logger: structuredLogger(Boolean(args.quiet)) });
1247
- console.log(JSON.stringify(status, null, 2));
1220
+ const state = optionalServiceState(args, stateRoot);
1221
+ const workspaceDaemon = state ? inspectWorkspaceDaemon(state) : null;
1222
+ console.log(JSON.stringify({
1223
+ ...status,
1224
+ workspace: state?.workspace?.path || null,
1225
+ workspace_daemon: workspaceDaemon,
1226
+ effective_active: Boolean(status.active || workspaceDaemon?.alive),
1227
+ orphaned_workspace_daemon: Boolean(!status.active && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
1228
+ }, null, 2));
1248
1229
  return;
1249
1230
  }
1250
1231
  if (action === "install") {
@@ -1256,26 +1237,63 @@ async function serviceCommand(args) {
1256
1237
  }
1257
1238
  const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], logger: structuredLogger(Boolean(args.quiet)) });
1258
1239
  console.log(JSON.stringify(result, null, 2));
1240
+ if (result?.ok === false) process.exitCode = 1;
1259
1241
  return;
1260
1242
  }
1261
1243
  if (action === "start") {
1262
1244
  const result = await startAutostart({ logger: structuredLogger(Boolean(args.quiet)) });
1263
1245
  console.log(JSON.stringify(result, null, 2));
1246
+ if (result?.ok === false) process.exitCode = 1;
1264
1247
  return;
1265
1248
  }
1266
1249
  if (action === "stop") {
1267
- const result = await stopAutostart({ logger: structuredLogger(Boolean(args.quiet)) });
1250
+ const logger = structuredLogger(Boolean(args.quiet));
1251
+ const provider = await stopAutostart({ logger });
1252
+ const state = optionalServiceState(args, stateRoot);
1253
+ const workspaceDaemon = state
1254
+ ? await stopWorkspaceServiceDaemon(state, { logger, reason: "service stop" })
1255
+ : { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
1256
+ const result = {
1257
+ ...provider,
1258
+ ok: provider?.ok !== false && workspaceDaemon.ok,
1259
+ workspace: state?.workspace?.path || null,
1260
+ workspace_daemon: workspaceDaemon,
1261
+ };
1268
1262
  console.log(JSON.stringify(result, null, 2));
1263
+ if (!result.ok) process.exitCode = 1;
1269
1264
  return;
1270
1265
  }
1271
1266
  if (action === "uninstall" || action === "remove") {
1272
- const result = await uninstallAutostart({ stateRoot, logger: structuredLogger(Boolean(args.quiet)) });
1273
- console.log(JSON.stringify(result, null, 2));
1267
+ const logger = structuredLogger(Boolean(args.quiet));
1268
+ const state = optionalServiceState(args, stateRoot);
1269
+ const lifecycle = await stopAndRemoveAutostart({
1270
+ states: state ? [state] : [],
1271
+ stateRoot,
1272
+ logger,
1273
+ reason: "service uninstall",
1274
+ stopAutostart,
1275
+ uninstallAutostart,
1276
+ stopWorkspaceServiceDaemon,
1277
+ });
1278
+ const output = {
1279
+ ...lifecycle,
1280
+ workspace: state?.workspace?.path || null,
1281
+ workspace_daemon: lifecycle.workspace_daemons[0] || null,
1282
+ autostart_removed: lifecycle.removed,
1283
+ };
1284
+ console.log(JSON.stringify(output, null, 2));
1285
+ if (!output.ok) process.exitCode = 1;
1274
1286
  return;
1275
1287
  }
1276
1288
  throw new Error(`Unknown service action: ${action}`);
1277
1289
  }
1278
1290
 
1291
+ function optionalServiceState(args, stateRoot) {
1292
+ const requested = args.workspace || args._[1] || selectedWorkspace(stateRoot);
1293
+ if (!requested || requested === true) return null;
1294
+ return loadState(resolveWorkspace(String(requested)), { stateDir: stateRoot });
1295
+ }
1296
+
1279
1297
  async function installAutostartBestEffort({ workspace, stateRoot, entryScript, logger }) {
1280
1298
  try {
1281
1299
  const { installAutostart } = await import("./service.mjs");
@@ -1288,30 +1306,25 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, l
1288
1306
  }
1289
1307
 
1290
1308
  async function stopAutostartBestEffort(logger) {
1309
+ let result = null;
1291
1310
  try {
1292
- const { autostartStatus, stopAutostart } = await import("./service.mjs");
1293
- let status = null;
1294
- try {
1295
- status = await autostartStatus({ logger: structuredLogger(true) });
1296
- } catch (error) {
1297
- logger.debug?.("Autostart status check failed before takeover", { error_class: classifyOperationalError(error) });
1298
- }
1299
- if (status?.active === false) return { waitForServiceExit: false };
1300
-
1301
- const result = await stopAutostart({ logger: structuredLogger(true) });
1302
- const stopSucceeded = result?.code === 0 || result?.ok === true;
1303
- if (status?.active && stopSucceeded) logger.info("stopping the background service before foreground startup");
1304
- return { waitForServiceExit: Boolean(stopSucceeded && status?.active !== false) };
1311
+ const { stopAutostart } = await import("./service.mjs");
1312
+ result = await stopAutostart({ logger: structuredLogger(true) });
1305
1313
  } catch (error) {
1306
- logger.warn("Autostart stop skipped", { error_class: classifyOperationalError(error) });
1307
- return { waitForServiceExit: false };
1314
+ logger.warn("Autostart stop command was unavailable; checking the workspace daemon directly", { error_class: classifyOperationalError(error) });
1315
+ return { takeOverServiceOwner: true, provider: null };
1308
1316
  }
1317
+ if (result?.active_before && result?.ok) logger.info("stopping the background service before foreground startup");
1318
+ if (result?.ok === false && result?.active === true) {
1319
+ throw new Error("the background service is still active after the stop request; run `machine-mcp service status` for details");
1320
+ }
1321
+ return { takeOverServiceOwner: true, provider: result };
1309
1322
  }
1310
1323
 
1311
1324
  async function uninstallCommand(args) {
1312
1325
  const stateRoot = stateRootFromArgs(args);
1313
1326
  const deleteRemote = !args.keepWorker;
1314
- validateStateRootForRemoval(stateRoot);
1327
+ const validation = validateStateRootForRemoval(stateRoot);
1315
1328
  const action = deleteRemote
1316
1329
  ? `delete deployed Worker(s), remove autostart entries, and remove local state at ${stateRoot}`
1317
1330
  : `remove autostart entries and local state at ${stateRoot} while keeping deployed Worker(s)`;
@@ -1320,26 +1333,46 @@ async function uninstallCommand(args) {
1320
1333
  console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
1321
1334
  return;
1322
1335
  }
1336
+ const currentValidation = validateStateRootForRemoval(stateRoot);
1337
+ const maintenance = currentValidation.exists ? acquireMaintenanceLock(stateRoot, { operation: "uninstall" }) : null;
1338
+ if (maintenance && !maintenance.acquired) {
1339
+ const pid = maintenance.owner?.pid ? `pid ${maintenance.owner.pid}` : "another process";
1340
+ throw new Error(`another state maintenance operation is active (${pid})`);
1341
+ }
1342
+ try {
1343
+ if (currentValidation.exists) validateStateRootForRemoval(stateRoot);
1344
+ assertNoActiveJobsForUninstall(stateRoot);
1345
+ const autostartRemoved = await removeAutostartBestEffort(stateRoot);
1346
+ if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
1347
+ await sleep(100);
1348
+ assertNoActiveJobsForUninstall(stateRoot);
1349
+ assertNoActiveLocksForUninstall(stateRoot);
1350
+ if (deleteRemote) await deleteKnownWorkers(stateRoot);
1351
+ assertNoActiveJobsForUninstall(stateRoot);
1352
+ assertNoActiveLocksForUninstall(stateRoot);
1353
+ removeStateRoot(stateRoot);
1354
+ console.log("Removed local autostart entries and state.");
1355
+ if (deleteRemote) console.log("Requested deletion for known deployed Worker(s).");
1356
+ console.log("If installed globally, remove the npm package with:");
1357
+ console.log(" npm uninstall -g machine-bridge-mcp");
1358
+ } finally {
1359
+ maintenance?.release?.();
1360
+ }
1361
+ }
1362
+
1363
+ function assertNoActiveJobsForUninstall(stateRoot) {
1323
1364
  const activeJobs = activeStateJobs(stateRoot);
1324
- if (activeJobs.length) {
1325
- const detail = activeJobs.slice(0, 5).map((item) => `${item.job_id}:${item.status}`).join(", ");
1326
- const suffix = activeJobs.length > 5 ? `, and ${activeJobs.length - 5} more` : "";
1327
- throw new Error(`refusing to uninstall while managed jobs are active (${detail}${suffix}); inspect or cancel them with machine-mcp job list/cancel`);
1328
- }
1329
- const autostartRemoved = await removeAutostartBestEffort(stateRoot);
1330
- if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
1331
- await sleep(500);
1365
+ if (!activeJobs.length) return;
1366
+ const detail = activeJobs.slice(0, 5).map((item) => `${item.job_id}:${item.status}`).join(", ");
1367
+ const suffix = activeJobs.length > 5 ? `, and ${activeJobs.length - 5} more` : "";
1368
+ throw new Error(`refusing to uninstall while managed jobs are active (${detail}${suffix}); inspect or cancel them with machine-mcp job list/cancel`);
1369
+ }
1370
+
1371
+ function assertNoActiveLocksForUninstall(stateRoot) {
1332
1372
  const activeLocks = activeStateLocks(stateRoot);
1333
- if (activeLocks.length) {
1334
- const detail = activeLocks.map((item) => `${item.kind}:${item.pid || "unknown"}`).join(", ");
1335
- throw new Error(`refusing to uninstall while Machine Bridge processes are active (${detail}); stop foreground sessions and retry`);
1336
- }
1337
- if (deleteRemote) await deleteKnownWorkers(stateRoot);
1338
- removeStateRoot(stateRoot);
1339
- console.log("Removed local autostart entries and state.");
1340
- if (deleteRemote) console.log("Requested deletion for known deployed Worker(s).");
1341
- console.log("If installed globally, remove the npm package with:");
1342
- console.log(" npm uninstall -g machine-bridge-mcp");
1373
+ if (!activeLocks.length) return;
1374
+ const detail = activeLocks.map((item) => `${item.kind}:${item.pid || "unknown"}`).join(", ");
1375
+ throw new Error(`refusing to uninstall while Machine Bridge processes are active (${detail}); stop foreground sessions and retry`);
1343
1376
  }
1344
1377
 
1345
1378
  async function deleteKnownWorkers(stateRoot) {
@@ -1363,31 +1396,52 @@ async function deleteKnownWorkers(stateRoot) {
1363
1396
  }
1364
1397
  }
1365
1398
 
1366
- function knownWorkerNames(stateRoot) {
1399
+ export function knownWorkerNames(stateRoot) {
1367
1400
  const profiles = resolve(expandHome(stateRoot), "profiles");
1368
1401
  if (!existsSync(profiles)) return [];
1369
1402
  const names = new Set();
1370
1403
  for (const entry of readdirSync(profiles, { withFileTypes: true })) {
1371
- if (!entry.isDirectory()) continue;
1372
- const stateFile = resolve(profiles, entry.name, "state.json");
1373
- if (!existsSync(stateFile)) continue;
1404
+ if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
1405
+ const profileDir = resolve(profiles, entry.name);
1406
+ const stateFile = resolve(profileDir, "state.json");
1407
+ if (!existsSync(stateFile)) {
1408
+ const evidence = readdirSync(profileDir).some((name) => /^state\.json\.corrupt-/.test(name) || name === "daemon.lock");
1409
+ if (evidence) throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
1410
+ continue;
1411
+ }
1412
+ let state;
1374
1413
  try {
1375
- if (statSync(stateFile).size > 2 * 1024 * 1024) continue;
1376
- const state = JSON.parse(readFileSync(stateFile, "utf8"));
1377
- const name = String(state?.worker?.name || "");
1378
- if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) names.add(name);
1379
- } catch {}
1414
+ state = JSON.parse(readBoundedRegularFileSync(stateFile, 2 * 1024 * 1024).toString("utf8"));
1415
+ } catch {
1416
+ throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
1417
+ }
1418
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
1419
+ throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
1420
+ }
1421
+ const name = String(state?.worker?.name || "");
1422
+ if (name && !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
1423
+ throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
1424
+ }
1425
+ if (name) names.add(name);
1380
1426
  }
1381
1427
  return [...names];
1382
1428
  }
1383
1429
 
1384
1430
  async function removeAutostartBestEffort(stateRoot) {
1431
+ const logger = structuredLogger(false);
1385
1432
  try {
1386
1433
  const { stopAutostart, uninstallAutostart } = await import("./service.mjs");
1387
- await stopAutostart({ logger: structuredLogger(true) }).catch(() => {});
1388
- const result = await uninstallAutostart({ stateRoot, logger: structuredLogger(false) });
1389
- if (result?.ok === false) {
1390
- console.warn("Autostart removal reported failure.");
1434
+ const lifecycle = await stopAndRemoveAutostart({
1435
+ states: knownProfileStates(stateRoot),
1436
+ stateRoot,
1437
+ logger,
1438
+ reason: "uninstall",
1439
+ stopAutostart,
1440
+ uninstallAutostart,
1441
+ stopWorkspaceServiceDaemon,
1442
+ });
1443
+ if (!lifecycle.ok) {
1444
+ console.warn(`Autostart removal stopped at ${lifecycle.reason}; service definitions and state were kept.`);
1391
1445
  return false;
1392
1446
  }
1393
1447
  return true;
@@ -1397,6 +1451,46 @@ async function removeAutostartBestEffort(stateRoot) {
1397
1451
  }
1398
1452
  }
1399
1453
 
1454
+ export function knownProfileStates(stateRoot) {
1455
+ const canonicalStateRoot = resolve(expandHome(stateRoot));
1456
+ const profiles = resolve(canonicalStateRoot, "profiles");
1457
+ if (!existsSync(profiles)) return [];
1458
+ const states = [];
1459
+ const seen = new Set();
1460
+ for (const entry of readdirSync(profiles, { withFileTypes: true })) {
1461
+ if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
1462
+ const profileDir = resolve(profiles, entry.name);
1463
+ const statePath = resolve(profileDir, "state.json");
1464
+ const candidates = [];
1465
+ if (existsSync(statePath)) {
1466
+ try {
1467
+ const value = JSON.parse(readBoundedRegularFileSync(statePath, 2 * 1024 * 1024).toString("utf8"));
1468
+ if (typeof value?.workspace?.path === "string") candidates.push(value.workspace.path);
1469
+ } catch {}
1470
+ }
1471
+ const daemonLock = resolve(profileDir, "daemon.lock");
1472
+ const daemonOwner = readDaemonLockOwner(daemonLock);
1473
+ if (existsSync(daemonLock) && !daemonOwner) {
1474
+ throw new Error(`cannot inspect daemon lock for profile ${entry.name}; service definitions and state were kept`);
1475
+ }
1476
+ if (typeof daemonOwner?.workspace === "string") candidates.push(daemonOwner.workspace);
1477
+ for (const candidate of candidates) {
1478
+ try {
1479
+ const workspace = resolveWorkspace(candidate);
1480
+ if (seen.has(workspace)) break;
1481
+ states.push({
1482
+ schemaVersion: 5,
1483
+ workspace: { path: workspace, hash: entry.name },
1484
+ paths: { stateRoot: canonicalStateRoot, profileDir, statePath },
1485
+ });
1486
+ seen.add(workspace);
1487
+ break;
1488
+ } catch {}
1489
+ }
1490
+ }
1491
+ return states;
1492
+ }
1493
+
1400
1494
  function activeStateJobs(stateRoot) {
1401
1495
  const profiles = resolve(expandHome(stateRoot), "profiles");
1402
1496
  if (!existsSync(profiles)) return [];
@@ -1420,23 +1514,17 @@ function activeStateLocks(stateRoot) {
1420
1514
  const lockPath = resolve(profiles, profile.name, name);
1421
1515
  if (!existsSync(lockPath)) continue;
1422
1516
  const owner = readDaemonLockOwner(lockPath);
1423
- if (owner?.pid && isPidAlive(owner.pid)) active.push({ kind, pid: owner.pid, path: lockPath });
1517
+ if (!owner) {
1518
+ active.push({ kind, pid: null, path: lockPath, reason: "invalid_or_unreadable_lock" });
1519
+ continue;
1520
+ }
1521
+ const identity = inspectProcessInstance(owner, { maxAgeMs: kind === "startup" ? 2 * 60 * 60 * 1000 : Number.POSITIVE_INFINITY });
1522
+ if (identity.current || (identity.alive && !identity.reclaimable)) active.push({ kind, pid: owner.pid, path: lockPath, reason: identity.reason });
1424
1523
  }
1425
1524
  }
1426
1525
  return active;
1427
1526
  }
1428
1527
 
1429
- function isPidAlive(pid) {
1430
- const parsed = Number(pid);
1431
- if (!Number.isInteger(parsed) || parsed <= 0) return false;
1432
- try {
1433
- process.kill(parsed, 0);
1434
- return true;
1435
- } catch (error) {
1436
- return error?.code === "EPERM";
1437
- }
1438
- }
1439
-
1440
1528
  function structuredLogger(quiet) {
1441
1529
  return createLogger({ quiet, component: "service" });
1442
1530
  }