machine-bridge-mcp 0.10.0 → 0.11.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.
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { createBuiltinInstruction, discoverAutomaticProjectInstruction } from "./default-instructions.mjs";
5
6
 
6
7
  const CONFIG_RELATIVE_PATH = join(".machine-bridge", "agent.json");
7
8
  const GLOBAL_CONFIG_RELATIVE_PATH = join(".config", "machine-bridge-mcp", "agent.json");
@@ -23,7 +24,7 @@ const MAX_COMMAND_ARGV = 128;
23
24
  const MAX_COMMAND_ARGUMENT_BYTES = 256 * 1024;
24
25
  const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
25
26
  const TOKEN_STOP_WORDS = new Set(["the", "and", "for", "with", "from", "this", "that", "use", "using", "into", "of", "to", "a", "an", "in", "on", "is", "are", "or", "及", "和", "的", "了", "在", "用", "使用", "进行", "根据"]);
26
- const CONFIG_KEYS = new Set(["version", "model_instructions_file", "instruction_files", "instruction_max_bytes", "skill_roots", "commands"]);
27
+ const CONFIG_KEYS = new Set(["version", "builtin_instructions", "automatic_project_context", "model_instructions_file", "instruction_files", "instruction_max_bytes", "skill_roots", "commands"]);
27
28
  const COMMAND_KEYS = new Set(["description", "argv", "cwd", "timeout_seconds", "allow_extra_args"]);
28
29
 
