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.
- package/CHANGELOG.md +42 -0
- package/README.md +38 -6
- package/SECURITY.md +4 -2
- package/browser-extension/manifest.json +2 -2
- package/docs/AGENT_CONTEXT.md +95 -19
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/CLIENTS.md +1 -1
- package/docs/ENGINEERING.md +5 -1
- package/docs/LOGGING.md +2 -1
- package/docs/OPERATIONS.md +23 -0
- package/docs/PRIVACY.md +8 -0
- package/docs/TESTING.md +2 -1
- package/package.json +2 -2
- package/src/local/agent-context.mjs +91 -12
- package/src/local/cli.mjs +80 -22
- package/src/local/daemon-process.mjs +263 -0
- package/src/local/default-instructions.mjs +337 -0
- package/src/local/service.mjs +62 -11
- package/src/local/state.mjs +7 -3
- package/src/shared/server-metadata.json +1 -1
- package/src/shared/tool-catalog.json +3 -3
- package/src/worker/index.ts +1 -1
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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 = {
|
|
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: [
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
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
|
-
|
|
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
|
|
370
|
+
notice,
|
|
358
371
|
});
|
|
359
372
|
return;
|
|
360
373
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1231
|
-
|
|
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
|
|
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,
|
|
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
|
+
}
|