machine-bridge-mcp 0.16.2 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/CONTRIBUTING.md +14 -0
  3. package/README.md +11 -1
  4. package/SECURITY.md +9 -1
  5. package/browser-extension/manifest.json +2 -2
  6. package/browser-extension/service-worker.js +52 -29
  7. package/docs/ARCHITECTURE.md +4 -2
  8. package/docs/CLIENTS.md +2 -0
  9. package/docs/ENGINEERING.md +3 -1
  10. package/docs/GETTING_STARTED.md +400 -0
  11. package/docs/MULTI_ACCOUNT.md +406 -0
  12. package/docs/PROJECT_STANDARDS.md +143 -0
  13. package/docs/RELEASING.md +12 -0
  14. package/docs/TESTING.md +7 -5
  15. package/docs/TOOL_REFERENCE.md +2836 -0
  16. package/package.json +13 -4
  17. package/scripts/commit-message-check.mjs +63 -0
  18. package/scripts/coverage-check.mjs +9 -1
  19. package/scripts/generate-policy-reference.mjs +1 -4
  20. package/scripts/generate-tool-reference.mjs +87 -0
  21. package/scripts/generate-worker-types.mjs +19 -0
  22. package/scripts/markdown.mjs +9 -0
  23. package/src/local/agent-context.mjs +8 -15
  24. package/src/local/cli.mjs +1 -107
  25. package/src/local/daemon-process.mjs +16 -4
  26. package/src/local/default-instructions.mjs +2 -45
  27. package/src/local/git-service.mjs +4 -9
  28. package/src/local/managed-job-plan.mjs +2 -7
  29. package/src/local/managed-jobs.mjs +2 -7
  30. package/src/local/numbers.mjs +9 -0
  31. package/src/local/process-execution.mjs +4 -9
  32. package/src/local/process-sessions.mjs +5 -10
  33. package/src/local/project-metadata.mjs +50 -0
  34. package/src/local/project-package.mjs +2 -45
  35. package/src/local/records.mjs +5 -0
  36. package/src/local/runtime.mjs +5 -12
  37. package/src/local/state-inventory.mjs +139 -0
  38. package/src/local/workspace-file-service.mjs +6 -12
  39. package/src/worker/index.ts +33 -17
  40. package/tsconfig.json +1 -1
  41. package/src/worker/worker-configuration.d.ts +0 -14716
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.16.2",
3
+ "version": "0.17.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",
@@ -40,10 +40,10 @@
40
40
  "version": "node scripts/sync-worker-version.mjs && git add package.json package-lock.json src/worker/index.ts browser-extension/manifest.json",
41
41
  "prepack": "npm run version:check && npm run privacy:check && npm run release-impact:check",
42
42
  "prepublishOnly": "npm run check && npm run version:check && npm run release:check",
43
- "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
43
+ "worker:types": "node scripts/generate-worker-types.mjs",
44
44
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
45
45
  "syntax": "node scripts/syntax-check.mjs",
46
- "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 release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run policy:test && npm run policy-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation: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",
46
+ "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 release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation: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",
47
47
  "worker:dry-run": "wrangler deploy --dry-run",
48
48
  "worker:integration-test": "node tests/worker-integration-test.mjs",
49
49
  "release:check": "node scripts/github-release.mjs --check",
@@ -66,6 +66,11 @@
66
66
  "install:test": "node tests/install-smoke-test.mjs",
67
67
  "secure-file:test": "node tests/secure-file-test.mjs",
68
68
  "architecture:test": "node tests/architecture-test.mjs",
69
+ "markdown:test": "node tests/markdown-test.mjs",
70
+ "project-metadata:test": "node tests/project-metadata-test.mjs",
71
+ "numbers:test": "node tests/numbers-test.mjs",
72
+ "records:test": "node tests/records-test.mjs",
73
+ "state-inventory:test": "node tests/state-inventory-test.mjs",
69
74
  "release-state:test": "node tests/release-state-test.mjs",
70
75
  "release-ci:test": "node tests/release-ci-test.mjs",
71
76
  "agent-context:test": "node tests/agent-context-test.mjs",
