machine-bridge-mcp 1.2.7 → 1.2.9

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 (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +37 -19
  3. package/README.md +120 -410
  4. package/SECURITY.md +2 -0
  5. package/browser-extension/manifest.json +2 -2
  6. package/docs/ARCHITECTURE.md +13 -4
  7. package/docs/AUDIT.md +22 -0
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/OVERVIEW.md +113 -0
  10. package/docs/PROJECT_STANDARDS.md +9 -5
  11. package/docs/RELEASING.md +78 -33
  12. package/docs/TESTING.md +14 -7
  13. package/docs/THREAT_MODEL.md +142 -0
  14. package/package.json +18 -6
  15. package/scripts/check-plan.mjs +91 -0
  16. package/scripts/coverage-check.mjs +22 -0
  17. package/scripts/github-push.mjs +49 -0
  18. package/scripts/github-release.mjs +15 -8
  19. package/scripts/local-release-acceptance.mjs +186 -0
  20. package/scripts/release-acceptance.mjs +181 -0
  21. package/scripts/release-impact-check.mjs +19 -3
  22. package/scripts/release-state.mjs +1 -1
  23. package/scripts/run-checks.mjs +29 -0
  24. package/scripts/start-release-candidate.mjs +113 -0
  25. package/src/local/agent-context-projection.mjs +158 -0
  26. package/src/local/agent-context.mjs +23 -332
  27. package/src/local/agent-skill-discovery.mjs +230 -0
  28. package/src/local/agent-text-file.mjs +41 -0
  29. package/src/local/browser-bridge-http.mjs +48 -0
  30. package/src/local/browser-bridge.mjs +48 -222
  31. package/src/local/browser-broker-routes.mjs +136 -0
  32. package/src/local/browser-broker-server.mjs +59 -0
  33. package/src/local/browser-request-registry.mjs +67 -0
  34. package/src/local/managed-job-lock.mjs +99 -0
  35. package/src/local/managed-job-projection.mjs +68 -0
  36. package/src/local/managed-job-runner.mjs +73 -0
  37. package/src/local/managed-job-storage.mjs +93 -0
  38. package/src/local/managed-jobs.mjs +12 -297
  39. package/src/local/runtime-paths.mjs +107 -0
  40. package/src/local/runtime-relay.mjs +73 -0
  41. package/src/local/runtime-tool-handlers.mjs +66 -0
  42. package/src/local/runtime.mjs +22 -204
  43. package/src/local/windows-launcher.mjs +57 -0
  44. package/src/local/windows-service.mjs +7 -56
  45. package/src/worker/index.ts +9 -118
  46. package/src/worker/mcp-jsonrpc.ts +94 -0
  47. package/src/worker/oauth-authorization-page.ts +70 -0
  48. package/src/worker/oauth-controller.ts +9 -58
  49. package/src/worker/websocket-protocol.ts +24 -0
  50. package/tsconfig.local.json +6 -1
@@ -0,0 +1,181 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ lstatSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ export const ACCEPTANCE_SCHEMA_VERSION = 1;
13
+ export const ACCEPTANCE_POLICY_VERSION = "1.2.8";
14
+ export const AGENT_VERIFIED_ACCEPTANCE_VERSION = "1.2.9";
15
+ export const ACCEPTANCE_CONFIRMATION = "owner-started-agent-verified-local-candidate";
16
+ export const LEGACY_ACCEPTANCE_CONFIRMATION = "repository-owner-local-test";
17
+ const MAX_ACCEPTANCE_BYTES = 64 * 1024;
18
+
19
+ export function requiresLocalAcceptance(version) {
20
+ return compareVersions(parseVersion(version), parseVersion(ACCEPTANCE_POLICY_VERSION)) >= 0;
21
+ }
22
+
23
+ export function acceptanceConfirmationForVersion(version) {
24
+ return compareVersions(parseVersion(version), parseVersion(AGENT_VERIFIED_ACCEPTANCE_VERSION)) >= 0
25
+ ? ACCEPTANCE_CONFIRMATION
26
+ : LEGACY_ACCEPTANCE_CONFIRMATION;
27
+ }
28
+
29
+ export function acceptancePath(root, version) {
30
+ return join(root, "release-acceptance", `v${version}.json`);
31
+ }
32
+
33
+ export function packProject(root, destination) {
34
+ const npmCli = process.env.npm_execpath;
35
+ if (!npmCli) {
36
+ throw new Error("release acceptance commands must run through npm so npm_execpath is available");
37
+ }
38
+ const result = spawnSync(process.execPath, [
39
+ npmCli,
40
+ "pack",
41
+ "--ignore-scripts",
42
+ "--silent",
43
+ "--json",
44
+ "--pack-destination",
45
+ destination,
46
+ ], {
47
+ cwd: root,
48
+ encoding: "utf8",
49
+ env: process.env,
50
+ windowsHide: true,
51
+ });
52
+ if (result.error) throw result.error;
53
+ if (result.status !== 0) {
54
+ throw new Error(`npm pack failed: ${String(result.stderr || result.stdout).trim()}`);
55
+ }
56
+ let value;
57
+ try {
58
+ value = JSON.parse(result.stdout);
59
+ } catch {
60
+ throw new Error("npm pack did not return valid JSON");
61
+ }
62
+ const pkg = readPackage(root);
63
+ const record = normalizePackRecord(value, pkg.name);
64
+ if (!record) throw new Error("npm pack did not return package metadata");
65
+ const metadata = {
66
+ package_name: pkg.name,
67
+ package_version: pkg.version,
68
+ filename: String(record.filename || ""),
69
+ shasum: String(record.shasum || ""),
70
+ integrity: String(record.integrity || ""),
71
+ };
72
+ validatePackMetadata(metadata);
73
+ verifyTarball(join(destination, metadata.filename), metadata);
74
+ return metadata;
75
+ }
76
+
77
+ export function readAcceptance(root, version) {
78
+ const path = acceptancePath(root, version);
79
+ const stat = lstatSync(path);
80
+ if (stat.isSymbolicLink() || !stat.isFile()) {
81
+ throw new Error(`release acceptance record must be a regular file: ${path}`);
82
+ }
83
+ if (stat.size > MAX_ACCEPTANCE_BYTES) {
84
+ throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
85
+ }
86
+ let value;
87
+ try {
88
+ value = JSON.parse(readFileSync(path, "utf8"));
89
+ } catch (error) {
90
+ throw new Error(`release acceptance record is not valid JSON: ${error.message}`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ export function verifyAcceptanceRecord(record, metadata) {
96
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
97
+ throw new Error("release acceptance record must be an object");
98
+ }
99
+ if (record.schema_version !== ACCEPTANCE_SCHEMA_VERSION) {
100
+ throw new Error(`unsupported release acceptance schema: ${record.schema_version}`);
101
+ }
102
+ if (record.result !== "passed") throw new Error("local release acceptance result is not passed");
103
+ const expectedConfirmation = acceptanceConfirmationForVersion(metadata.package_version);
104
+ if (record.confirmation !== expectedConfirmation) {
105
+ throw new Error("local release acceptance confirmation is missing or does not match the active verification workflow");
106
+ }
107
+ const acceptedAt = Date.parse(String(record.accepted_at || ""));
108
+ if (!Number.isFinite(acceptedAt)) throw new Error("local release acceptance timestamp is invalid");
109
+ if (!/^[0-9a-f]{64}$/.test(String(record.package_content_sha256 || ""))) {
110
+ throw new Error("local release acceptance portable package-content digest is missing or invalid");
111
+ }
112
+ for (const key of ["package_name", "package_version", "filename", "shasum", "integrity"]) {
113
+ if (record[key] !== metadata[key]) {
114
+ throw new Error(`local release acceptance ${key} does not match the current npm package`);
115
+ }
116
+ }
117
+ return record;
118
+ }
119
+
120
+ export function verifyCurrentReleaseAcceptance(root) {
121
+ const pkg = readPackage(root);
122
+ if (!requiresLocalAcceptance(pkg.version)) {
123
+ return { required: false, version: pkg.version };
124
+ }
125
+ const temp = mkdtempSync(join(tmpdir(), "mbm-release-acceptance-"));
126
+ try {
127
+ const metadata = packProject(root, temp);
128
+ const record = readAcceptance(root, pkg.version);
129
+ verifyAcceptanceRecord(record, metadata);
130
+ return { required: true, metadata, record };
131
+ } finally {
132
+ rmSync(temp, { recursive: true, force: true });
133
+ }
134
+ }
135
+
136
+ export function verifyTarball(path, metadata) {
137
+ const stat = lstatSync(path);
138
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("release candidate tarball is not a regular file");
139
+ const bytes = readFileSync(path);
140
+ const shasum = createHash("sha1").update(bytes).digest("hex");
141
+ const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
142
+ if (shasum !== metadata.shasum || integrity !== metadata.integrity) {
143
+ throw new Error("release candidate tarball hash does not match npm pack metadata");
144
+ }
145
+ }
146
+
147
+ export function normalizePackRecord(value, packageName) {
148
+ if (Array.isArray(value)) return value[0] ?? null;
149
+ if (!value || typeof value !== "object") return null;
150
+ if (value[packageName] && typeof value[packageName] === "object") return value[packageName];
151
+ return Object.values(value).find((item) => item && typeof item === "object") ?? null;
152
+ }
153
+
154
+ function readPackage(root) {
155
+ const value = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
156
+ if (typeof value.name !== "string" || !value.name) throw new Error("package.json name is invalid");
157
+ if (!parseVersion(value.version)) throw new Error("package.json version is invalid");
158
+ return value;
159
+ }
160
+
161
+ function validatePackMetadata(metadata) {
162
+ if (!metadata.filename.endsWith(".tgz") || metadata.filename.includes("/") || metadata.filename.includes("\\")) {
163
+ throw new Error("npm pack returned an invalid filename");
164
+ }
165
+ if (!/^[0-9a-f]{40}$/.test(metadata.shasum)) throw new Error("npm pack returned an invalid SHA-1 shasum");
166
+ if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(metadata.integrity)) throw new Error("npm pack returned an invalid SHA-512 integrity value");
167
+ }
168
+
169
+ function parseVersion(value) {
170
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(String(value || ""));
171
+ if (!match) return null;
172
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
173
+ }
174
+
175
+ function compareVersions(left, right) {
176
+ if (!left || !right) throw new Error("version comparison requires semantic versions");
177
+ for (let index = 0; index < 3; index += 1) {
178
+ if (left[index] !== right[index]) return left[index] - right[index];
179
+ }
180
+ return 0;
181
+ }
@@ -25,20 +25,36 @@ const changed = new Set([
25
25
  ...lines(git(["ls-files", "--others", "--exclude-standard"])),
26
26
  ]);
27
27
  const relevant = [...changed].sort();
28
+ const packageRelevant = relevant.filter((path) => isPackageRelevant(path, pkg.files));
28
29
 
29
30
  if (!relevant.length) {
30
- process.stderr.write(`release impact check ok: no release-relevant changes since ${latestTag}\n`);
31
+ process.stderr.write(`release impact check ok: no repository changes since ${latestTag}\n`);
32
+ process.exit(0);
33
+ }
34
+ if (!packageRelevant.length) {
35
+ process.stderr.write(`release impact check ok: ${relevant.length} repository-only file(s) changed since ${latestTag}; npm package content is unchanged\n`);
31
36
  process.exit(0);
32
37
  }
33
38
  if (compareVersions(current, latest) <= 0) {
34
- fail(`release-relevant changes exist since ${latestTag}, but package.json is still ${pkg.version}; bump the npm version and add a CHANGELOG section before merging`);
39
+ fail(`npm-package changes exist since ${latestTag}, but package.json is still ${pkg.version}; bump the npm version and add a CHANGELOG section before merging`);
35
40
  }
36
41
 
37
42
  const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
38
43
  const heading = new RegExp(`^## ${escapeRegExp(pkg.version)}(?:\\s+-[^\\n]*)?$`, "m");
39
44
  if (!heading.test(changelog)) fail(`CHANGELOG.md has no section for ${pkg.version}`);
40
45
 
41
- process.stderr.write(`release impact check ok: ${relevant.length} release-relevant file(s) since ${latestTag}; next npm version ${pkg.version}\n`);
46
+ process.stderr.write(`release impact check ok: ${packageRelevant.length} npm-package file(s) changed since ${latestTag}; next npm version ${pkg.version}\n`);
47
+
48
+ export function isPackageRelevant(path, packageFiles = []) {
49
+ const normalized = String(path || "").replaceAll("\\", "/").replace(/^\.\//, "");
50
+ if (!normalized) return false;
51
+ if (["package.json", "package-lock.json", "npm-shrinkwrap.json"].includes(normalized)) return true;
52
+ for (const entry of packageFiles || []) {
53
+ const candidate = String(entry || "").replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
54
+ if (candidate && (normalized === candidate || normalized.startsWith(`${candidate}/`))) return true;
55
+ }
56
+ return false;
57
+ }
42
58
 
43
59
  function git(args) {
44
60
  try {
@@ -1,7 +1,7 @@
1
1
  export function tagSyncError({ scope = "local", tag, head, commit }) {
2
2
  const label = scope === "remote" ? "remote tag" : "local tag";
3
3
  if (!commit) {
4
- return `${label} ${tag} is missing; run npm run release:publish before npm publish`;
4
+ return `${label} ${tag} is missing; run npm run release before npm publish`;
5
5
  }
6
6
  if (commit !== head) {
7
7
  return `${label} ${tag} points to ${commit}, not HEAD ${head}`;
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+ import { checkTasks } from "./check-plan.mjs";
4
+
5
+ const mode = process.argv[2] || "full";
6
+ const tasks = checkTasks(mode);
7
+ const npmCli = process.env.npm_execpath;
8
+ if (!npmCli) throw new Error("check runner must run through npm so npm_execpath is available");
9
+ const planStartedAt = performance.now();
10
+
11
+ console.log(`running ${mode} verification plan (${tasks.length} tasks)`);
12
+ for (const [index, task] of tasks.entries()) {
13
+ const taskStartedAt = performance.now();
14
+ console.log(`\n[${index + 1}/${tasks.length}] npm run ${task}`);
15
+ const result = spawnSync(process.execPath, [npmCli, "run", task], {
16
+ cwd: process.cwd(),
17
+ env: process.env,
18
+ stdio: "inherit",
19
+ });
20
+ const elapsedSeconds = ((performance.now() - taskStartedAt) / 1000).toFixed(1);
21
+ if (result.error) throw result.error;
22
+ if (result.status !== 0) {
23
+ console.error(`verification task failed after ${elapsedSeconds}s: ${task}`);
24
+ process.exit(result.status || 1);
25
+ }
26
+ console.log(`completed ${task} in ${elapsedSeconds}s`);
27
+ }
28
+ const totalSeconds = ((performance.now() - planStartedAt) / 1000).toFixed(1);
29
+ console.log(`\n${mode} verification plan passed in ${totalSeconds}s`);
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { mkdirSync, readFileSync, rmSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { verifyTarball } from "./release-acceptance.mjs";
8
+
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const candidateDirectory = join(root, ".release-candidate");
11
+ const manifestPath = join(candidateDirectory, "manifest.json");
12
+ const installPrefix = join(candidateDirectory, "runtime");
13
+ const npmCli = process.env.npm_execpath;
14
+
15
+ if (!npmCli) fail("candidate startup must run through npm so npm_execpath is available");
16
+
17
+ try {
18
+ const manifest = readJson(manifestPath, "release candidate manifest");
19
+ if (manifest.result !== "pending") throw new Error("release candidate manifest is not pending");
20
+ const tarball = join(candidateDirectory, String(manifest.filename || ""));
21
+ verifyTarball(tarball, manifest);
22
+
23
+ const npmVersion = runNpm(["--version"], root).stdout.trim();
24
+ if (Number(npmVersion.split(".")[0]) < 12) {
25
+ throw new Error(`candidate startup requires npm 12 or newer; current ${npmVersion}`);
26
+ }
27
+
28
+ rmSync(installPrefix, { recursive: true, force: true });
29
+ mkdirSync(installPrefix, { recursive: true });
30
+ runNpm([
31
+ "install",
32
+ "--global",
33
+ "--prefix", installPrefix,
34
+ "--omit=optional",
35
+ "--allow-scripts=esbuild,workerd,sharp,fsevents",
36
+ tarball,
37
+ ], root);
38
+
39
+ const globalRoot = runNpm(["root", "--global", "--prefix", installPrefix], root).stdout.trim();
40
+ const installedPackage = join(globalRoot, manifest.package_name);
41
+ const installed = readJson(join(installedPackage, "package.json"), "installed candidate package");
42
+ if (installed.version !== manifest.package_version) {
43
+ throw new Error(`installed candidate version ${installed.version} does not match ${manifest.package_version}`);
44
+ }
45
+
46
+ console.log(`Verified candidate tarball: ${manifest.filename} (${manifest.shasum})`);
47
+ console.log(`Installed isolated candidate: ${installedPackage}`);
48
+ const installOnly = process.argv.includes("--install-only");
49
+ const allowWorkerDeploy = process.argv.includes("--allow-worker-deploy");
50
+ if (installOnly) {
51
+ console.log("Candidate installation check passed; startup was skipped by --install-only.");
52
+ process.exit(0);
53
+ }
54
+
55
+ if (!allowWorkerDeploy) {
56
+ throw new Error("foreground candidate startup may update the configured same-name Worker; rerun with --allow-worker-deploy to authorize that live candidate deployment");
57
+ }
58
+
59
+ const forwardedArgs = process.argv.slice(2).filter((value) => value !== "--install-only" && value !== "--allow-worker-deploy");
60
+ const cli = join(installedPackage, "bin", "machine-mcp.mjs");
61
+ console.log("Authorized live candidate deployment: startup may update the configured same-name Worker when its version or deployment hash differs.");
62
+ console.log("Starting the exact candidate in the foreground. Leave this process running while the coding agent verifies the Worker, relay, and local runtime end to end.");
63
+ const child = spawn(process.execPath, [cli, ...forwardedArgs], {
64
+ cwd: root,
65
+ env: process.env,
66
+ stdio: "inherit",
67
+ windowsHide: true,
68
+ });
69
+ for (const signal of ["SIGINT", "SIGTERM"]) {
70
+ process.on(signal, () => {
71
+ try { child.kill(signal); } catch {
72
+ // The child may already have exited.
73
+ }
74
+ });
75
+ }
76
+ child.once("error", (error) => fail(error?.message || error));
77
+ child.once("exit", (code, signal) => {
78
+ if (signal) {
79
+ console.error(`release candidate exited from ${signal}`);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ process.exitCode = code ?? 1;
84
+ });
85
+ } catch (error) {
86
+ fail(error?.message || error);
87
+ }
88
+
89
+ function runNpm(args, cwd) {
90
+ const result = spawnSync(process.execPath, [npmCli, ...args], {
91
+ cwd,
92
+ encoding: "utf8",
93
+ env: process.env,
94
+ timeout: 300_000,
95
+ windowsHide: true,
96
+ });
97
+ if (result.error) throw result.error;
98
+ if (result.status !== 0) throw new Error(`npm ${args[0]} failed: ${result.stderr || result.stdout}`);
99
+ return result;
100
+ }
101
+
102
+ function readJson(path, label) {
103
+ try {
104
+ return JSON.parse(readFileSync(path, "utf8"));
105
+ } catch (error) {
106
+ throw new Error(`${label} is unavailable or invalid: ${error.message}`);
107
+ }
108
+ }
109
+
110
+ function fail(message) {
111
+ console.error(`release candidate startup failed: ${message}`);
112
+ process.exit(1);
113
+ }
@@ -0,0 +1,158 @@
1
+ // @ts-check
2
+ import { createHash } from "node:crypto";
3
+
4
+ /** @typedef {(value: string) => string} DisplayPath */
5
+ /**
6
+ * @typedef {object} InstructionItem
7
+ * @property {string} [source]
8
+ * @property {string} [path]
9
+ * @property {string} scope
10
+ * @property {number} bytes
11
+ * @property {string} sha256
12
+ * @property {number} precedence
13
+ * @property {string} content
14
+ */
15
+ /**
16
+ * @typedef {object} SkillSummary
17
+ * @property {string} id
18
+ * @property {string} name
19
+ * @property {string} description
20
+ * @property {string} entrypoint
21
+ * @property {string} sourceRoot
22
+ * @property {number} bytes
23
+ * @property {string} sha256
24
+ */
25
+ /**
26
+ * @typedef {object} CommandSummary
27
+ * @property {string} name
28
+ * @property {string} description
29
+ * @property {string[]} argv
30
+ * @property {string} cwd
31
+ * @property {number} timeoutSeconds
32
+ * @property {boolean} allowExtraArgs
33
+ * @property {string} source
34
+ * @property {string} [sourceType]
35
+ * @property {string} [script]
36
+ */
37
+ /**
38
+ * @typedef {object} ProjectionState
39
+ * @property {string[]} configFiles
40
+ * @property {InstructionItem | null} builtinInstructions
41
+ * @property {InstructionItem | null} automaticProjectContext
42
+ * @property {InstructionItem | null} modelInstructions
43
+ * @property {InstructionItem[]} instructions
44
+ * @property {Map<string, CommandSummary>} commands
45
+ */
46
+
47
+ /** @param {ProjectionState} state @param {SkillSummary[]} skills */
48
+ export function capabilityFingerprint(state, skills) {
49
+ return sha256(JSON.stringify({
50
+ configs: state.configFiles,
51
+ instructions: [
52
+ state.builtinInstructions?.sha256 || "",
53
+ state.automaticProjectContext?.sha256 || "",
54
+ state.modelInstructions?.sha256 || "",
55
+ ...state.instructions.map((item) => item.sha256),
56
+ ],
57
+ skills: skills.map((skill) => [skill.id, skill.sha256]),
58
+ commands: [...state.commands.values()].map((command) => [command.name, command.argv]),
59
+ }));
60
+ }
61
+
62
+ /** @param {SkillSummary} skill @param {DisplayPath} displayPath */
63
+ export function publicSkill(skill, displayPath) {
64
+ return {
65
+ id: skill.id,
66
+ name: skill.name,
67
+ description: skill.description,
68
+ entrypoint: displayPath(skill.entrypoint),
69
+ source_root: displayPath(skill.sourceRoot),
70
+ bytes: skill.bytes,
71
+ sha256: skill.sha256,
72
+ };
73
+ }
74
+
75
+ /** @param {SkillSummary[]} skills @param {DisplayPath} displayPath @param {number} maxSkills @param {number} budgetChars */
76
+ export function contextSkillSummaries(skills, displayPath, maxSkills, budgetChars) {
77
+ /** @type {Array<ReturnType<typeof publicSkill> & {description_truncated?: boolean}>} */
78
+ const selected = [];
79
+ let used = 0;
80
+ for (const skill of skills) {
81
+ if (selected.length >= maxSkills) return { skills: selected, truncated: true };
82
+ const item = publicSkill(skill, displayPath);
83
+ const fullSize = JSON.stringify(item).length;
84
+ if (used + fullSize <= budgetChars) {
85
+ selected.push(item);
86
+ used += fullSize;
87
+ continue;
88
+ }
89
+ const withoutDescription = { ...item, description: "", description_truncated: true };
90
+ const baseSize = JSON.stringify(withoutDescription).length;
91
+ const available = budgetChars - used - baseSize;
92
+ if (available >= 32) selected.push({ ...withoutDescription, description: item.description.slice(0, available) });
93
+ return { skills: selected, truncated: true };
94
+ }
95
+ return { skills: selected, truncated: false };
96
+ }
97
+
98
+ /** @param {Array<{entrypoint: string, message: string}>} warnings @param {DisplayPath} displayPath */
99
+ export function publicSkillWarnings(warnings, displayPath) {
100
+ return warnings.map((warning) => ({ entrypoint: displayPath(warning.entrypoint), message: warning.message }));
101
+ }
102
+
103
+ /** @param {Map<string, CommandSummary>} commands @param {DisplayPath} displayPath */
104
+ export function publicCommands(commands, displayPath) {
105
+ return [...commands.values()]
106
+ .sort((left, right) => left.name.localeCompare(right.name))
107
+ .map((command) => ({
108
+ name: command.name,
109
+ description: command.description,
110
+ argv: [...command.argv],
111
+ cwd: displayPath(command.cwd),
112
+ timeout_seconds: command.timeoutSeconds,
113
+ allow_extra_args: command.allowExtraArgs,
114
+ source: displayPath(command.source),
115
+ source_type: command.sourceType || "agent-config",
116
+ ...(command.script ? { package_script: command.script } : {}),
117
+ }));
118
+ }
119
+
120
+ /** @param {ProjectionState} state */
121
+ export function effectiveInstructionItems(state) {
122
+ return [
123
+ ...(state.builtinInstructions ? [state.builtinInstructions] : []),
124
+ ...(state.automaticProjectContext ? [state.automaticProjectContext] : []),
125
+ ...(state.modelInstructions ? [state.modelInstructions] : []),
126
+ ...state.instructions,
127
+ ];
128
+ }
129
+
130
+ /** @param {InstructionItem | null} item @param {boolean} includeContent */
131
+ export function publicVirtualInstruction(item, includeContent) {
132
+ if (!item) return null;
133
+ return {
134
+ source: item.source,
135
+ scope: item.scope,
136
+ bytes: item.bytes,
137
+ sha256: item.sha256,
138
+ precedence: item.precedence,
139
+ ...(includeContent ? { content: item.content } : {}),
140
+ };
141
+ }
142
+
143
+ /** @param {InstructionItem[]} instructions @param {DisplayPath} displayPath */
144
+ export function renderEffectiveInstructions(instructions, displayPath) {
145
+ return instructions.map((item) => {
146
+ const source = item.source || displayPath(item.path || "");
147
+ return [
148
+ `--- BEGIN ${source} (precedence ${item.precedence}) ---`,
149
+ item.content,
150
+ `--- END ${source} ---`,
151
+ ].join("\n");
152
+ }).join("\n\n");
153
+ }
154
+
155
+ /** @param {unknown} value */
156
+ export function sha256(value) {
157
+ return createHash("sha256").update(String(value)).digest("hex");
158
+ }