machine-bridge-mcp 0.11.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 +13 -0
- package/README.md +2 -2
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/OPERATIONS.md +5 -1
- package/docs/TESTING.md +1 -1
- package/package.json +2 -2
- package/src/local/cli.mjs +60 -56
- package/src/local/daemon-process.mjs +263 -0
- package/src/local/service.mjs +62 -11
- package/src/worker/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.1 - 2026-07-12
|
|
4
|
+
|
|
5
|
+
### Reliable macOS service and orphan-daemon takeover
|
|
6
|
+
|
|
7
|
+
- Stop launchd jobs by their loaded service target first, with the plist form retained as a compatibility fallback. Treat an already-unloaded job as an idempotent success, verify the post-stop state before reporting success, and prevent `service start` or `service uninstall` from continuing when the existing job remains active.
|
|
8
|
+
- Detect legacy daemon locks that predate mode/version metadata by inspecting the live process command line. A process is eligible for takeover only when its PID, daemon purpose, canonical workspace, canonical state root, entrypoint, and `--daemon-only` arguments all match. Foreground or unverifiable processes are never signalled.
|
|
9
|
+
- Let normal foreground startup and `machine-mcp service stop` terminate a verified detached/orphan service daemon with `SIGTERM`, wait for the actual PID as well as the lock to disappear, reclaim stale locks through the normal token-aware primitive, and fail with a bounded timeout rather than escalating to a forced kill.
|
|
10
|
+
- Extend `machine-mcp service status` to report platform-service state and workspace-daemon state separately, including whether a live legacy process was verified as service-style. This makes a launchd-unloaded but still-running daemon visible instead of presenting a misleading inactive-only status.
|
|
11
|
+
|
|
12
|
+
### Tests and documentation
|
|
13
|
+
|
|
14
|
+
- Add real child-process regression coverage for legacy orphan takeover, canonical `/var` versus `/private/var` path aliases, foreground-process protection, non-forcing timeout behavior, launchd service-target generation, and packaged daemon-process module presence. Update upgrade and recovery guidance for the exact npm install command and split service/daemon diagnosis.
|
|
15
|
+
|
|
3
16
|
## 0.11.0 - 2026-07-12
|
|
4
17
|
|
|
5
18
|
### Zero-configuration agent working agreements
|
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ On first remote start, the CLI:
|
|
|
79
79
|
6. starts an outbound-only daemon connection;
|
|
80
80
|
7. prints the Remote MCP URL and connection password.
|
|
81
81
|
|
|
82
|
-
A normal `machine-mcp` invocation is a foreground start: it remains attached to the terminal and prints `info` logs.
|
|
82
|
+
A normal `machine-mcp` invocation is a foreground start: it remains attached to the terminal and prints `info` logs. After a global package upgrade, the CLI unloads the platform service and also checks the workspace lock for a detached/orphan `--daemon-only` process that the service manager no longer tracks. It terminates only a process whose PID, entrypoint, canonical workspace, canonical state root, and daemon-only arguments all match, waits up to 15 seconds for the PID and lock to disappear, and then starts the installed version in the foreground. A genuine foreground or unverifiable conflict is left untouched and reported with actionable guidance. To run only in the background, use `machine-mcp service start`; inspect its owner-only logs under `~/.local/state/machine-bridge-mcp/logs/`.
|
|
83
83
|
|
|
84
84
|
Recommended upgrade sequence:
|
|
85
85
|
|
|
@@ -88,7 +88,7 @@ npm install -g --omit=optional --allow-scripts=esbuild,workerd,sharp,fsevents ma
|
|
|
88
88
|
machine-mcp --verbose
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
The global install replaces files on disk but cannot hot-reload an already running Node process; the second command performs the bounded service takeover. If takeover fails, run `machine-mcp service
|
|
91
|
+
The global install replaces files on disk but cannot hot-reload an already running Node process; the second command performs the bounded service and orphan-daemon takeover. Use the command exactly as shown: `--omit=optional` prevents the development-only optional `fsevents` package from producing a blocked-install-script warning. If takeover fails, run `machine-mcp service status` first; it reports the platform service and workspace daemon separately. Then run `machine-mcp service stop` and retry.
|
|
92
92
|
|
|
93
93
|
Use the printed values in the MCP client:
|
|
94
94
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Machine Bridge Browser",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.1",
|
|
5
5
|
"description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
"action": {
|
|
30
30
|
"default_title": "Machine Bridge Browser"
|
|
31
31
|
},
|
|
32
|
-
"version_name": "0.11.
|
|
32
|
+
"version_name": "0.11.1"
|
|
33
33
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -40,6 +40,8 @@ A canonical workspace receives an independent profile, Worker name, secret set,
|
|
|
40
40
|
|
|
41
41
|
`RelayConnection` owns remote WebSocket transport, authenticated `hello_ack` readiness, heartbeat liveness, reconnect backoff, and outage logging. Stdio mode invokes `LocalRuntime` directly without that adapter.
|
|
42
42
|
|
|
43
|
+
`daemon-process.mjs` owns workspace-daemon inspection and takeover. It distinguishes platform service state from the lock-owning Node process, canonicalizes workspace/state paths before identity comparison, parses bounded process command lines without executing them, and sends `SIGTERM` only to a verified same-workspace `--daemon-only` process. CLI orchestration never treats a missing launchd/systemd job as proof that the process exited.
|
|
44
|
+
|
|
43
45
|
### Agent context and capability resolver
|
|
44
46
|
|
|
45
47
|
`AgentContextManager` discovers the nearest Git/workspace scope, applies the user configuration and hierarchical `.machine-bridge/agent.json` files, selects built-in/user/root-to-target instructions, discovers bounded filesystem skills, and resolves registered commands. `default-instructions.mjs` supplies a versioned in-package working-agreement block and derives a small virtual project-context block from root filenames and bounded metadata. It reads package script names but not bodies, does not inspect dependency values or source contents, executes nothing, and writes no user/repository files. A global `model_instructions_file` is a separate user-designated session source and cannot be overridden by a project.
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -61,7 +61,9 @@ Application UI inspection/actions require Accessibility permission for the Node/
|
|
|
61
61
|
|
|
62
62
|
`machine-mcp` is a foreground command. It remains attached to the terminal, defaults to `info` logging, and stops on `Ctrl+C`. `machine-mcp service start` launches the installed platform service in the background and returns to the shell; that service uses `warn` logging.
|
|
63
63
|
|
|
64
|
-
A global npm install changes the CLI files on disk but does not replace an already running Node process. On a normal foreground start, Machine Bridge
|
|
64
|
+
A global npm install changes the CLI files on disk but does not replace an already running Node process. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd/systemd/task scheduling no longer tracks, including legacy locks without mode/version metadata. Before sending `SIGTERM`, Machine Bridge verifies the live command line against the lock, entrypoint, canonical workspace, canonical state root, and daemon-only flag. It waits up to 15 seconds for both the PID and lock to disappear and never escalates to a forced kill. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
|
|
65
|
+
|
|
66
|
+
`machine-mcp service status [WORKSPACE]` reports two independent layers: the platform service (`active`) and `workspace_daemon`, plus `effective_active` and `orphaned_workspace_daemon` summary flags. On macOS it is possible for launchd to report inactive while a prior Node process remains alive with parent PID 1; that is an orphan-daemon condition, not proof that the daemon stopped. `service stop` now unloads the provider when present and then terminates only a verified service-style workspace daemon. If takeover reaches its deadline, run:
|
|
65
67
|
|
|
66
68
|
```sh
|
|
67
69
|
machine-mcp service stop
|
|
@@ -76,6 +78,8 @@ npm install -g --omit=optional --allow-scripts=esbuild,workerd,sharp,fsevents ma
|
|
|
76
78
|
machine-mcp --verbose
|
|
77
79
|
```
|
|
78
80
|
|
|
81
|
+
Keep `--omit=optional` in the install command. Without it npm may resolve optional `fsevents` and warn that its install script was not included in `allowScripts`; Machine Bridge does not require that development-time watcher at runtime.
|
|
82
|
+
|
|
79
83
|
## Logs
|
|
80
84
|
|
|
81
85
|
Remote autostart definitions prefer a stable PATH alias that resolves to the currently running Node executable and persist a sanitized absolute-only service `PATH` containing the Node/CLI directories, the installer's absolute PATH entries, and platform defaults. This avoids versioned Homebrew-style paths becoming invalid after upgrades and prevents launchd/systemd from falling back to a minimal system-only PATH. Re-run `machine-mcp service install` after changing Node installation families or PATH layout. A service-style `--daemon-only` start that finds the same workspace daemon already running is an idempotent no-op: it exits successfully without repeating warnings or readiness output; explicit policy/secret/change requests still report that changes were not applied. Autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup. On a logging-schema upgrade, bounded prior content is moved into `daemon.out.legacy.log` and `daemon.err.legacy.log`; use the active filenames when diagnosing current behavior.
|
package/docs/TESTING.md
CHANGED
|
@@ -18,7 +18,7 @@ The suite includes:
|
|
|
18
18
|
- syntax validation for every shipped JavaScript entry point;
|
|
19
19
|
- shared tool-catalog schema, annotation, and profile-inventory checks;
|
|
20
20
|
- default working-agreement injection without user files, bounded automatic project metadata, script-body non-disclosure, user-global opt-out, repository opt-out rejection, global `model_instructions_file` injection in stdio/remote initialization, hierarchical precedence, live project/skill rescanning and fingerprints, automatic task ranking/loading, command override/removal, direct argv handling, timeout ceilings, and execution-profile denial;
|
|
21
|
-
- foreground takeover of
|
|
21
|
+
- foreground takeover of active and orphaned background daemons, legacy lock identification from canonicalized process arguments, foreground-process protection, actual-PID exit waiting, non-forcing timeout guidance, daemon lock mode/version metadata, launchd service-target semantics, and silent idempotent duplicate service starts;
|
|
22
22
|
- machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, and broker result redaction;
|
|
23
23
|
- canonical path and symbolic-link escape tests;
|
|
24
24
|
- relative-path privacy and error-path redaction tests;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"prepublishOnly": "npm run check && npm run version:check && npm run release:check",
|
|
45
45
|
"worker:types": "wrangler types src/worker/worker-configuration.d.ts",
|
|
46
46
|
"typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
|
|
47
|
-
"syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/agent-context.mjs && node --check src/local/default-instructions.mjs && node --check src/local/browser-bridge.mjs && node --check src/local/app-automation.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/release-state.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/agent-context-test.mjs && node --check tests/browser-bridge-test.mjs && node --check tests/app-automation-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/release-state-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs && node --check browser-extension/service-worker.js && node --check browser-extension/pairing.js && node --check browser-extension/page-automation.js",
|
|
47
|
+
"syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/agent-context.mjs && node --check src/local/default-instructions.mjs && node --check src/local/daemon-process.mjs && node --check src/local/browser-bridge.mjs && node --check src/local/app-automation.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/release-state.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/agent-context-test.mjs && node --check tests/browser-bridge-test.mjs && node --check tests/app-automation-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/release-state-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs && node --check browser-extension/service-worker.js && node --check browser-extension/pairing.js && node --check browser-extension/page-automation.js",
|
|
48
48
|
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
49
49
|
"worker:dry-run": "wrangler deploy --dry-run",
|
|
50
50
|
"worker:integration-test": "node tests/worker-integration-test.mjs",
|
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";
|
|
@@ -47,8 +48,6 @@ const BOOLEAN_OPTIONS = new Set([
|
|
|
47
48
|
const VALUE_OPTIONS = new Set([
|
|
48
49
|
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
|
|
49
50
|
]);
|
|
50
|
-
const DAEMON_TAKEOVER_TIMEOUT_MS = 15_000;
|
|
51
|
-
const DAEMON_TAKEOVER_POLL_MS = 100;
|
|
52
51
|
|
|
53
52
|
const COMMAND_HANDLERS = Object.freeze({
|
|
54
53
|
start: startCommand,
|
|
@@ -146,7 +145,7 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
|
146
145
|
},
|
|
147
146
|
service(args) {
|
|
148
147
|
const action = String(args._[0] || "status");
|
|
149
|
-
return { max:
|
|
148
|
+
return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
150
149
|
},
|
|
151
150
|
autostart(args) {
|
|
152
151
|
return ACTION_POSITIONAL_RULES.service(args);
|
|
@@ -324,7 +323,7 @@ async function startCommand(args) {
|
|
|
324
323
|
try {
|
|
325
324
|
const startMode = await prepareStartMode(args, state, logger);
|
|
326
325
|
const daemonLock = await acquireDaemonLockWithTakeover(state, {
|
|
327
|
-
|
|
326
|
+
takeOverServiceOwner: startMode.takeOverServiceOwner,
|
|
328
327
|
ownerMetadata: {
|
|
329
328
|
mode: args.daemonOnly ? "service" : "foreground",
|
|
330
329
|
version: currentPackageVersion(),
|
|
@@ -345,43 +344,15 @@ async function prepareStartMode(args, state, logger) {
|
|
|
345
344
|
if (args.daemonOnly) {
|
|
346
345
|
const { trimAutostartLogs } = await import("./service.mjs");
|
|
347
346
|
trimAutostartLogs(state.paths.stateRoot);
|
|
348
|
-
return {
|
|
347
|
+
return { takeOverServiceOwner: false };
|
|
349
348
|
}
|
|
350
|
-
// A normal foreground start
|
|
351
|
-
//
|
|
352
|
-
//
|
|
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
353
|
return stopAutostartBestEffort(logger);
|
|
354
354
|
}
|
|
355
355
|
|
|
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
356
|
function reportExistingDaemon(args, state, owner, logger) {
|
|
386
357
|
const pid = owner?.pid ? `pid ${owner.pid}` : "unknown pid";
|
|
387
358
|
if (isIdempotentDaemonOnlyStart(args)) {
|
|
@@ -1244,7 +1215,15 @@ async function serviceCommand(args) {
|
|
|
1244
1215
|
const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
|
|
1245
1216
|
if (action === "status") {
|
|
1246
1217
|
const status = await autostartStatus({ logger: structuredLogger(Boolean(args.quiet)) });
|
|
1247
|
-
|
|
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));
|
|
1248
1227
|
return;
|
|
1249
1228
|
}
|
|
1250
1229
|
if (action === "install") {
|
|
@@ -1264,18 +1243,48 @@ async function serviceCommand(args) {
|
|
|
1264
1243
|
return;
|
|
1265
1244
|
}
|
|
1266
1245
|
if (action === "stop") {
|
|
1267
|
-
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
|
+
};
|
|
1268
1258
|
console.log(JSON.stringify(result, null, 2));
|
|
1259
|
+
if (!result.ok) process.exitCode = 1;
|
|
1269
1260
|
return;
|
|
1270
1261
|
}
|
|
1271
1262
|
if (action === "uninstall" || action === "remove") {
|
|
1272
|
-
const
|
|
1273
|
-
|
|
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;
|
|
1274
1277
|
return;
|
|
1275
1278
|
}
|
|
1276
1279
|
throw new Error(`Unknown service action: ${action}`);
|
|
1277
1280
|
}
|
|
1278
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
|
+
|
|
1279
1288
|
async function installAutostartBestEffort({ workspace, stateRoot, entryScript, logger }) {
|
|
1280
1289
|
try {
|
|
1281
1290
|
const { installAutostart } = await import("./service.mjs");
|
|
@@ -1288,24 +1297,19 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, l
|
|
|
1288
1297
|
}
|
|
1289
1298
|
|
|
1290
1299
|
async function stopAutostartBestEffort(logger) {
|
|
1300
|
+
let result = null;
|
|
1291
1301
|
try {
|
|
1292
|
-
const {
|
|
1293
|
-
|
|
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) };
|
|
1302
|
+
const { stopAutostart } = await import("./service.mjs");
|
|
1303
|
+
result = await stopAutostart({ logger: structuredLogger(true) });
|
|
1305
1304
|
} catch (error) {
|
|
1306
|
-
logger.warn("Autostart stop
|
|
1307
|
-
return {
|
|
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");
|
|
1308
1311
|
}
|
|
1312
|
+
return { takeOverServiceOwner: true, provider: result };
|
|
1309
1313
|
}
|
|
1310
1314
|
|
|
1311
1315
|
async function uninstallCommand(args) {
|
|
@@ -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
|
+
}
|
package/src/local/service.mjs
CHANGED
|
@@ -248,6 +248,18 @@ function launchdPlistPath() {
|
|
|
248
248
|
return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
export function launchdServiceTarget(uid = process.getuid?.()) {
|
|
252
|
+
const parsed = Number(uid);
|
|
253
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new Error("launchd requires a numeric user id");
|
|
254
|
+
return `gui/${parsed}/${LABEL}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function launchdDomainTarget(uid = process.getuid?.()) {
|
|
258
|
+
const parsed = Number(uid);
|
|
259
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new Error("launchd requires a numeric user id");
|
|
260
|
+
return `gui/${parsed}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
251
263
|
async function installLaunchd(spec, logger) {
|
|
252
264
|
const plistPath = launchdPlistPath();
|
|
253
265
|
mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
@@ -259,34 +271,73 @@ async function installLaunchd(spec, logger) {
|
|
|
259
271
|
|
|
260
272
|
async function startLaunchd(logger) {
|
|
261
273
|
const plistPath = launchdPlistPath();
|
|
262
|
-
const target =
|
|
274
|
+
const target = launchdDomainTarget();
|
|
263
275
|
if (!existsSync(plistPath)) return { ok: false, error: "launchd plist not installed" };
|
|
264
|
-
await
|
|
276
|
+
const stopped = await stopLaunchd({ info() {}, warn() {} });
|
|
277
|
+
if (!stopped.ok) return { ok: false, provider: "launchd", stop: stopped };
|
|
265
278
|
const boot = await serviceRun("launchctl", ["bootstrap", target, plistPath]);
|
|
266
279
|
const kick = await serviceRun("launchctl", ["kickstart", "-k", `${target}/${LABEL}`]);
|
|
267
|
-
|
|
268
|
-
|
|
280
|
+
const ok = boot.code === 0 || kick.code === 0;
|
|
281
|
+
if (ok) logger.info?.("launchd service started");
|
|
282
|
+
else logger.warn?.("launchd service failed to start");
|
|
283
|
+
return { ok, provider: "launchd", bootstrap: boot, kickstart: kick };
|
|
269
284
|
}
|
|
270
285
|
|
|
271
286
|
async function stopLaunchd(logger) {
|
|
272
287
|
const plistPath = launchdPlistPath();
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
288
|
+
const domainTarget = launchdDomainTarget();
|
|
289
|
+
const serviceTarget = launchdServiceTarget();
|
|
290
|
+
const before = await statusLaunchd();
|
|
291
|
+
if (!before.active) {
|
|
292
|
+
logger.info?.("launchd service is not loaded");
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
provider: "launchd",
|
|
296
|
+
installed: existsSync(plistPath),
|
|
297
|
+
active_before: false,
|
|
298
|
+
active: false,
|
|
299
|
+
already_stopped: true,
|
|
300
|
+
code: 0,
|
|
301
|
+
stdout: "",
|
|
302
|
+
stderr: "",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const byServiceTarget = await serviceRun("launchctl", ["bootout", serviceTarget]);
|
|
307
|
+
const byPlist = byServiceTarget.code === 0
|
|
308
|
+
? null
|
|
309
|
+
: await serviceRun("launchctl", ["bootout", domainTarget, plistPath]);
|
|
310
|
+
const after = await statusLaunchd();
|
|
311
|
+
const rawResult = byPlist || byServiceTarget;
|
|
312
|
+
const ok = !after.active;
|
|
313
|
+
if (ok) logger.info?.("launchd service stopped");
|
|
314
|
+
else logger.warn?.("launchd service is still active after the stop request");
|
|
315
|
+
return {
|
|
316
|
+
...rawResult,
|
|
317
|
+
ok,
|
|
318
|
+
provider: "launchd",
|
|
319
|
+
installed: existsSync(plistPath),
|
|
320
|
+
active_before: true,
|
|
321
|
+
active: after.active,
|
|
322
|
+
already_stopped: false,
|
|
323
|
+
code: ok ? 0 : rawResult.code,
|
|
324
|
+
bootout_service_target: byServiceTarget,
|
|
325
|
+
...(byPlist ? { bootout_plist_fallback: byPlist } : {}),
|
|
326
|
+
};
|
|
277
327
|
}
|
|
278
328
|
|
|
279
329
|
async function uninstallLaunchd(logger) {
|
|
280
|
-
await stopLaunchd(logger)
|
|
330
|
+
const stopped = await stopLaunchd(logger);
|
|
281
331
|
const plistPath = launchdPlistPath();
|
|
332
|
+
if (!stopped.ok) return { ok: false, provider: "launchd", path: plistPath, stop: stopped };
|
|
282
333
|
if (existsSync(plistPath)) rmSync(plistPath, { force: true });
|
|
283
334
|
logger.info?.("Autostart removed.");
|
|
284
|
-
return { ok: true, provider: "launchd", path: plistPath };
|
|
335
|
+
return { ok: true, provider: "launchd", path: plistPath, stop: stopped };
|
|
285
336
|
}
|
|
286
337
|
|
|
287
338
|
async function statusLaunchd() {
|
|
288
339
|
const plistPath = launchdPlistPath();
|
|
289
|
-
const target =
|
|
340
|
+
const target = launchdServiceTarget();
|
|
290
341
|
const result = await serviceRun("launchctl", ["print", target]);
|
|
291
342
|
return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
292
343
|
}
|
package/src/worker/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
|
|
|
3
3
|
import serverMetadata from "../shared/server-metadata.json";
|
|
4
4
|
|
|
5
5
|
const SERVER_NAME = String(serverMetadata.name);
|
|
6
|
-
const SERVER_VERSION = "0.11.
|
|
6
|
+
const SERVER_VERSION = "0.11.1";
|
|
7
7
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
8
8
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
9
9
|
const JSONRPC_VERSION = "2.0";
|