@@ -89,7 +94,11 @@
89
94
  "capability-ranking:test": "node tests/capability-ranking-test.mjs",
90
95
  "lifecycle:test": "node tests/lifecycle-test.mjs",
91
96
  "runtime-handlers:test": "node tests/runtime-handler-matrix-test.mjs",
92
- "cli-entrypoint:test": "node tests/cli-entrypoint-test.mjs"
97
+ "cli-entrypoint:test": "node tests/cli-entrypoint-test.mjs",
98
+ "commit-message:check": "node scripts/commit-message-check.mjs",
99
+ "commit-message:test": "node tests/commit-message-test.mjs",
100
+ "tool-docs:generate": "node scripts/generate-tool-reference.mjs",
101
+ "tool-docs:check": "node scripts/generate-tool-reference.mjs --check"
93
102
  },
94
103
  "dependencies": {
95
104
  "https-proxy-agent": "9.1.0",
@@ -0,0 +1,63 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import process from "node:process";
3
+
4
+ const allowedTypes = Object.freeze([
5
+ "feat",
6
+ "fix",
7
+ "docs",
8
+ "refactor",
9
+ "perf",
10
+ "test",
11
+ "build",
12
+ "ci",
13
+ "chore",
14
+ "security",
15
+ "release",
16
+ "revert",
17
+ ]);
18
+ const subjectPattern = new RegExp(`^(?:${allowedTypes.join("|")})(?:\\([a-z0-9][a-z0-9._/-]*\\))?!?: [^\\s].*$`);
19
+ const titles = readTitles(process.argv.slice(2));
20
+ const failures = [];
21
+
22
+ for (const title of titles) {
23
+ const normalized = String(title).trim();
24
+ if (!normalized) {
25
+ failures.push("commit or pull-request title is empty");
26
+ continue;
27
+ }
28
+ if (normalized.length > 120) failures.push(`title exceeds 120 characters: ${normalized}`);
29
+ if (!subjectPattern.test(normalized)) {
30
+ failures.push(`invalid Conventional Commit title: ${normalized}`);
31
+ }
32
+ }
33
+
34
+ if (failures.length) {
35
+ console.error([
36
+ ...failures,
37
+ "Expected: <type>[optional scope][optional !]: <imperative description>",
38
+ `Allowed types: ${allowedTypes.join(", ")}`,
39
+ ].join("\n"));
40
+ process.exitCode = 1;
41
+ } else {
42
+ console.log(`commit message check ok (${titles.length} title${titles.length === 1 ? "" : "s"})`);
43
+ }
44
+
45
+ function readTitles(args) {
46
+ const titleIndex = args.indexOf("--title");
47
+ if (titleIndex !== -1) {
48
+ const title = args[titleIndex + 1];
49
+ if (title === undefined) throw new Error("--title requires a value");
50
+ return [title];
51
+ }
52
+
53
+ const rangeIndex = args.indexOf("--range");
54
+ if (rangeIndex !== -1) {
55
+ const range = args[rangeIndex + 1];
56
+ if (!range) throw new Error("--range requires a Git revision range");
57
+ return execFileSync("git", ["log", "--format=%s", range], { encoding: "utf8" })
58
+ .split(/\r?\n/)
59
+ .filter(Boolean);
60
+ }
61
+
62
+ return [execFileSync("git", ["log", "-1", "--format=%s"], { encoding: "utf8" }).trim()];
63
+ }
@@ -14,6 +14,10 @@ const tests = [
14
14
  "tests/runtime-handler-matrix-test.mjs",
15
15
  "tests/cli-entrypoint-test.mjs",
16
16
  "tests/local-self-test.mjs",
17
+ "tests/numbers-test.mjs",
18
+ "tests/records-test.mjs",
19
+ "tests/project-metadata-test.mjs",
20
+ "tests/state-inventory-test.mjs",
17
21
  ];
18
22
 
19
23
  try {
@@ -41,9 +45,13 @@ try {
41
45
  "src/local/process-tracker.mjs": [65, 35],
42
46
  "src/local/log.mjs": [60, 40],
43
47
  "src/local/runtime.mjs": [60, 35],
44
- "src/local/cli.mjs": [40, 5],
48
+ "src/local/cli.mjs": [40, 10],
45
49
  "src/local/cli-options.mjs": [65, 35],
46
50
  "src/local/cli-policy.mjs": [70, 35],
51
+ "src/local/numbers.mjs": [100, 100],
52
+ "src/local/records.mjs": [100, 100],
53
+ "src/local/project-metadata.mjs": [85, 55],
54
+ "src/local/state-inventory.mjs": [80, 50],
47
55
  "src/worker/pending-calls.ts": [85, null],
48
56
  "src/worker/policy.ts": [90, null],
49
57
  "src/worker/errors.ts": [100, null],
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import process from "node:process";
3
+ import { normalizeLineEndings } from "./markdown.mjs";
3
4
 
4
5
  const root = new URL("../", import.meta.url);
5
6
  const contract = JSON.parse(await readFile(new URL("src/shared/policy-contract.json", root), "utf8"));
@@ -89,7 +90,3 @@ function display(value) {
89
90
  if (typeof value === "boolean") return value ? "yes" : "no";
90
91
  return String(value);
91
92
  }
92
-
93
- function normalizeLineEndings(value) {
94
- return String(value).replace(/\r\n/g, "\n");
95
- }
@@ -0,0 +1,87 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import process from "node:process";
3
+ import { escapeMarkdownTableCell, normalizeLineEndings } from "./markdown.mjs";
4
+
5
+ const root = new URL("../", import.meta.url);
6
+ const catalogUrl = new URL("src/shared/tool-catalog.json", root);
7
+ const targetUrl = new URL("docs/TOOL_REFERENCE.md", root);
8
+ const catalog = JSON.parse(await readFile(catalogUrl, "utf8"));
9
+ const generated = renderToolReference(catalog);
10
+
11
+ if (process.argv.includes("--check")) {
12
+ const current = await readFile(targetUrl, "utf8").catch(() => "");
13
+ if (normalizeLineEndings(current) !== generated) {
14
+ console.error("docs/TOOL_REFERENCE.md is stale; run npm run tool-docs:generate");
15
+ process.exitCode = 1;
16
+ } else {
17
+ console.log(`tool reference is up to date (${catalog.length} tools)`);
18
+ }
19
+ } else {
20
+ await writeFile(targetUrl, generated, "utf8");
21
+ console.log(`generated docs/TOOL_REFERENCE.md (${catalog.length} tools)`);
22
+ }
23
+
24
+ function renderToolReference(tools) {
25
+ const lines = [
26
+ "# MCP tool reference",
27
+ "",
28
+ "> Generated from `src/shared/tool-catalog.json`. Do not edit this file manually.",
29
+ "",
30
+ "This catalog is the authoritative public tool contract for names, availability classes, behavior annotations, and JSON input schemas. Runtime authorization is additionally determined by the active policy described in [POLICY_REFERENCE.md](POLICY_REFERENCE.md).",
31
+ "",
32
+ `Tool count: **${tools.length}**.`,
33
+ "",
34
+ "## Index",
35
+ "",
36
+ "| Tool | Availability | Read only | Destructive | Idempotent | Open world |",
37
+ "|---|---|---:|---:|---:|---:|",
38
+ ];
39
+
40
+ for (const tool of tools) {
41
+ const annotations = tool.annotations || {};
42
+ lines.push(`| [\`${escapeMarkdownTableCell(tool.name)}\`](#${anchor(tool.name)}) | \`${escapeMarkdownTableCell(tool.availability)}\` | ${yesNo(annotations.readOnlyHint)} | ${yesNo(annotations.destructiveHint)} | ${yesNo(annotations.idempotentHint)} | ${yesNo(annotations.openWorldHint)} |`);
43
+ }
44
+
45
+ for (const tool of tools) {
46
+ const annotations = tool.annotations || {};
47
+ lines.push(
48
+ "",
49
+ `## \`${tool.name}\``,
50
+ "",
51
+ `**${tool.title}**`,
52
+ "",
53
+ tool.description,
54
+ "",
55
+ "| Contract field | Value |",
56
+ "|---|---|",
57
+ `| Availability | \`${tool.availability}\` |`,
58
+ `| Read-only hint | ${yesNo(annotations.readOnlyHint)} |`,
59
+ `| Destructive hint | ${yesNo(annotations.destructiveHint)} |`,
60
+ `| Idempotent hint | ${yesNo(annotations.idempotentHint)} |`,
61
+ `| Open-world hint | ${yesNo(annotations.openWorldHint)} |`,
62
+ "",
63
+ "### Input schema",
64
+ "",
65
+ "```json",
66
+ JSON.stringify(tool.inputSchema || { type: "object", additionalProperties: false }, null, 2),
67
+ "```",
68
+ );
69
+ }
70
+
71
+ lines.push(
72
+ "",
73
+ "## Change discipline",
74
+ "",
75
+ "A catalog change must update its runtime handler and protocol tests in the same pull request. CI verifies catalog uniqueness, local/Worker use of the shared catalog, handler parity, generated policy documentation, and this generated reference.",
76
+ "",
77
+ );
78
+ return `${lines.join("\n").trimEnd()}\n`;
79
+ }
80
+
81
+ function anchor(name) {
82
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
83
+ }
84
+
85
+ function yesNo(value) {
86
+ return value === true ? "yes" : "no";
87
+ }
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import process from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const target = resolve(root, ".wrangler", "worker-configuration.d.ts");
9
+ const wrangler = resolve(root, "node_modules", "wrangler", "bin", "wrangler.js");
10
+
11
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
12
+ const result = spawnSync(process.execPath, [wrangler, "types", target], {
13
+ cwd: root,
14
+ env: process.env,
15
+ stdio: "inherit",
16
+ windowsHide: true,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.status !== 0) process.exit(result.status ?? 1);
@@ -0,0 +1,9 @@
1
+ export function escapeMarkdownTableCell(value) {
2
+ return String(value)
3
+ .replace(/\\/g, "\\\\")
4
+ .replace(/\|/g, "\\|");
5
+ }
6
+
7
+ export function normalizeLineEndings(value) {
8
+ return String(value).replace(/\r\n/g, "\n");
9
+ }
@@ -5,6 +5,8 @@ import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { createBuiltinInstruction, discoverAutomaticProjectInstruction } from "./default-instructions.mjs";
7
7
  import { automaticPackageCommands, readProjectPackageMetadata } from "./project-package.mjs";
8
+ import { clampInteger } from "./numbers.mjs";
9
+ import { isPlainRecord } from "./records.mjs";
8
10
 
9
11
  const CONFIG_RELATIVE_PATH = join(".machine-bridge", "agent.json");
10
12
  const GLOBAL_CONFIG_RELATIVE_PATH = join(".config", "machine-bridge-mcp", "agent.json");
@@ -42,7 +44,7 @@ export class AgentContextManager {
42
44
  async agentContext(args = {}, context = {}) {
43
45
  const state = await this.discoverState(args.path || ".", context);
44
46
  const includeContent = args.include_instruction_content !== false;
45
- const skillLimit = clampInt(args.max_skills, 100, 1, MAX_SKILL_RESULTS);
47
+ const skillLimit = clampInteger(args.max_skills, 100, 1, MAX_SKILL_RESULTS);
46
48
  const discoveredSkills = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
47
49
  const contextSkills = contextSkillSummaries(discoveredSkills.skills, this.displayPath, skillLimit, MAX_CONTEXT_SKILL_SUMMARY_CHARS);
48
50
  const result = {
@@ -111,7 +113,7 @@ export class AgentContextManager {
111
113
  if (task.length > 20_000) throw new Error("task exceeds 20000 characters");
112
114
  const state = await this.discoverState(args.path || ".", context);
113
115
  const discovered = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
114
- const maxSkills = clampInt(args.max_skills, 10, 1, 50);
116
+ const maxSkills = clampInteger(args.max_skills, 10, 1, 50);
115
117
  const skillMatches = discovered.skills
116
118
  .map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}`, skill.name) }))
117
119
  .filter((item) => item.score > 0)
@@ -169,7 +171,7 @@ export class AgentContextManager {
169
171
  const state = await this.discoverState(args.path || ".", context);
170
172
  const result = await this.discoverSkills(state, {
171
173
  query: typeof args.query === "string" ? args.query : "",
172
- maxResults: clampInt(args.max_results, 100, 1, MAX_SKILL_RESULTS),
174
+ maxResults: clampInteger(args.max_results, 100, 1, MAX_SKILL_RESULTS),
173
175
  }, context);
174
176
  return {
175
177
  target: this.displayPath(state.target),
@@ -190,7 +192,7 @@ export class AgentContextManager {
190
192
  if (matches.length > 1) throw new Error(`local skill name is ambiguous; use its id: ${requested}`);
191
193
  const skill = matches[0];
192
194
  const content = await readRegularUtf8(skill.entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
193
- const inventory = await listSkillFiles(skill.directory, clampInt(args.max_files, 200, 1, MAX_SKILL_FILES), context, this.throwIfCancelled);
195
+ const inventory = await listSkillFiles(skill.directory, clampInteger(args.max_files, 200, 1, MAX_SKILL_FILES), context, this.throwIfCancelled);
194
196
  return {
195
197
  skill: publicSkill(skill, this.displayPath),
196
198
  instructions: content.text,
@@ -381,7 +383,7 @@ export class AgentContextManager {
381
383
 
382
384
  async discoverSkills(state, options = {}, context = {}) {
383
385
  const query = String(options.query || "").trim().toLowerCase();
384
- const maxResults = clampInt(options.maxResults, 100, 1, MAX_SKILL_RESULTS);
386
+ const maxResults = clampInteger(options.maxResults, 100, 1, MAX_SKILL_RESULTS);
385
387
  const skills = [];
386
388
  const warnings = [];
387
389
  const seenEntrypoints = new Set();
@@ -581,7 +583,7 @@ function normalizeCommand(value, name, configPath) {
581
583
  const description = typeof value.description === "string" ? value.description.trim() : "";
582
584
  if (!description || description.length > 1000) throw new Error(`registered command '${name}' requires a description of at most 1000 characters: ${configPath}`);
583
585
  const cwd = value.cwd === undefined ? "." : requiredString(value.cwd, `commands.${name}.cwd`);
584
- const timeoutSeconds = clampInt(value.timeout_seconds, 120, 1, 600);
586
+ const timeoutSeconds = clampInteger(value.timeout_seconds, 120, 1, 600);
585
587
  if (value.timeout_seconds !== undefined && timeoutSeconds !== value.timeout_seconds) {
586
588
  throw new Error(`registered command '${name}' timeout_seconds must be an integer from 1 to 600: ${configPath}`);
587
589
  }
@@ -877,9 +879,6 @@ function isContainedPath(root, target) {
877
879
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
878
880
  }
879
881
 
880
- function isPlainRecord(value) {
881
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
882
- }
883
882
 
884
883
  function boundedMessage(error) {
885
884
  return String(error?.message || error || "invalid local skill").replace(/[\r\n]+/g, " ").slice(0, 1000);
@@ -897,12 +896,6 @@ function unquoteScalar(value) {
897
896
  return value;
898
897
  }
899
898
 
900
- function clampInt(value, fallback, min, max) {
901
- if (value === undefined || value === null || value === "") return fallback;
902
- const parsed = Number(value);
903
- if (!Number.isInteger(parsed)) return fallback;
904
- return Math.min(Math.max(parsed, min), max);
905
- }
906
899
 
907
900
  function sha256(value) {
908
901
  return createHash("sha256").update(String(value)).digest("hex");
package/src/local/cli.mjs CHANGED
@@ -13,12 +13,11 @@ import { createLocalAdminCommands } from "./cli-local-admin.mjs";
13
13
  export { resolvePolicy } from "./cli-policy.mjs";
14
14
  export { parseArgs, validateCommandOptions, validateLoggingOptions, validatePositionals } from "./cli-options.mjs";
15
15
  import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
16
- import { activeManagedJobs } from "./managed-jobs.mjs";
17
16
  import { run, runWrangler } from "./shell.mjs";
18
17
  import { runFullAccessTest } from "./full-access-test.mjs";
19
- import { readBoundedRegularFileSync } from "./secure-file.mjs";
20
18
  import { inspectProcessInstance } from "./process-identity.mjs";
21
19
  import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
20
+ import { activeStateJobs, activeStateLocks, knownProfileStates, knownWorkerNames } from "./state-inventory.mjs";
22
21
  import { createExclusiveFileSync } from "./exclusive-file.mjs";
23
22
  import {
24
23
  acquireMaintenanceLock,
@@ -908,37 +907,6 @@ async function deleteKnownWorkers(stateRoot) {
908
907
  }
909
908
  }
910
909
 
911
- export function knownWorkerNames(stateRoot) {
912
- const profiles = resolve(expandHome(stateRoot), "profiles");
913
- if (!existsSync(profiles)) return [];
914
- const names = new Set();
915
- for (const entry of readdirSync(profiles, { withFileTypes: true })) {
916
- if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
917
- const profileDir = resolve(profiles, entry.name);
918
- const stateFile = resolve(profileDir, "state.json");
919
- if (!existsSync(stateFile)) {
920
- const evidence = readdirSync(profileDir).some((name) => /^state\.json\.corrupt-/.test(name) || name === "daemon.lock");
921
- if (evidence) throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
922
- continue;
923
- }
924
- let state;
925
- try {
926
- state = JSON.parse(readBoundedRegularFileSync(stateFile, 2 * 1024 * 1024).toString("utf8"));
927
- } catch {
928
- throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
929
- }
930
- if (!state || typeof state !== "object" || Array.isArray(state)) {
931
- throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
932
- }
933
- const name = String(state?.worker?.name || "");
934
- if (name && !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
935
- throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
936
- }
937
- if (name) names.add(name);
938
- }
939
- return [...names];
940
- }
941
-
942
910
  async function removeAutostartBestEffort(stateRoot) {
943
911
  const logger = structuredLogger(false);
944
912
  try {
@@ -963,80 +931,6 @@ async function removeAutostartBestEffort(stateRoot) {
963
931
  }
964
932
  }
965
933
 
966
- export function knownProfileStates(stateRoot) {
967
- const canonicalStateRoot = resolve(expandHome(stateRoot));
968
- const profiles = resolve(canonicalStateRoot, "profiles");
969
- if (!existsSync(profiles)) return [];
970
- const states = [];
971
- const seen = new Set();
972
- for (const entry of readdirSync(profiles, { withFileTypes: true })) {
973
- if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
974
- const profileDir = resolve(profiles, entry.name);
975
- const statePath = resolve(profileDir, "state.json");
976
- const candidates = [];
977
- if (existsSync(statePath)) {
978
- try {
979
- const value = JSON.parse(readBoundedRegularFileSync(statePath, 2 * 1024 * 1024).toString("utf8"));
980
- if (typeof value?.workspace?.path === "string") candidates.push(value.workspace.path);
981
- } catch {}
982
- }
983
- const daemonLock = resolve(profileDir, "daemon.lock");
984
- const daemonOwner = readDaemonLockOwner(daemonLock);
985
- if (existsSync(daemonLock) && !daemonOwner) {
986
- throw new Error(`cannot inspect daemon lock for profile ${entry.name}; service definitions and state were kept`);
987
- }
988
- if (typeof daemonOwner?.workspace === "string") candidates.push(daemonOwner.workspace);
989
- for (const candidate of candidates) {
990
- try {
991
- const workspace = resolveWorkspace(candidate);
992
- if (seen.has(workspace)) break;
993
- states.push({
994
- schemaVersion: 5,
995
- workspace: { path: workspace, hash: entry.name },
996
- paths: { stateRoot: canonicalStateRoot, profileDir, statePath },
997
- });
998
- seen.add(workspace);
999
- break;
1000
- } catch {}
1001
- }
1002
- }
1003
- return states;
1004
- }
1005
-
1006
- function activeStateJobs(stateRoot) {
1007
- const profiles = resolve(expandHome(stateRoot), "profiles");
1008
- if (!existsSync(profiles)) return [];
1009
- const active = [];
1010
- for (const profile of readdirSync(profiles, { withFileTypes: true })) {
1011
- if (!profile.isDirectory()) continue;
1012
- for (const job of activeManagedJobs(resolve(profiles, profile.name, "jobs"))) {
1013
- active.push({ profile: profile.name, ...job });
1014
- }
1015
- }
1016
- return active;
1017
- }
1018
-
1019
- function activeStateLocks(stateRoot) {
1020
- const profiles = resolve(expandHome(stateRoot), "profiles");
1021
- if (!existsSync(profiles)) return [];
1022
- const active = [];
1023
- for (const profile of readdirSync(profiles, { withFileTypes: true })) {
1024
- if (!profile.isDirectory()) continue;
1025
- for (const [kind, name] of [["daemon", "daemon.lock"], ["startup", "startup.lock"]]) {
1026
- const lockPath = resolve(profiles, profile.name, name);
1027
- if (!existsSync(lockPath)) continue;
1028
- const owner = readDaemonLockOwner(lockPath);
1029
- if (!owner) {
1030
- active.push({ kind, pid: null, path: lockPath, reason: "invalid_or_unreadable_lock" });
1031
- continue;
1032
- }
1033
- const identity = inspectProcessInstance(owner, { maxAgeMs: kind === "startup" ? 2 * 60 * 60 * 1000 : Number.POSITIVE_INFINITY });
1034
- if (identity.current || (identity.alive && !identity.reclaimable)) active.push({ kind, pid: owner.pid, path: lockPath, reason: identity.reason });
1035
- }
1036
- }
1037
- return active;
1038
- }
1039
-
1040
934
  function structuredLogger(quiet) {
1041
935
  return createLogger({ quiet, component: "service" });
1042
936
  }
@@ -13,13 +13,15 @@ const DEFAULT_TAKEOVER_POLL_MS = 100;
13
13
 
14
14
  export async function acquireDaemonLockWithTakeover(state, options = {}) {
15
15
  const ownerMetadata = options.ownerMetadata || {};
16
+ const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
17
+ const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
16
18
  let lock = acquireDaemonLock(state, ownerMetadata);
17
19
  if (lock.acquired || !options.takeOverServiceOwner) return lock;
18
20
 
19
21
  const stopped = await stopWorkspaceServiceDaemon(state, {
20
22
  owner: lock.owner,
21
- timeoutMs: options.timeoutMs,
22
- pollMs: options.pollMs,
23
+ timeoutMs,
24
+ pollMs,
23
25
  logger: options.logger,
24
26
  reason: "foreground startup",
25
27
  });
@@ -29,8 +31,18 @@ export async function acquireDaemonLockWithTakeover(state, options = {}) {
29
31
  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
32
  }
31
33
 
32
- lock = acquireDaemonLock(state, ownerMetadata);
33
- if (lock.acquired) options.logger?.info?.("background daemon stopped; foreground startup is taking over the workspace");
34
+ // A terminated daemon can release its token immediately before the filesystem
35
+ // makes the lock removal visible to this process. Retry only this handoff.
36
+ const handoffDeadline = Date.now() + Math.min(timeoutMs, 1_000);
37
+ do {
38
+ lock = acquireDaemonLock(state, ownerMetadata);
39
+ if (lock.acquired) {
40
+ options.logger?.info?.("background daemon stopped; foreground startup is taking over the workspace");
41
+ return lock;
42
+ }
43
+ if (lock.owner?.pid && isPidAlive(lock.owner.pid)) return lock;
44
+ await sleep(Math.min(pollMs, Math.max(1, handoffDeadline - Date.now())));
45
+ } while (Date.now() < handoffDeadline);
34
46
  return lock;
35
47
  }
36
48
 
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { constants as fsConstants } from "node:fs";
3
- import { lstat, open, opendir } from "node:fs/promises";
2
+ import { lstat, opendir } from "node:fs/promises";
4
3
  import { join, relative, resolve, sep } from "node:path";
5
4
  import { packageScriptDisplayCommand, readProjectPackageMetadata, safeVersionValue } from "./project-package.mjs";
5
+ import { isRegularNonSymlink, readOptionalRegularUtf8, safeSingleLine, skippableMetadataError } from "./project-metadata.mjs";
6
6
 
7
7
  const MAX_PROJECT_CONTEXT_BYTES = 16 * 1024;
8
8
  const MAX_SCRIPT_NAMES = 24;
@@ -208,36 +208,6 @@ async function listWorkflowFiles(root, throwIfCancelled) {
208
208
  return files.sort((left, right) => left.localeCompare(right));
209
209
  }
210
210
 
211
- async function isRegularNonSymlink(filePath) {
212
- const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
213
- return Boolean(info && !info.isSymbolicLink() && info.isFile());
214
- }
215
-
216
- async function readOptionalRegularUtf8(filePath, maxBytes) {
217
- const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
218
- if (!info || info.isSymbolicLink() || !info.isFile() || info.size > maxBytes) return null;
219
- const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0)).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
220
- if (!handle) return null;
221
- try {
222
- const current = await handle.stat();
223
- if (!current.isFile() || current.size > maxBytes) return null;
224
- const buffer = Buffer.alloc(current.size);
225
- let offset = 0;
226
- while (offset < buffer.length) {
227
- const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
228
- if (!bytesRead) break;
229
- offset += bytesRead;
230
- }
231
- try {
232
- return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
233
- } catch {
234
- return null;
235
- }
236
- } finally {
237
- await handle.close();
238
- }
239
- }
240
-
241
211
  function virtualInstruction(source, content, precedence, scope) {
242
212
  return {
243
213
  scope,
@@ -249,19 +219,6 @@ function virtualInstruction(source, content, precedence, scope) {
249
219
  };
250
220
  }
251
221
 
252
- function skippableMetadataError(error) {
253
- return ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "EBUSY"].includes(error?.code);
254
- }
255
-
256
- function safeSingleLine(value, maxLength) {
257
- if (typeof value !== "string") return "";
258
- return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
259
- }
260
-
261
222
  function escapeInlineCode(value) {
262
223
  return String(value).replaceAll("`", "'");
263
224
  }
264
-
265
- function isPlainRecord(value) {
266
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
267
- }
@@ -1,6 +1,7 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, relative, sep } from "node:path";
3
3
  import { BridgeError } from "./errors.mjs";
4
+ import { clampInteger } from "./numbers.mjs";
4
5
 
5
6
  export class GitService {
6
7
  constructor({ resolveExistingPath, displayPath, runProcess, maximumBytes }) {
@@ -20,7 +21,7 @@ export class GitService {
20
21
  }
21
22
 
22
23
  async diff(args = {}, context = {}) {
23
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
24
+ const maxBytes = clampInteger(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
24
25
  const git = await this.context(args.path || ".", context);
25
26
  if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
26
27
  const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "diff", "--no-ext-diff", "--no-textconv"];
@@ -33,7 +34,7 @@ export class GitService {
33
34
  async log(args = {}, context = {}) {
34
35
  const git = await this.context(args.path || ".", context);
35
36
  if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
36
- const maxCount = clampInt(args.max_count, 20, 1, 100);
37
+ const maxCount = clampInteger(args.max_count, 20, 1, 100);
37
38
  const format = "%H%x1f%h%x1f%aI%x1f%an%x1f%ae%x1f%s%x1e";
38
39
  const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "log", `--max-count=${maxCount}`, `--format=${format}`];
39
40
  if (git.pathspec) commandArgs.push("--", git.pathspec);
@@ -51,7 +52,7 @@ export class GitService {
51
52
  const git = await this.context(args.path || ".", context);
52
53
  if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
53
54
  const revision = validateRevision(args.revision || "HEAD");
54
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
55
+ const maxBytes = clampInteger(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
55
56
  const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "show", "--no-ext-diff", "--no-textconv", "--decorate=no", revision];
56
57
  if (git.pathspec) commandArgs.push("--", git.pathspec);
57
58
  const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
@@ -80,9 +81,3 @@ function validateRevision(value) {
80
81
  }
81
82
  return revision;
82
83
  }
83
-
84
- function clampInt(value, fallback, minimum, maximum) {
85
- const parsed = Number.parseInt(String(value ?? ""), 10);
86
- const number = Number.isFinite(parsed) ? parsed : fallback;
87
- return Math.min(Math.max(number, minimum), maximum);
88
- }