29
30
  export class AgentContextManager {
@@ -46,8 +47,10 @@ export class AgentContextManager {
46
47
  const result = {
47
48
  target: this.displayPath(state.target),
48
49
  scope_root: this.displayPath(state.scopeRoot),
49
- precedence: "global guidance first, then scope root to target directory; each directory contributes the first non-empty instruction_files candidate; later directories have higher precedence",
50
+ precedence: "built-in defaults, automatic project facts, user-global guidance, then scope root to target directory; explicit files loaded later have higher precedence",
50
51
  config_files: state.configFiles.map((file) => this.displayPath(file)),
52
+ builtin_instructions: publicVirtualInstruction(state.builtinInstructions, includeContent),
53
+ automatic_project_context: publicVirtualInstruction(state.automaticProjectContext, includeContent),
51
54
  model_instructions_file: state.modelInstructions ? {
52
55
  path: this.displayPath(state.modelInstructions.path),
53
56
  bytes: state.modelInstructions.bytes,
@@ -68,12 +71,13 @@ export class AgentContextManager {
68
71
  instructions_truncated: state.instructionsTruncated,
69
72
  commands: publicCommands(state.commands, this.displayPath),
70
73
  guidance: [
74
+ "Apply built-in instructions and automatic project context as lower-precedence defaults; explicit global/project instruction files loaded later take precedence.",
71
75
  "Treat instruction_files as authoritative workspace guidance in the returned precedence order.",
72
76
  "Load a relevant skill with load_local_skill before following its workflow; SKILL.md is an instruction bundle, not executable code by itself.",
73
77
  "Prefer run_local_command for registered repeatable commands. Use run_process or exec_command only when no registered command fits and policy permits it.",
74
78
  ],
75
79
  };
76
- if (includeContent) result.effective_instructions = renderEffectiveInstructions([...(state.modelInstructions ? [state.modelInstructions] : []), ...state.instructions], this.displayPath);
80
+ if (includeContent) result.effective_instructions = renderEffectiveInstructions(effectiveInstructionItems(state), this.displayPath);
77
81
  return result;
78
82
  }
79
83
 
@@ -83,7 +87,9 @@ export class AgentContextManager {
83
87
  const fingerprint = capabilityFingerprint(state, []);
84
88
  return {
85
89
  target: this.displayPath(state.target),
86
- instructions: renderEffectiveInstructions([...(state.modelInstructions ? [state.modelInstructions] : []), ...state.instructions], this.displayPath),
90
+ instructions: renderEffectiveInstructions(effectiveInstructionItems(state), this.displayPath),
91
+ builtin_instructions: publicVirtualInstruction(state.builtinInstructions, false),
92
+ automatic_project_context: publicVirtualInstruction(state.automaticProjectContext, false),
87
93
  model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path) : null,
88
94
  capability_refresh: {
89
95
  strategy: "resolve_task_capabilities-rescans-on-every-call",
@@ -92,6 +98,7 @@ export class AgentContextManager {
92
98
  generated_at: new Date().toISOString(),
93
99
  },
94
100
  guidance: [
101
+ "Built-in working agreements and bounded automatic project facts are present by default unless disabled in the user-global agent config.",
95
102
  "Call resolve_task_capabilities with the current user task before substantive local work or at the start of a reused-host conversation.",
96
103
  "The resolver returns the effective instructions again and rescans skill and command metadata on every call; the runtime supplements application and browser capability metadata.",
97
104
  ],
@@ -125,7 +132,9 @@ export class AgentContextManager {
125
132
  return {
126
133
  task,
127
134
  target: this.displayPath(state.target),
128
- effective_instructions: renderEffectiveInstructions([...(state.modelInstructions ? [state.modelInstructions] : []), ...state.instructions], this.displayPath),
135
+ effective_instructions: renderEffectiveInstructions(effectiveInstructionItems(state), this.displayPath),
136
+ builtin_instructions: publicVirtualInstruction(state.builtinInstructions, false),
137
+ automatic_project_context: publicVirtualInstruction(state.automaticProjectContext, false),
129
138
  model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path) : null,
130
139
  instruction_files: state.instructions.map((item) => ({ path: this.displayPath(item.path), scope: item.scope, bytes: item.bytes, sha256: item.sha256, precedence: item.precedence })),
131
140
  instructions_truncated: state.instructionsTruncated,
@@ -232,6 +241,10 @@ export class AgentContextManager {
232
241
  instructionMaxBytes: DEFAULT_INSTRUCTION_MAX_BYTES,
233
242
  skillRoots: defaultSkillRoots(directories, this.home, this.policy.unrestrictedPaths === true),
234
243
  commands: new Map(),
244
+ builtinInstructionsEnabled: true,
245
+ automaticProjectContextEnabled: true,
246
+ builtinInstructions: null,
247
+ automaticProjectContext: null,
235
248
  modelInstructionsFile: "",
236
249
  modelInstructions: null,
237
250
  configFiles: [],
@@ -247,9 +260,22 @@ export class AgentContextManager {
247
260
  if (this.policy.unrestrictedPaths === true) this.applyConfig(state, config, globalConfig, this.home, { global: true });
248
261
  else {
249
262
  state.configFiles.push(globalConfig);
263
+ if (config.builtinInstructions !== null) state.builtinInstructionsEnabled = config.builtinInstructions;
264
+ if (config.automaticProjectContext !== null) state.automaticProjectContextEnabled = config.automaticProjectContext;
250
265
  if (config.modelInstructionsFile) state.modelInstructionsFile = resolveConfiguredPath(config.modelInstructionsFile, this.home, this.home, this.workspace, true);
251
266
  }
252
267
  }
268
+ }
269
+
270
+ state.builtinInstructions = createBuiltinInstruction(state.builtinInstructionsEnabled);
271
+ state.automaticProjectContext = await discoverAutomaticProjectInstruction({
272
+ scopeRoot,
273
+ targetDir,
274
+ enabled: state.automaticProjectContextEnabled,
275
+ throwIfCancelled: () => this.throwIfCancelled(context),
276
+ });
277
+
278
+ if (this.home) {
253
279
  if (state.modelInstructionsFile) await this.collectModelInstructions(state, context);
254
280
  if (this.policy.unrestrictedPaths === true && this.codexHome) await this.collectDirectoryInstruction(state, this.codexHome, context, "global");
255
281
  }
@@ -266,6 +292,14 @@ export class AgentContextManager {
266
292
 
267
293
  applyConfig(state, config, configPath, baseDir, { global = false } = {}) {
268
294
  state.configFiles.push(configPath);
295
+ if (config.builtinInstructions !== null) {
296
+ if (!global) throw new Error(`builtin_instructions is only allowed in the global agent config: ${configPath}`);
297
+ state.builtinInstructionsEnabled = config.builtinInstructions;
298
+ }
299
+ if (config.automaticProjectContext !== null) {
300
+ if (!global) throw new Error(`automatic_project_context is only allowed in the global agent config: ${configPath}`);
301
+ state.automaticProjectContextEnabled = config.automaticProjectContext;
302
+ }
269
303
  if (config.modelInstructionsFile) {
270
304
  if (!global) throw new Error(`model_instructions_file is only allowed in the global agent config: ${configPath}`);
271
305
  state.modelInstructionsFile = resolveConfiguredPath(config.modelInstructionsFile, baseDir, this.home, this.workspace, true);
@@ -480,7 +514,23 @@ function normalizeConfig(value, configPath) {
480
514
  if (!isPlainRecord(value)) throw new Error(`agent config must be a JSON object: ${configPath}`);
481
515
  for (const key of Object.keys(value)) if (!CONFIG_KEYS.has(key)) throw new Error(`unknown agent config field '${key}': ${configPath}`);
482
516
  if (value.version !== 1) throw new Error(`agent config version must be 1: ${configPath}`);
483
- const result = { modelInstructionsFile: null, instructionFiles: null, instructionMaxBytes: null, skillRoots: null, commands: new Map() };
517
+ const result = {
518
+ builtinInstructions: null,
519
+ automaticProjectContext: null,
520
+ modelInstructionsFile: null,
521
+ instructionFiles: null,
522
+ instructionMaxBytes: null,
523
+ skillRoots: null,
524
+ commands: new Map(),
525
+ };
526
+ if (value.builtin_instructions !== undefined) {
527
+ if (typeof value.builtin_instructions !== "boolean") throw new Error(`builtin_instructions must be boolean: ${configPath}`);
528
+ result.builtinInstructions = value.builtin_instructions;
529
+ }
530
+ if (value.automatic_project_context !== undefined) {
531
+ if (typeof value.automatic_project_context !== "boolean") throw new Error(`automatic_project_context must be boolean: ${configPath}`);
532
+ result.automaticProjectContext = value.automatic_project_context;
533
+ }
484
534
  if (value.model_instructions_file !== undefined) {
485
535
  result.modelInstructionsFile = requiredString(value.model_instructions_file, "model_instructions_file");
486
536
  }
@@ -657,7 +707,12 @@ async function listSkillFiles(root, maxFiles, context, throwIfCancelled) {
657
707
  function capabilityFingerprint(state, skills) {
658
708
  return sha256(JSON.stringify({
659
709
  configs: state.configFiles,
660
- instructions: [state.modelInstructions?.sha256 || "", ...state.instructions.map((item) => item.sha256)],
710
+ instructions: [
711
+ state.builtinInstructions?.sha256 || "",
712
+ state.automaticProjectContext?.sha256 || "",
713
+ state.modelInstructions?.sha256 || "",
714
+ ...state.instructions.map((item) => item.sha256),
715
+ ],
661
716
  skills: skills.map((skill) => [skill.id, skill.sha256]),
662
717
  commands: [...state.commands.values()].map((command) => [command.name, command.argv]),
663
718
  }));
@@ -763,12 +818,36 @@ function publicCommands(commands, displayPath) {
763
818
  }));
764
819
  }
765
820
 
821
+ function effectiveInstructionItems(state) {
822
+ return [
823
+ ...(state.builtinInstructions ? [state.builtinInstructions] : []),
824
+ ...(state.automaticProjectContext ? [state.automaticProjectContext] : []),
825
+ ...(state.modelInstructions ? [state.modelInstructions] : []),
826
+ ...state.instructions,
827
+ ];
828
+ }
829
+
830
+ function publicVirtualInstruction(item, includeContent) {
831
+ if (!item) return null;
832
+ return {
833
+ source: item.source,
834
+ scope: item.scope,
835
+ bytes: item.bytes,
836
+ sha256: item.sha256,
837
+ precedence: item.precedence,
838
+ ...(includeContent ? { content: item.content } : {}),
839
+ };
840
+ }
841
+
766
842
  function renderEffectiveInstructions(instructions, displayPath) {
767
- return instructions.map((item) => [
768
- `--- BEGIN ${displayPath(item.path)} (precedence ${item.precedence}) ---`,
769
- item.content,
770
- `--- END ${displayPath(item.path)} ---`,
771
- ].join("\n")).join("\n\n");
843
+ return instructions.map((item) => {
844
+ const source = item.source || displayPath(item.path);
845
+ return [
846
+ `--- BEGIN ${source} (precedence ${item.precedence}) ---`,
847
+ item.content,
848
+ `--- END ${source} ---`,
849
+ ].join("\n");
850
+ }).join("\n\n");
772
851
  }
773
852
 
774
853
  async function readOptionalRegularUtf8(filePath, maxBytes, label) {
package/src/local/cli.mjs CHANGED
@@ -5,6 +5,7 @@ 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";
@@ -144,7 +145,7 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
144
145
  },
145
146
  service(args) {
146
147
  const action = String(args._[0] || "status");
147
- return { max: action === "install" ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
148
+ return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
148
149
  },
149
150
  autostart(args) {
150
151
  return ACTION_POSITIONAL_RULES.service(args);
@@ -320,8 +321,15 @@ async function startCommand(args) {
320
321
  }
321
322
 
322
323
  try {
323
- await prepareStartMode(args, state, logger);
324
- const daemonLock = acquireDaemonLock(state);
324
+ const startMode = await prepareStartMode(args, state, logger);
325
+ const daemonLock = await acquireDaemonLockWithTakeover(state, {
326
+ takeOverServiceOwner: startMode.takeOverServiceOwner,
327
+ ownerMetadata: {
328
+ mode: args.daemonOnly ? "service" : "foreground",
329
+ version: currentPackageVersion(),
330
+ },
331
+ logger,
332
+ });
325
333
  if (!daemonLock.acquired) {
326
334
  reportExistingDaemon(args, state, daemonLock.owner, logger);
327
335
  return;
@@ -336,11 +344,13 @@ async function prepareStartMode(args, state, logger) {
336
344
  if (args.daemonOnly) {
337
345
  const { trimAutostartLogs } = await import("./service.mjs");
338
346
  trimAutostartLogs(state.paths.stateRoot);
339
- return;
347
+ return { takeOverServiceOwner: false };
340
348
  }
341
- // Stop an installed service before acquiring the runtime lock. If a
342
- // foreground daemon owns the lock, no new policy or secret state is saved.
343
- await stopAutostartBestEffort(logger);
349
+ // A normal foreground start first asks the platform service manager to
350
+ // unload the job, then independently reclaims a verified daemon-only
351
+ // process. The second step handles legacy/orphan daemons that launchd or
352
+ // another service manager no longer tracks.
353
+ return stopAutostartBestEffort(logger);
344
354
  }
345
355
 
346
356
  function reportExistingDaemon(args, state, owner, logger) {
@@ -349,21 +359,24 @@ function reportExistingDaemon(args, state, owner, logger) {
349
359
  logger.debug?.("local daemon already running; daemon-only start completed as an idempotent no-op", { owner_pid_known: Boolean(owner?.pid) });
350
360
  return;
351
361
  }
352
- logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
362
+ const mode = owner?.mode === "foreground" ? "foreground" : owner?.mode === "service" ? "background service" : "local";
363
+ const version = owner?.version ? `, version ${owner.version}` : "";
364
+ const notice = `${mode} daemon already running for this workspace (${pid}${version}); it was not restarted and requested changes were not applied`;
365
+ logger.warn(notice);
353
366
  if (args.json) {
354
367
  printStartJson(state, {
355
368
  showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
356
369
  requestedChangesApplied: false,
357
- notice: "local daemon already running; requested changes were not applied",
370
+ notice,
358
371
  });
359
372
  return;
360
373
  }
361
- printMcpConnection(state, {
362
- noPrintCredentials: Boolean(args.noPrintCredentials),
363
- includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
364
- quiet: Boolean(args.quiet),
365
- verbose: Boolean(args.verbose),
366
- });
374
+ if (owner?.mode === "foreground") {
375
+ logger.plain(" Stop the existing foreground process with Ctrl+C in its terminal, then retry.");
376
+ } else {
377
+ logger.plain(" Run `machine-mcp service stop`, verify `machine-mcp service status`, then retry.");
378
+ }
379
+ logger.plain(` Workspace: ${state.workspace.path}`);
367
380
  }
368
381
 
369
382
  function isIdempotentDaemonOnlyStart(args) {
@@ -1202,7 +1215,15 @@ async function serviceCommand(args) {
1202
1215
  const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
1203
1216
  if (action === "status") {
1204
1217
  const status = await autostartStatus({ logger: structuredLogger(Boolean(args.quiet)) });
1205
- console.log(JSON.stringify(status, null, 2));
1218
+ const state = optionalServiceState(args, stateRoot);
1219
+ const workspaceDaemon = state ? inspectWorkspaceDaemon(state) : null;
1220
+ console.log(JSON.stringify({
1221
+ ...status,
1222
+ workspace: state?.workspace?.path || null,
1223
+ workspace_daemon: workspaceDaemon,
1224
+ effective_active: Boolean(status.active || workspaceDaemon?.alive),
1225
+ orphaned_workspace_daemon: Boolean(!status.active && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
1226
+ }, null, 2));
1206
1227
  return;
1207
1228
  }
1208
1229
  if (action === "install") {
@@ -1222,18 +1243,48 @@ async function serviceCommand(args) {
1222
1243
  return;
1223
1244
  }
1224
1245
  if (action === "stop") {
1225
- const result = await stopAutostart({ logger: structuredLogger(Boolean(args.quiet)) });
1246
+ const logger = structuredLogger(Boolean(args.quiet));
1247
+ const provider = await stopAutostart({ logger });
1248
+ const state = optionalServiceState(args, stateRoot);
1249
+ const workspaceDaemon = state
1250
+ ? await stopWorkspaceServiceDaemon(state, { logger, reason: "service stop" })
1251
+ : { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
1252
+ const result = {
1253
+ ...provider,
1254
+ ok: provider?.ok !== false && workspaceDaemon.ok,
1255
+ workspace: state?.workspace?.path || null,
1256
+ workspace_daemon: workspaceDaemon,
1257
+ };
1226
1258
  console.log(JSON.stringify(result, null, 2));
1259
+ if (!result.ok) process.exitCode = 1;
1227
1260
  return;
1228
1261
  }
1229
1262
  if (action === "uninstall" || action === "remove") {
1230
- const result = await uninstallAutostart({ stateRoot, logger: structuredLogger(Boolean(args.quiet)) });
1231
- console.log(JSON.stringify(result, null, 2));
1263
+ const logger = structuredLogger(Boolean(args.quiet));
1264
+ const result = await uninstallAutostart({ stateRoot, logger });
1265
+ const state = optionalServiceState(args, stateRoot);
1266
+ const workspaceDaemon = state
1267
+ ? await stopWorkspaceServiceDaemon(state, { logger, reason: "service uninstall" })
1268
+ : { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
1269
+ const output = {
1270
+ ...result,
1271
+ ok: result?.ok !== false && workspaceDaemon.ok,
1272
+ workspace: state?.workspace?.path || null,
1273
+ workspace_daemon: workspaceDaemon,
1274
+ };
1275
+ console.log(JSON.stringify(output, null, 2));
1276
+ if (!output.ok) process.exitCode = 1;
1232
1277
  return;
1233
1278
  }
1234
1279
  throw new Error(`Unknown service action: ${action}`);
1235
1280
  }
1236
1281
 
1282
+ function optionalServiceState(args, stateRoot) {
1283
+ const requested = args.workspace || args._[1] || selectedWorkspace(stateRoot);
1284
+ if (!requested || requested === true) return null;
1285
+ return loadState(resolveWorkspace(String(requested)), { stateDir: stateRoot });
1286
+ }
1287
+
1237
1288
  async function installAutostartBestEffort({ workspace, stateRoot, entryScript, logger }) {
1238
1289
  try {
1239
1290
  const { installAutostart } = await import("./service.mjs");
@@ -1246,12 +1297,19 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, l
1246
1297
  }
1247
1298
 
1248
1299
  async function stopAutostartBestEffort(logger) {
1300
+ let result = null;
1249
1301
  try {
1250
1302
  const { stopAutostart } = await import("./service.mjs");
1251
- await stopAutostart({ logger: structuredLogger(true) });
1303
+ result = await stopAutostart({ logger: structuredLogger(true) });
1252
1304
  } catch (error) {
1253
- logger.warn("Autostart stop skipped", { error_class: classifyOperationalError(error) });
1305
+ logger.warn("Autostart stop command was unavailable; checking the workspace daemon directly", { error_class: classifyOperationalError(error) });
1306
+ return { takeOverServiceOwner: true, provider: null };
1307
+ }
1308
+ if (result?.active_before && result?.ok) logger.info("stopping the background service before foreground startup");
1309
+ if (result?.ok === false && result?.active === true) {
1310
+ throw new Error("the background service is still active after the stop request; run `machine-mcp service status` for details");
1254
1311
  }
1312
+ return { takeOverServiceOwner: true, provider: result };
1255
1313
  }
1256
1314
 
1257
1315
  async function uninstallCommand(args) {
@@ -1433,7 +1491,7 @@ Usage:
1433
1491
  .\\mbm.cmd # from source checkout on Windows cmd
1434
1492
 
1435
1493
  Commands:
1436
- start Deploy/update Worker, install autostart, start remote daemon
1494
+ start Deploy/update Worker, take over autostart, run foreground daemon
1437
1495
  stdio Run a local MCP stdio server for Claude, Cursor, Codex, and compatible clients
1438
1496
  client-config Print stdio client configuration snippets
1439
1497
  workspace show Show remembered workspace
@@ -0,0 +1,263 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import {
5
+ acquireDaemonLock,
6
+ daemonLockPathForState,
7
+ readDaemonLockOwner,
8
+ resolveWorkspace,
9
+ } from "./state.mjs";
10
+
11
+ const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
12
+ const DEFAULT_TAKEOVER_POLL_MS = 100;
13
+
14
+ export async function acquireDaemonLockWithTakeover(state, options = {}) {
15
+ const ownerMetadata = options.ownerMetadata || {};
16
+ let lock = acquireDaemonLock(state, ownerMetadata);
17
+ if (lock.acquired || !options.takeOverServiceOwner) return lock;
18
+
19
+ const stopped = await stopWorkspaceServiceDaemon(state, {
20
+ owner: lock.owner,
21
+ timeoutMs: options.timeoutMs,
22
+ pollMs: options.pollMs,
23
+ logger: options.logger,
24
+ reason: "foreground startup",
25
+ });
26
+ if (!stopped.verified_service_daemon) return lock;
27
+ if (!stopped.ok) {
28
+ const pid = stopped.pid ? `pid ${stopped.pid}` : "unknown pid";
29
+ throw new Error(`background daemon did not release the workspace within ${Math.ceil(stopped.timeout_ms / 1000)} seconds (${pid}); run \`machine-mcp service stop\`, verify \`machine-mcp service status\`, and retry`);
30
+ }
31
+
32
+ lock = acquireDaemonLock(state, ownerMetadata);
33
+ if (lock.acquired) options.logger?.info?.("background daemon stopped; foreground startup is taking over the workspace");
34
+ return lock;
35
+ }
36
+
37
+ export async function stopWorkspaceServiceDaemon(state, options = {}) {
38
+ const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
39
+ const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
40
+ const logger = options.logger || { info() {}, warn() {} };
41
+ const deadline = Date.now() + timeoutMs;
42
+ const signalled = new Set();
43
+ let owner = options.owner || readDaemonLockOwner(daemonLockPathForState(state));
44
+ let verified = false;
45
+ let lastOwner = owner;
46
+
47
+ while (true) {
48
+ if (owner?.pid && isPidAlive(owner.pid)) {
49
+ lastOwner = owner;
50
+ const identity = inspectWorkspaceDaemonOwner(state, owner);
51
+ if (!identity.verified_service_daemon) {
52
+ return {
53
+ ok: false,
54
+ found: true,
55
+ verified_service_daemon: false,
56
+ reason: identity.reason,
57
+ timeout_ms: timeoutMs,
58
+ ...publicDaemonOwner(owner),
59
+ };
60
+ }
61
+ verified = true;
62
+ if (!signalled.has(Number(owner.pid))) {
63
+ const purpose = options.reason || "service stop";
64
+ logger.info?.(`stopping detached background daemon (pid ${owner.pid}) for ${purpose}`);
65
+ try {
66
+ process.kill(Number(owner.pid), "SIGTERM");
67
+ } catch (error) {
68
+ if (error?.code !== "ESRCH") {
69
+ return {
70
+ ok: false,
71
+ found: true,
72
+ verified_service_daemon: true,
73
+ reason: "signal_failed",
74
+ timeout_ms: timeoutMs,
75
+ ...publicDaemonOwner(owner),
76
+ };
77
+ }
78
+ }
79
+ signalled.add(Number(owner.pid));
80
+ }
81
+ }
82
+
83
+ const liveSignalled = [...signalled].filter((pid) => isPidAlive(pid));
84
+ const currentOwnerAlive = Boolean(owner?.pid && isPidAlive(owner.pid));
85
+ if (!currentOwnerAlive && liveSignalled.length === 0) break;
86
+
87
+ if (Date.now() >= deadline) {
88
+ logger.warn?.(`detached background daemon did not stop within ${Math.ceil(timeoutMs / 1000)} seconds`);
89
+ const remainingPid = Number(owner?.pid) || liveSignalled[0] || null;
90
+ return {
91
+ ok: false,
92
+ found: true,
93
+ stopped: false,
94
+ verified_service_daemon: verified,
95
+ reason: "timeout",
96
+ timeout_ms: timeoutMs,
97
+ pid: remainingPid,
98
+ mode: publicDaemonMode(lastOwner),
99
+ version: typeof lastOwner?.version === "string" ? lastOwner.version : "unknown",
100
+ };
101
+ }
102
+ await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
103
+ owner = readDaemonLockOwner(daemonLockPathForState(state));
104
+ }
105
+
106
+ // Reclaim a stale lock only through the normal token-aware primitive.
107
+ const staleCleanup = acquireDaemonLock(state, { mode: "service" });
108
+ if (staleCleanup.acquired) staleCleanup.release();
109
+ return {
110
+ ok: true,
111
+ found: verified,
112
+ stopped: verified,
113
+ verified_service_daemon: verified,
114
+ reason: verified ? "stopped" : "not_running",
115
+ timeout_ms: timeoutMs,
116
+ ...(lastOwner ? publicDaemonOwner(lastOwner) : {}),
117
+ };
118
+ }
119
+
120
+ export function inspectWorkspaceDaemon(state) {
121
+ const owner = readDaemonLockOwner(daemonLockPathForState(state));
122
+ if (!owner) return { present: false, alive: false, verified_service_daemon: false };
123
+ const alive = Boolean(owner.pid && isPidAlive(owner.pid));
124
+ const identity = alive
125
+ ? inspectWorkspaceDaemonOwner(state, owner)
126
+ : { verified_service_daemon: false, reason: "stale_lock" };
127
+ return {
128
+ present: true,
129
+ alive,
130
+ verified_service_daemon: identity.verified_service_daemon,
131
+ identity_reason: identity.reason,
132
+ ...publicDaemonOwner(owner),
133
+ };
134
+ }
135
+
136
+ function inspectWorkspaceDaemonOwner(state, owner) {
137
+ if (!owner || owner.purpose !== "daemon") return { verified_service_daemon: false, reason: "invalid_lock_owner" };
138
+ const pid = Number(owner.pid);
139
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return { verified_service_daemon: false, reason: "invalid_pid" };
140
+ if (!sameCanonicalPath(owner.workspace, state.workspace.path)) return { verified_service_daemon: false, reason: "workspace_mismatch" };
141
+ if (owner.mode === "foreground") return { verified_service_daemon: false, reason: "foreground_daemon" };
142
+
143
+ const command = processCommandLine(pid);
144
+ if (!command) {
145
+ return { verified_service_daemon: false, reason: owner.mode === "service" ? "service_identity_unavailable" : "legacy_identity_unavailable" };
146
+ }
147
+ const argv = splitProcessCommandLine(command);
148
+ const entryName = path.basename(String(owner.entryScript || "machine-mcp"));
149
+ const workspaceArg = commandFlagValue(argv, "--workspace");
150
+ const stateRootArg = commandFlagValue(argv, "--state-dir");
151
+ const entryMatches = argv.some((value) => {
152
+ const name = path.basename(value);
153
+ return name === entryName || name === "machine-mcp" || name === "machine-mcp.mjs";
154
+ });
155
+ const matches = argv.includes("--daemon-only")
156
+ && sameCanonicalPath(workspaceArg, state.workspace.path)
157
+ && sameCanonicalPath(stateRootArg, state.paths.stateRoot)
158
+ && entryMatches;
159
+ return { verified_service_daemon: matches, reason: matches ? "service_command" : "command_mismatch" };
160
+ }
161
+
162
+ function processCommandLine(pid) {
163
+ if (process.platform === "win32") {
164
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
165
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
166
+ encoding: "utf8",
167
+ timeout: 3000,
168
+ windowsHide: true,
169
+ });
170
+ return result.status === 0 ? String(result.stdout || "").trim() : "";
171
+ }
172
+ const result = spawnSync("ps", ["-ww", "-p", String(pid), "-o", "command="], {
173
+ encoding: "utf8",
174
+ timeout: 3000,
175
+ windowsHide: true,
176
+ });
177
+ return result.status === 0 ? String(result.stdout || "").trim() : "";
178
+ }
179
+
180
+ function splitProcessCommandLine(value) {
181
+ const args = [];
182
+ let current = "";
183
+ let quote = "";
184
+ const text = String(value);
185
+ for (let index = 0; index < text.length; index += 1) {
186
+ const character = text[index];
187
+ if (quote) {
188
+ if (character === quote) quote = "";
189
+ else if (character === "\\" && quote === '"' && ['"', "\\"].includes(text[index + 1])) {
190
+ current += text[index + 1];
191
+ index += 1;
192
+ } else current += character;
193
+ continue;
194
+ }
195
+ if (character === '"' || character === "'") {
196
+ quote = character;
197
+ continue;
198
+ }
199
+ if (/\s/.test(character)) {
200
+ if (current) {
201
+ args.push(current);
202
+ current = "";
203
+ }
204
+ continue;
205
+ }
206
+ if (character === "\\" && /[\s'"\\]/.test(text[index + 1] || "")) {
207
+ current += text[index + 1];
208
+ index += 1;
209
+ continue;
210
+ }
211
+ current += character;
212
+ }
213
+ if (current) args.push(current);
214
+ return args;
215
+ }
216
+
217
+ function commandFlagValue(argv, name) {
218
+ const index = argv.indexOf(name);
219
+ return index >= 0 && index + 1 < argv.length ? argv[index + 1] : "";
220
+ }
221
+
222
+ function sameCanonicalPath(left, right) {
223
+ if (typeof left !== "string" || typeof right !== "string") return false;
224
+ try {
225
+ const a = resolveWorkspace(left);
226
+ const b = resolveWorkspace(right);
227
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ function publicDaemonOwner(owner) {
234
+ return {
235
+ pid: Number(owner?.pid) || null,
236
+ mode: publicDaemonMode(owner),
237
+ version: typeof owner?.version === "string" ? owner.version : "unknown",
238
+ };
239
+ }
240
+
241
+ function publicDaemonMode(owner) {
242
+ return owner?.mode === "service" || owner?.mode === "foreground" ? owner.mode : "legacy";
243
+ }
244
+
245
+ function isPidAlive(pid) {
246
+ const parsed = Number(pid);
247
+ if (!Number.isInteger(parsed) || parsed <= 0) return false;
248
+ try {
249
+ process.kill(parsed, 0);
250
+ return true;
251
+ } catch (error) {
252
+ return error?.code === "EPERM";
253
+ }
254
+ }
255
+
256
+ function boundedPositiveInt(value, fallback) {
257
+ const parsed = Number(value);
258
+ return Number.isFinite(parsed) ? Math.max(1, parsed) : fallback;
259
+ }
260
+
261
+ function sleep(ms) {
262
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
263
+ }