@henols/vice-mcp 0.1.12 → 0.2.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/THIRD-PARTY-NOTICES.md +23 -0
- package/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/package.json +26 -1
- package/r2000-acme-ident.ts +97 -0
- package/r2000-cli.ts +1103 -0
- package/r2000-confidence.ts +233 -0
- package/r2000-d64.ts +310 -0
- package/r2000-enum-gen.ts +574 -0
- package/r2000-launch.ts +357 -0
- package/r2000-mcp-client.ts +596 -0
- package/r2000-memmap-render.ts +531 -0
- package/r2000-project.ts +190 -0
- package/r2000-regbits-gen.ts +416 -0
- package/r2000-regbits.json +1370 -0
- package/r2000-symbols.ts +388 -0
- package/r2000-tools.ts +914 -0
- package/r2000-verify.ts +184 -0
- package/resources/backend-detect.mjs +30 -2
- package/resources/broker-launch.mjs +166 -34
- package/resources/vice-broker.mjs +25 -4
- package/stock-cia.ts +598 -0
- package/stock-connect.ts +137 -20
- package/stock-derived.ts +67 -14
- package/stock-diagnose.ts +994 -0
- package/stock-dispatch.ts +105 -4
- package/stock-handler.ts +29 -0
- package/stock-memory-search.ts +441 -0
- package/stock-memory.ts +92 -13
- package/stock-protocol.ts +328 -0
- package/stock-recycle.ts +500 -0
- package/stock-run-until.ts +400 -0
- package/stock-runstate.ts +46 -2
- package/stock-sprites.ts +712 -0
- package/stock-symbols.ts +455 -0
- package/stock-timing.ts +562 -0
- package/stock-vicii.ts +318 -0
- package/tools-manifest.stock.json +3031 -223
- package/version.ts +279 -0
- package/vice-proxy.ts +195 -6
package/version.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// The ONE authoritative implementation of this project's version-resolution
|
|
2
|
+
// algorithm (D-5). Before this file existed the repo carried FOUR
|
|
3
|
+
// hand-maintained version strings and none of them were true:
|
|
4
|
+
// `.claude/mcp/vice/package.json` and `installer/package.json` were stale
|
|
5
|
+
// placeholders CI never touched between releases, `.claude-plugin/plugin.json`
|
|
6
|
+
// was bumped by NO automation at all, and `vice-proxy.ts`'s own
|
|
7
|
+
// `PROXY_VERSION` literal -- advertised to every MCP client over
|
|
8
|
+
// `initialize` -- sat twelve patches behind npm's actual `latest`. This file
|
|
9
|
+
// exists so there is exactly one place that (a) parses the repo-root
|
|
10
|
+
// `VERSION` template, (b) resolves it against a published version per D-2's
|
|
11
|
+
// four rules, and (c) answers "what version am I, right now, at runtime"
|
|
12
|
+
// (D-4) -- every other consumer (the CLI in `scripts/version.mjs`,
|
|
13
|
+
// `vice-proxy.ts`'s `PROXY_VERSION`, CI's `release-on-merge` job) calls INTO
|
|
14
|
+
// this module rather than re-deriving any part of the algorithm locally.
|
|
15
|
+
//
|
|
16
|
+
// Do NOT reimplement the resolution rules (`pinned` / `no-published` /
|
|
17
|
+
// `prefix-differs` / `prefix-matches`) anywhere else in this repo -- not in
|
|
18
|
+
// `scripts/version.mjs`, not in CI YAML, not in `installer/bin/cli.mjs`.
|
|
19
|
+
// `version.test.ts`'s "single-implementation guard" test greps
|
|
20
|
+
// `scripts/version.mjs` for the rule literals to catch exactly that
|
|
21
|
+
// regression.
|
|
22
|
+
//
|
|
23
|
+
// Do NOT import `repo-root.ts` from this file. That module's `repoRoot()`
|
|
24
|
+
// carries a documented side effect (`ensureResourcesInstalled()`, fired at
|
|
25
|
+
// module-load time) this seam must never trigger just by being imported --
|
|
26
|
+
// `scripts/version.mjs` and any future test that imports this module for
|
|
27
|
+
// its pure functions alone would otherwise deploy host launcher resources as
|
|
28
|
+
// a side effect of asking what version something is. Per this repo's own
|
|
29
|
+
// convention (see `hostpath.ts`/`containerpath.ts`), the workspace root
|
|
30
|
+
// arrives here as an explicit argument (`readTemplate(repoRootDir)`) or a
|
|
31
|
+
// caller-supplied lazy thunk (`runtimeVersion({ repoRoot })`), never as an
|
|
32
|
+
// import this module resolves itself.
|
|
33
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** The single self-evident placeholder every derived, publishable version
|
|
37
|
+
* string (the two npm package.json `.version` fields, the installer's
|
|
38
|
+
* `@henols/vice-mcp` dependency pin, and the three plugin-manifest version
|
|
39
|
+
* fields) carries in the working tree (R-2). Valid semver -- `npm pack`
|
|
40
|
+
* accepts it -- but unmistakably not a release. Overwritten at publish time
|
|
41
|
+
* by `npm version` (npm packages) or `scripts/version.mjs stamp` (plugin
|
|
42
|
+
* manifests); never hand-edited. */
|
|
43
|
+
export const DEV_PLACEHOLDER = "0.0.0-dev";
|
|
44
|
+
|
|
45
|
+
export type ResolveRule = "pinned" | "no-published" | "prefix-differs" | "prefix-matches";
|
|
46
|
+
|
|
47
|
+
export interface ResolveResult {
|
|
48
|
+
version: string;
|
|
49
|
+
rule: ResolveRule;
|
|
50
|
+
template: string;
|
|
51
|
+
published: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Leading zeros are rejected (bare "0" is the only zero-value exception) --
|
|
55
|
+
// SemVer 2.0.0 SS2 forbids leading-zero numeric identifiers, and this
|
|
56
|
+
// component is echoed VERBATIM into the resolved string for literal (non-`-`)
|
|
57
|
+
// slots, so "1.00.0" must be rejected here rather than surfacing later as an
|
|
58
|
+
// npm-publish-time failure (MED-2).
|
|
59
|
+
const TEMPLATE_COMPONENT = /^(0|[1-9]\d*|-)$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse a version TEMPLATE (not a resolved version) into its three
|
|
63
|
+
* dot-separated components. Each component must be either a non-negative
|
|
64
|
+
* integer literal or the literal string `-` (an auto-managed slot, D-2).
|
|
65
|
+
* Throws on anything else: wrong component count, a non-numeric/non-`-`
|
|
66
|
+
* component, a blank/whitespace-only string, OR a literal component
|
|
67
|
+
* appearing after a `-` component (MED-1) -- D-2's "literal prefix" wording,
|
|
68
|
+
* and every row of CONTEXT.md's worked-example table, only ever describe
|
|
69
|
+
* dashes trailing literals, never leading or interleaved. A shape like
|
|
70
|
+
* "-.2.3" or "0.-.2" has no defined resolution semantics and must be
|
|
71
|
+
* rejected at the one seam that owns validating the hand-edited VERSION
|
|
72
|
+
* file, not silently resolved into something CONTEXT.md never specified.
|
|
73
|
+
*/
|
|
74
|
+
export function parseTemplate(raw: string): string[] {
|
|
75
|
+
const trimmed = raw.trim();
|
|
76
|
+
if (trimmed === "") {
|
|
77
|
+
throw new Error(`version.ts: malformed template ${JSON.stringify(raw)} -- empty`);
|
|
78
|
+
}
|
|
79
|
+
const parts = trimmed.split(".");
|
|
80
|
+
if (parts.length !== 3) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`version.ts: malformed template ${JSON.stringify(raw)} -- expected exactly 3 dot-separated components, got ${parts.length}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
let sawDash = false;
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
if (part === "-") {
|
|
88
|
+
sawDash = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (sawDash) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`version.ts: malformed template ${JSON.stringify(raw)} -- literal component ${JSON.stringify(part)} cannot follow a "-" component; dashes only trail literals (D-2)`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!TEMPLATE_COMPONENT.test(part)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`version.ts: malformed template ${JSON.stringify(raw)} -- component ${JSON.stringify(part)} is neither an integer (no leading zeros) nor "-"`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return parts;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse a PUBLISHED version string into a 3-tuple of numbers, stripping any
|
|
107
|
+
* `-prerelease` / `+build` suffix first. Returns null (never throws) when
|
|
108
|
+
* the input is null, not parseable, or does not have exactly 3 numeric
|
|
109
|
+
* dot-separated components -- callers treat null exactly like "nothing is
|
|
110
|
+
* published" (D-2 rule 4).
|
|
111
|
+
*/
|
|
112
|
+
function parsePublished(published: string | null): number[] | null {
|
|
113
|
+
if (published == null) return null;
|
|
114
|
+
const core = published.split(/[-+]/, 1)[0];
|
|
115
|
+
const parts = core.split(".");
|
|
116
|
+
if (parts.length !== 3) return null;
|
|
117
|
+
// Strict plain-decimal-digit validation (MED-4) -- deliberately NOT
|
|
118
|
+
// `Number(p)` + `Number.isInteger`, which also accepts hex-like literals
|
|
119
|
+
// ("0x2" -> 2), exponential notation ("5e2" -> 500), and whitespace-padded
|
|
120
|
+
// numbers. Those would silently coerce malformed `--published` input or an
|
|
121
|
+
// unexpected `npm view` response into a number that doesn't reflect the
|
|
122
|
+
// original text; this must instead fall back to null (== "no-published",
|
|
123
|
+
// D-2 rule 4) exactly like any other unparseable input, matching the
|
|
124
|
+
// strict digit validation `parseTemplate`'s `TEMPLATE_COMPONENT` already
|
|
125
|
+
// uses for the same kind of input.
|
|
126
|
+
if (!parts.every((p) => /^\d+$/.test(p))) return null;
|
|
127
|
+
return parts.map((p) => Number(p));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The D-2 resolution algorithm, in full. `template` must already be
|
|
132
|
+
* well-formed (call `parseTemplate` first, or pass through unchanged --
|
|
133
|
+
* this function calls it internally so a malformed template always throws
|
|
134
|
+
* here too). `published` is the raw published-version string (or null);
|
|
135
|
+
* this function does its own stripping/parsing via `parsePublished`.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveVersion(template: string, published: string | null): ResolveResult {
|
|
138
|
+
const components = parseTemplate(template);
|
|
139
|
+
|
|
140
|
+
if (!components.includes("-")) {
|
|
141
|
+
return { version: components.join("."), rule: "pinned", template, published };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const pub = parsePublished(published);
|
|
145
|
+
|
|
146
|
+
if (pub === null) {
|
|
147
|
+
const resolved = components.map((c) => (c === "-" ? "0" : c));
|
|
148
|
+
return { version: resolved.join("."), rule: "no-published", template, published };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const prefixMatches = components.every((c, i) => c === "-" || Number(c) === pub[i]);
|
|
152
|
+
|
|
153
|
+
if (!prefixMatches) {
|
|
154
|
+
const resolved = components.map((c) => (c === "-" ? "0" : c));
|
|
155
|
+
return { version: resolved.join("."), rule: "prefix-differs", template, published };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let firstDashSeen = false;
|
|
159
|
+
const resolved = components.map((c, i) => {
|
|
160
|
+
if (c !== "-") return c;
|
|
161
|
+
if (!firstDashSeen) {
|
|
162
|
+
firstDashSeen = true;
|
|
163
|
+
// Guard (LOW-1): `pub[i] + 1` on a published component at
|
|
164
|
+
// Number.MAX_SAFE_INTEGER would silently lose precision to float
|
|
165
|
+
// rounding and could fail to actually increment, violating this
|
|
166
|
+
// seam's "always publishes something new" invariant. Purely
|
|
167
|
+
// theoretical for real-world semver (patch counts in the billions are
|
|
168
|
+
// not realistic) but the arithmetic is load-bearing enough to fail
|
|
169
|
+
// loud rather than silently miscompute.
|
|
170
|
+
if (pub[i] >= Number.MAX_SAFE_INTEGER) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`version.ts: published component ${pub[i]} at index ${i} is at or beyond Number.MAX_SAFE_INTEGER -- refusing to increment (would lose precision)`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return String(pub[i] + 1);
|
|
176
|
+
}
|
|
177
|
+
return "0";
|
|
178
|
+
});
|
|
179
|
+
return { version: resolved.join("."), rule: "prefix-matches", template, published };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Compare two plain, fully-resolved (no `-`, no prerelease) semver-shaped
|
|
184
|
+
* version strings as a numeric 3-tuple. Returns -1/0/1. Throws if either
|
|
185
|
+
* side does not parse as 3 numeric components -- this is for comparing
|
|
186
|
+
* RESOLVED versions, not templates.
|
|
187
|
+
*/
|
|
188
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
|
189
|
+
const pa = parsePublished(a);
|
|
190
|
+
const pb = parsePublished(b);
|
|
191
|
+
if (pa === null) throw new Error(`version.ts: compareVersions() cannot parse ${JSON.stringify(a)}`);
|
|
192
|
+
if (pb === null) throw new Error(`version.ts: compareVersions() cannot parse ${JSON.stringify(b)}`);
|
|
193
|
+
for (let i = 0; i < 3; i++) {
|
|
194
|
+
if (pa[i] < pb[i]) return -1;
|
|
195
|
+
if (pa[i] > pb[i]) return 1;
|
|
196
|
+
}
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read `<repoRootDir>/VERSION`, trimmed. Returns the empty string for an
|
|
202
|
+
* existing-but-blank (or whitespace-only) file, and null ONLY when the file
|
|
203
|
+
* is absent (never throws on either outcome) -- do not treat `=== null` as
|
|
204
|
+
* the sole "no template" signal; an empty string is also "no usable
|
|
205
|
+
* template", and every current caller checks for it via truthiness (LOW-3).
|
|
206
|
+
* Does NOT validate the template shape -- `resolveVersion`/`parseTemplate`
|
|
207
|
+
* do that; this function's only job is the filesystem read.
|
|
208
|
+
*/
|
|
209
|
+
export function readTemplate(repoRootDir: string): string | null {
|
|
210
|
+
const path = join(repoRootDir, "VERSION");
|
|
211
|
+
if (!existsSync(path)) return null;
|
|
212
|
+
try {
|
|
213
|
+
return readFileSync(path, "utf8").trim();
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface RuntimeVersionOptions {
|
|
220
|
+
pkgJsonPath?: string;
|
|
221
|
+
/** LAZY -- see this file's header and the precedence note below. Only
|
|
222
|
+
* called when `pkgJsonPath`'s own version is absent or is
|
|
223
|
+
* DEV_PLACEHOLDER, so a published tarball (which has a real version and
|
|
224
|
+
* no repo-root VERSION file) never calls this and never risks the
|
|
225
|
+
* stderr note `repoRoot()` implementations may emit. */
|
|
226
|
+
repoRoot?: () => string | undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* D-4's runtime precedence, synchronous and never throwing:
|
|
231
|
+
*
|
|
232
|
+
* 1. `pkgJsonPath`'s own `.version`, when present and not DEV_PLACEHOLDER
|
|
233
|
+
* -- the published-tarball path: `npm version` already stamped it.
|
|
234
|
+
* 2. Otherwise call `opts.repoRoot?.()` and, if it yields a directory,
|
|
235
|
+
* read that directory's `VERSION` template. A pinned template (no `-`)
|
|
236
|
+
* returns verbatim; a template containing `-` resolves every `-` to 0
|
|
237
|
+
* and appends a `-dev` prerelease tag, so a dev checkout never claims
|
|
238
|
+
* to be a release build.
|
|
239
|
+
* 3. Otherwise DEV_PLACEHOLDER.
|
|
240
|
+
*
|
|
241
|
+
* The whole body is wrapped in try/catch: any unexpected failure (a
|
|
242
|
+
* malformed package.json, a repoRoot() thunk that throws, a malformed
|
|
243
|
+
* VERSION template) degrades to DEV_PLACEHOLDER rather than crashing the
|
|
244
|
+
* caller -- this is the one runtime-facing entry point in this file, and a
|
|
245
|
+
* standalone MCP server must never fail to start over a version string.
|
|
246
|
+
*/
|
|
247
|
+
export function runtimeVersion(opts: RuntimeVersionOptions = {}): string {
|
|
248
|
+
try {
|
|
249
|
+
if (opts.pkgJsonPath) {
|
|
250
|
+
try {
|
|
251
|
+
const raw = readFileSync(opts.pkgJsonPath, "utf8");
|
|
252
|
+
const pkg = JSON.parse(raw) as { version?: unknown };
|
|
253
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0 && pkg.version !== DEV_PLACEHOLDER) {
|
|
254
|
+
return pkg.version;
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// No readable/parseable package.json at pkgJsonPath -- fall through.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const root = opts.repoRoot?.();
|
|
262
|
+
if (root) {
|
|
263
|
+
const template = readTemplate(root);
|
|
264
|
+
if (template) {
|
|
265
|
+
const components = parseTemplate(template);
|
|
266
|
+
if (!components.includes("-")) {
|
|
267
|
+
return components.join(".");
|
|
268
|
+
}
|
|
269
|
+
const resolved = components.map((c) => (c === "-" ? "0" : c));
|
|
270
|
+
return `${resolved.join(".")}-dev`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
// Degrade to the placeholder below -- see this function's own doc
|
|
275
|
+
// comment for why nothing here may throw.
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return DEV_PLACEHOLDER;
|
|
279
|
+
}
|
package/vice-proxy.ts
CHANGED
|
@@ -100,6 +100,10 @@ import {
|
|
|
100
100
|
// reconnect ladder.
|
|
101
101
|
import { probeInstance, type ProbeResult } from "./vice-probe.ts";
|
|
102
102
|
import { repoRoot } from "./repo-root.ts";
|
|
103
|
+
// The single version-resolution seam (quick-260819-tsz, D-5) -- PROXY_VERSION
|
|
104
|
+
// below is the only consumer in this file; see version.ts's own header for
|
|
105
|
+
// why this file must never re-derive any part of the algorithm itself.
|
|
106
|
+
import { runtimeVersion } from "./version.ts";
|
|
103
107
|
import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
|
|
104
108
|
// The INVERSE direction (host -> container), for inverting a broker grant's
|
|
105
109
|
// own host-local coordinates before useInstance() ever adopts them (this
|
|
@@ -176,6 +180,131 @@ import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
|
176
180
|
// and every call site.
|
|
177
181
|
import * as backendDetect from "./backend-detect.mts";
|
|
178
182
|
import * as stockDispatch from "./stock-dispatch.ts";
|
|
183
|
+
// Plan 08-02: the single per-backend capability lookup (BACK-05), consumed
|
|
184
|
+
// only inside the CallToolRequestSchema override's tools[name] miss branch
|
|
185
|
+
// below, strictly after the DENY_LIST check -- see the comment at that call
|
|
186
|
+
// site for why the ordering is load-bearing.
|
|
187
|
+
import { capabilityRefusalMessage } from "./capability-registry.ts";
|
|
188
|
+
// Plan 11-05: the curated r2000_* tool surface's DEFINITIONS, imported
|
|
189
|
+
// STATICALLY -- registration below happens synchronously at module scope, so
|
|
190
|
+
// a dynamic import cannot serve it. This costs no child process and no
|
|
191
|
+
// socket: the heavy part (the MCP client, the spawned regenerator2000 child)
|
|
192
|
+
// stays behind r2000-tools.ts's own `await import("./r2000-mcp-client.ts")`
|
|
193
|
+
// inside runR2000Tool() itself, reached only when a tool is actually called.
|
|
194
|
+
import { R2000_TOOL_DEFINITIONS, runR2000Tool } from "./r2000-tools.ts";
|
|
195
|
+
|
|
196
|
+
// ------------------------------------------------------------ r2000 subcommand
|
|
197
|
+
//
|
|
198
|
+
// D-06 / RESEARCH.md Open Question #1 (plan 10-04): `vice-mcp r2000 <verb>` is
|
|
199
|
+
// the ONLY surface that resolves identically across the Claude Code plugin
|
|
200
|
+
// route and both npm-installer routes -- `installer/bin/cli.mjs`'s
|
|
201
|
+
// `viceServerEntry()` always launches this server via `npx` in BOTH
|
|
202
|
+
// npm-installer modes (`--vendor` only pre-resolves the package; it never
|
|
203
|
+
// places `.claude/mcp/vice/*.ts` as plain files inside a consuming project),
|
|
204
|
+
// so any design resolving a filesystem path to the seam would silently fail
|
|
205
|
+
// to resolve for npm-installed users. This bin is the one surface proven to
|
|
206
|
+
// work in all three routes.
|
|
207
|
+
//
|
|
208
|
+
// This branch runs as the first executable statement of the module body,
|
|
209
|
+
// deliberately ABOVE `ACTIVE_BACKEND`'s backend probe (which shells out to a
|
|
210
|
+
// binary's `--help`), above the manifest read, and above
|
|
211
|
+
// `new MCPServer(...)`/`server.startStdio()` far below -- a CLI invocation
|
|
212
|
+
// must never open a socket, never probe a binary, and never write a byte of
|
|
213
|
+
// JSON-RPC to stdout. WHAT NOT TO DO: never let this branch fall through
|
|
214
|
+
// into the server path, and never print anything on stdout on the server
|
|
215
|
+
// path that a CLI caller could confuse for `r2000` output.
|
|
216
|
+
//
|
|
217
|
+
// Ending the process here is deliberate and is NOT a violation of this
|
|
218
|
+
// file's standing "never end the process from a teardown handler" rule (see
|
|
219
|
+
// that handler's own comment further down): that rule protects the
|
|
220
|
+
// long-lived server's lease-release path, and this branch ends the process
|
|
221
|
+
// before any lease, socket or handler exists. A dynamic import is used
|
|
222
|
+
// (not a static one) so the CLI module is not part of the server's startup
|
|
223
|
+
// cost on the normal, non-`r2000` path.
|
|
224
|
+
//
|
|
225
|
+
// IN-01 (10-REVIEW.md; 11.1-CONTEXT.md AUDIT-01, D-11.1-04): `console.log`/
|
|
226
|
+
// `console.error` writes to `process.stdout`/`process.stderr` are
|
|
227
|
+
// ASYNCHRONOUS on POSIX once the fd is a pipe (Node opens pipe/socket fds
|
|
228
|
+
// non-blocking, unlike a TTY or a regular file), so a bare `process.exit()`
|
|
229
|
+
// immediately after can discard whatever write has not yet drained --
|
|
230
|
+
// measured at a 128 KiB truncation point on this host's Node for a single
|
|
231
|
+
// write exceeding the OS pipe's capacity. The reachable trigger is
|
|
232
|
+
// `cmdExportAsm`'s `console.error(result.stderr)` in r2000-cli.ts, which can
|
|
233
|
+
// carry a large diagnostic from the spawned regenerator2000 child: the one
|
|
234
|
+
// case where the user most needs the diagnostic is exactly the case a piped
|
|
235
|
+
// invocation could silently lose it in. `drainStdio()` below explicitly
|
|
236
|
+
// awaits both streams' own pending writes (a `write("", cb)`-style
|
|
237
|
+
// zero-length write's callback fires only once every prior queued write has
|
|
238
|
+
// actually flushed) before the terminating `process.exit(code)`.
|
|
239
|
+
//
|
|
240
|
+
// T-11.1-EXITHANG: the drain is BOUNDED to `R2000_CLI_DRAIN_TIMEOUT_MS`. An
|
|
241
|
+
// exit that hangs forever waiting on a pipe nobody reads is worse than a
|
|
242
|
+
// truncated diagnostic -- this project's standing rule is that a teardown
|
|
243
|
+
// path never becomes a hang (see the "never end the process from a teardown
|
|
244
|
+
// handler" comment above; a BOUNDED drain here does not violate that rule
|
|
245
|
+
// for the same reason the original unbounded `process.exit()` did not: this
|
|
246
|
+
// still runs before any lease, socket or handler exists, and now also can
|
|
247
|
+
// never block indefinitely).
|
|
248
|
+
const R2000_CLI_DRAIN_TIMEOUT_MS = 300;
|
|
249
|
+
|
|
250
|
+
/** Resolves once `stream`'s own pending writes have flushed, or after
|
|
251
|
+
* `timeoutMs`, whichever comes first. A zero-length `write("", cb)`'s
|
|
252
|
+
* callback fires strictly after every write queued ahead of it on the same
|
|
253
|
+
* stream has completed -- so this is a genuine drain barrier, not a fixed
|
|
254
|
+
* sleep. Guarded so a stream that is not writable (already closed/ended,
|
|
255
|
+
* e.g. under `> /dev/null` teardown races) resolves immediately rather than
|
|
256
|
+
* calling `write()` on it. */
|
|
257
|
+
function drainStdio(stream: NodeJS.WriteStream): Promise<void> {
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
if (!stream.writable) {
|
|
260
|
+
resolve();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const timer = setTimeout(resolve, R2000_CLI_DRAIN_TIMEOUT_MS);
|
|
264
|
+
stream.write("", () => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
resolve();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (process.argv[2] === "r2000") {
|
|
272
|
+
// A broken pipe (the reader closing early, e.g. `| head`) makes
|
|
273
|
+
// `stream.write()` fail with EPIPE. Awaiting `drainStdio()` below gives
|
|
274
|
+
// that failure the chance to actually surface as Node's stream 'error'
|
|
275
|
+
// event -- which throws UNCAUGHT and crashes the process if nothing is
|
|
276
|
+
// listening, converting what used to be a silent (bare `process.exit()`
|
|
277
|
+
// outran the async error) success into a stack-trace crash. This
|
|
278
|
+
// mirrors the SAME EPIPE class this file already guards against for the
|
|
279
|
+
// server path further down (see that handler's own
|
|
280
|
+
// modelcontextprotocol/typescript-sdk#1564 citation) -- registered here
|
|
281
|
+
// too because this branch exits long before reaching that one.
|
|
282
|
+
process.stdout.on("error", () => {});
|
|
283
|
+
process.stderr.on("error", () => {});
|
|
284
|
+
|
|
285
|
+
// Test-only escape hatch, never documented to end users and inert unless
|
|
286
|
+
// this exact env var is set: writes a deterministic filler payload
|
|
287
|
+
// through this SAME drained-exit path, so vice-proxy.test.ts can measure
|
|
288
|
+
// an exact byte count well above any OS pipe capacity without needing a
|
|
289
|
+
// real regenerator2000 project (empirically, neither `--help`'s ~5.6 KB
|
|
290
|
+
// USAGE text nor a synthesized/garbage `.regen2000proj` fed to
|
|
291
|
+
// export-asm's error path scales anywhere near 128 KiB on this host's
|
|
292
|
+
// regenerator2000 0.9.20 -- both were measured before this hatch was
|
|
293
|
+
// added; see 11.1-05-SUMMARY.md for the measurements). Never reachable
|
|
294
|
+
// from a real `r2000 <verb>` invocation: the check is against a specific,
|
|
295
|
+
// unambiguous env var name no real caller would ever set.
|
|
296
|
+
const testFillBytes = process.env.VICE_TEST_R2000_CLI_STDOUT_FILL_BYTES;
|
|
297
|
+
if (testFillBytes) {
|
|
298
|
+
process.stdout.write("x".repeat(Number(testFillBytes)));
|
|
299
|
+
await Promise.all([drainStdio(process.stdout), drainStdio(process.stderr)]);
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const { runR2000Cli } = await import("./r2000-cli.ts");
|
|
304
|
+
const code = await runR2000Cli(process.argv.slice(3));
|
|
305
|
+
await Promise.all([drainStdio(process.stdout), drainStdio(process.stderr)]);
|
|
306
|
+
process.exit(code);
|
|
307
|
+
}
|
|
179
308
|
|
|
180
309
|
const HERE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
181
310
|
|
|
@@ -254,8 +383,19 @@ process.stdout.on("error", (err) => {
|
|
|
254
383
|
// override that preserves this file's own `{content, isError}` wire
|
|
255
384
|
// contract exactly (see this plan's "Ground truth" section for why
|
|
256
385
|
// MCPServer's OWN tools/call dispatch cannot be used as-is). PROXY_VERSION
|
|
257
|
-
//
|
|
258
|
-
|
|
386
|
+
// no longer survives as a hand-edited literal (quick-260819-tsz, D-4/D-5):
|
|
387
|
+
// it used to say "0.1.0" while npm's actual `latest` was twelve patches
|
|
388
|
+
// ahead, because nothing updated it. It is now derived through
|
|
389
|
+
// `runtimeVersion()` (the ONE seam, `./version.ts`), which reads this
|
|
390
|
+
// package's own `package.json` first (the published-tarball path, where
|
|
391
|
+
// `npm version` already stamped a real number) and falls back to the
|
|
392
|
+
// repo-root `VERSION` template -- rendered as `<resolved>-dev` -- only in a
|
|
393
|
+
// git checkout, degrading to `0.0.0-dev` if neither is available. Reused,
|
|
394
|
+
// unchanged, as MCPServer's own `version` field below.
|
|
395
|
+
const PROXY_VERSION = runtimeVersion({
|
|
396
|
+
pkgJsonPath: join(HERE_DIR, "package.json"),
|
|
397
|
+
repoRoot: () => repoRoot(),
|
|
398
|
+
});
|
|
259
399
|
|
|
260
400
|
// --------------------------------------------------------------- tools/list
|
|
261
401
|
//
|
|
@@ -3176,7 +3316,11 @@ function buildBackendAwareTool(def: ToolDefinition, forkRun: (args: Record<strin
|
|
|
3176
3316
|
}
|
|
3177
3317
|
|
|
3178
3318
|
const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
|
|
3179
|
-
for
|
|
3319
|
+
// Read ONCE and reused below for both the manifest loop and the two
|
|
3320
|
+
// synthetic registrations' own resolveAdvertisedToolDefinition() calls --
|
|
3321
|
+
// never re-read per registration (WR-07, plan 07-16).
|
|
3322
|
+
const manifestTools = readManifestTools();
|
|
3323
|
+
for (const def of manifestTools) {
|
|
3180
3324
|
if (DENY_LIST.includes(def.name)) continue;
|
|
3181
3325
|
tools[def.name] = buildBackendAwareTool(def, (args) => forwardToVice(def.name, args));
|
|
3182
3326
|
}
|
|
@@ -3187,9 +3331,41 @@ for (const def of readManifestTools()) {
|
|
|
3187
3331
|
tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
|
|
3188
3332
|
// Backend-AWARE (CR-07): both of these gather evidence over the fork's HTTP
|
|
3189
3333
|
// transport, so on stock they are refused by name rather than advertised and
|
|
3190
|
-
// then failed at the wire.
|
|
3191
|
-
|
|
3192
|
-
|
|
3334
|
+
// then failed at the wire. WR-07 (plan 07-16): the ADVERTISED definition is
|
|
3335
|
+
// now also backend-aware -- resolveAdvertisedToolDefinition() picks the
|
|
3336
|
+
// corrected stock manifest entry on stock (falling back to the synthetic
|
|
3337
|
+
// definition when the manifest has none) and always returns the synthetic
|
|
3338
|
+
// definition unchanged on the fork, so RECYCLE_TOOL/DIAGNOSE_TOOL's literal
|
|
3339
|
+
// fork-worded text below no longer overwrites the stock manifest's own
|
|
3340
|
+
// entry in `tools/list`.
|
|
3341
|
+
tools[RECYCLE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(RECYCLE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleRecycle(args));
|
|
3342
|
+
tools[DIAGNOSE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(DIAGNOSE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleDiagnose(args));
|
|
3343
|
+
// Backend-INDEPENDENT by construction (plan 11-05): the r2000_* family never
|
|
3344
|
+
// touches VICE at all -- regenerator2000 is a separate, static-analysis child
|
|
3345
|
+
// process, so there is no fork/stock distinction to make and
|
|
3346
|
+
// buildBackendAwareTool() would be flatly wrong here (there is nothing for it
|
|
3347
|
+
// to dispatch to on either backend). The family is in NEITHER
|
|
3348
|
+
// tools-manifest.json NOR tools-manifest.stock.json: both are regenerated by
|
|
3349
|
+
// refresh-manifest.ts from a live HOST VICE server's own tools/list, and an
|
|
3350
|
+
// r2000 child process is never that host -- a hand-added entry in either
|
|
3351
|
+
// manifest would be silently wiped on the next refresh. Registered here via
|
|
3352
|
+
// buildViceTool() directly (the SAME exception RESULT_CONTINUE_TOOL above
|
|
3353
|
+
// is), so no r2000_* runner can ever reach forwardToVice(), call(), or
|
|
3354
|
+
// ensureViceSession() -- CLAUDE.md's "derived tools must be intercepted
|
|
3355
|
+
// before forwardToVice()" constraint is satisfied by construction for this
|
|
3356
|
+
// family, not by an interception, because the runner is never wired to
|
|
3357
|
+
// forwardToVice() in the first place.
|
|
3358
|
+
// Deliberately NOT named `def` (the manifest loop's own loop variable,
|
|
3359
|
+
// above): `stock-dispatch.test.ts`'s `proxyToolRegistrations()` regex-scans
|
|
3360
|
+
// this file's own `tools[...] = ...;` lines and keys each one by its raw
|
|
3361
|
+
// captured text, so an identically-named loop variable here would make this
|
|
3362
|
+
// registration textually indistinguishable from the manifest loop's -- a
|
|
3363
|
+
// distinct name (`r2000Def`) keeps the r2000 family's own exemption from
|
|
3364
|
+
// `buildBackendAwareTool()` from ever being confused with, or accidentally
|
|
3365
|
+
// widened to cover, the manifest loop's registration.
|
|
3366
|
+
for (const r2000Def of R2000_TOOL_DEFINITIONS) {
|
|
3367
|
+
tools[r2000Def.name] = buildViceTool(r2000Def, (args) => runR2000Tool(r2000Def.name, args));
|
|
3368
|
+
}
|
|
3193
3369
|
|
|
3194
3370
|
const server = new MCPServer({ name: "vice", version: PROXY_VERSION, tools });
|
|
3195
3371
|
await server.startStdio();
|
|
@@ -3243,6 +3419,19 @@ server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3243
3419
|
// .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md.
|
|
3244
3420
|
const tool = tools[name];
|
|
3245
3421
|
if (!tool || !tool.execute) {
|
|
3422
|
+
// Layer 3 (BACK-05, plan 08-02): fires ONLY when the ACTIVE backend's
|
|
3423
|
+
// trimmed manifest (D-07) never registered this name -- i.e. `tools`
|
|
3424
|
+
// has no key for it -- and MUST stay strictly after the DENY_LIST check
|
|
3425
|
+
// above: those four meta-tool names are a confused-deputy bypass hazard
|
|
3426
|
+
// (01.4-01), not a capability gap, and must never be reachable here.
|
|
3427
|
+
// This lookup renders undefined for a genuinely unknown name (or a
|
|
3428
|
+
// same-backend miss), so a real typo still falls through to the generic
|
|
3429
|
+
// message below unchanged. capability-registry.ts is the ONE place to
|
|
3430
|
+
// edit this data -- never hand-add a per-tool special case here.
|
|
3431
|
+
const capabilityRefusal = capabilityRefusalMessage(name, ACTIVE_BACKEND.backend);
|
|
3432
|
+
if (capabilityRefusal !== undefined) {
|
|
3433
|
+
return { content: [{ type: "text", text: capabilityRefusal }], isError: true };
|
|
3434
|
+
}
|
|
3246
3435
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
3247
3436
|
}
|
|
3248
3437
|
try {
|