@henols/vice-mcp 0.2.2 → 0.2.4
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/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +253 -108
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/vice.ts
DELETED
|
@@ -1,772 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Single MCP client seam for the host VICE MCP server. Every emulator
|
|
3
|
-
// interaction in this project goes through `call()` -- no other file speaks
|
|
4
|
-
// MCP JSON-RPC or raw HTTP to the VICE endpoint directly.
|
|
5
|
-
//
|
|
6
|
-
// Why a seam at all: Phase 1 tooling and Phase 3's verify/runner.mjs both
|
|
7
|
-
// depend on this one transport. If the handshake shape ever needs to change
|
|
8
|
-
// (session header, SSE framing, a curl fallback), it changes here once.
|
|
9
|
-
//
|
|
10
|
-
// The deny-list is the other reason this file exists: vice_disk_list crashes
|
|
11
|
-
// the shared host MCP server (see CLAUDE.md's hazard note and STATE.md's
|
|
12
|
-
// blocker entry). The guard below runs *before* any request is serialised,
|
|
13
|
-
// so no caller -- however indirect -- can reach that tool by accident.
|
|
14
|
-
import { resolve, join } from "node:path";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
|
|
17
|
-
import { supervisorDir } from "./repo-root.ts";
|
|
18
|
-
import { isInsideContainer, type ContainerGuardDeps } from "./container-guard.mts";
|
|
19
|
-
|
|
20
|
-
// Renamed from ENDPOINT to DEFAULT_ENDPOINT (D-5): a pool lease redirects
|
|
21
|
-
// the seam to a DIFFERENT endpoint at runtime via useInstance() below, so
|
|
22
|
-
// this is only the starting value, never assumed to be the active one.
|
|
23
|
-
//
|
|
24
|
-
// PORT TRIAGE (01.6.2-09, D-18): this 6510 is a KEPT, CORRECT value, not an
|
|
25
|
-
// oversight. 6510-6599 is the band reserved by convention for an x64sc a
|
|
26
|
-
// human launches on the host for their OWN work; when no broker grant
|
|
27
|
-
// exists and no explicit VICE_MCP_URL override is set, this fallback
|
|
28
|
-
// describes exactly that human-launched instance -- which is what the
|
|
29
|
-
// reserved band is now for. Do not "fix" this into the broker's own
|
|
30
|
-
// allocated band (6600+, DEFAULT_BASE_PORT in broker-state.mts) -- that
|
|
31
|
-
// would be the broker squatting a port a human wants, the exact defect
|
|
32
|
-
// D-18 exists to prevent.
|
|
33
|
-
// The host part is resolved through mcpHost() rather than baked in as a
|
|
34
|
-
// literal, for the same container-versus-host reason documented on mcpHost()
|
|
35
|
-
// below -- this URL was the LAST remaining unconditional
|
|
36
|
-
// "host.docker.internal" in the tree. Calling mcpHost() here is legal despite
|
|
37
|
-
// it being declared further down: it is a function DECLARATION, so it is
|
|
38
|
-
// hoisted, and its own import is initialised before this module body runs.
|
|
39
|
-
// Evaluated once at startup, which also warms isInsideContainer()'s cache
|
|
40
|
-
// before any tool call needs it.
|
|
41
|
-
const DEFAULT_ENDPOINT: string = process.env.VICE_MCP_URL || `http://${mcpHost()}:6510/mcp`;
|
|
42
|
-
const DEFAULT_TIMEOUT_MS: number = Number(process.env.VICE_MCP_TIMEOUT_MS || 30000);
|
|
43
|
-
|
|
44
|
-
// The address of the host machine -- the ONE definition every consumer that
|
|
45
|
-
// needs to build a host-facing URL from a bare port reads, instead of each
|
|
46
|
-
// inlining its own `process.env.VICE_MCP_HOST || "host.docker.internal"` copy
|
|
47
|
-
// (there were three such copies before this: vice-pool.mjs's instanceFor() and
|
|
48
|
-
// defaultInstance(), and vice-session.mjs's readSession()). A FUNCTION, not
|
|
49
|
-
// a module-level constant, so it stays sensitive to a runtime env override
|
|
50
|
-
// -- vice-pool.test.mjs's own withMcpHostEnv() helper mutated
|
|
51
|
-
// process.env.VICE_MCP_HOST across test cases within the SAME process
|
|
52
|
-
// (before that file's 2026-08-02 deletion), which a constant captured once
|
|
53
|
-
// at import time would have silently stopped honouring.
|
|
54
|
-
//
|
|
55
|
-
// CONTAINER-AWARE (2026-08-05, developer instruction). The default was
|
|
56
|
-
// previously the bare literal "host.docker.internal", which is correct in
|
|
57
|
-
// exactly ONE of the two environments this code runs in: it is a
|
|
58
|
-
// Docker-provided alias, published into the container by
|
|
59
|
-
// .devcontainer/devcontainer.json's `--add-host=host.docker.internal:host-gateway`,
|
|
60
|
-
// and it does not resolve on the host at all. Host-bound modules genuinely do
|
|
61
|
-
// consume this tree (vice-broker.mts references vice-broker-client), so a
|
|
62
|
-
// single unconditional answer was wrong for one side by construction.
|
|
63
|
-
//
|
|
64
|
-
// Detection is delegated to container-guard.mts's isInsideContainer() rather
|
|
65
|
-
// than re-derived -- see that function's own comment for why a second
|
|
66
|
-
// detector is a bug waiting to happen here.
|
|
67
|
-
//
|
|
68
|
-
// Non-container branch is 127.0.0.1 rather than "localhost" DELIBERATELY:
|
|
69
|
-
// "localhost" may resolve to ::1 first, and the broker binds 0.0.0.0 --
|
|
70
|
-
// IPv4-only (broker-control.mts's documented bind), so an IPv6 loopback
|
|
71
|
-
// connect would be refused by a listener that is in fact running. An explicit
|
|
72
|
-
// IPv4 literal cannot pick the wrong family. It also classifies as `loopback`
|
|
73
|
-
// under vice-broker-client.ts's classifyConnectHost(), which that resolver
|
|
74
|
-
// deliberately does NOT refuse, and is not `wildcard_bind`, so it does not
|
|
75
|
-
// trip the pre-connect refusal.
|
|
76
|
-
export function mcpHost(deps?: ContainerGuardDeps): string {
|
|
77
|
-
return process.env.VICE_MCP_HOST || (isInsideContainer(deps) ? "host.docker.internal" : "127.0.0.1");
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Where tools/vice-supervisor.sh (host-only) writes its restart epoch --
|
|
81
|
-
// resolved via repo-root.ts's supervisorDir() (never a fixed hop count off
|
|
82
|
-
// this file's own location), so the path is correct regardless of the
|
|
83
|
-
// caller's cwd AND regardless of how deep this file sits under the repo
|
|
84
|
-
// root. Overridable for tests and for anyone running the supervisor with a
|
|
85
|
-
// non-default VICE_SUPERVISOR_DIR. Kept exactly as-is (D-5: no behaviour
|
|
86
|
-
// change with no pool running) -- this remains the default that
|
|
87
|
-
// activeEpochFile below starts from.
|
|
88
|
-
export const EPOCH_FILE: string = process.env.VICE_EPOCH_FILE
|
|
89
|
-
? resolve(process.env.VICE_EPOCH_FILE)
|
|
90
|
-
: join(supervisorDir(), "epoch.json");
|
|
91
|
-
|
|
92
|
-
export interface ActiveInstance {
|
|
93
|
-
port: number;
|
|
94
|
-
url: string;
|
|
95
|
-
epochFile: string;
|
|
96
|
-
pooled: boolean;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// -------------------------------------------------------- active instance
|
|
100
|
-
//
|
|
101
|
-
// Mutable module-level state, deliberately NOT frozen at module load (D-5):
|
|
102
|
-
// restart detection has to stay correct PER INSTANCE, which is impossible if
|
|
103
|
-
// the epoch path is fixed at import time. useInstance() below is the only
|
|
104
|
-
// writer; every other read goes through the functions in this file so a
|
|
105
|
-
// lease redirect takes effect everywhere at once (rpc()'s POST target,
|
|
106
|
-
// readEpoch()'s default path, beginSession()'s default path).
|
|
107
|
-
let activeUrl: string = DEFAULT_ENDPOINT;
|
|
108
|
-
let activeEpochFile: string = EPOCH_FILE;
|
|
109
|
-
// Derived from DEFAULT_ENDPOINT rather than hardcoded or left null: with no
|
|
110
|
-
// lease ever taken (no pool, or a programmatic caller that never calls
|
|
111
|
-
// acquire()/useInstance()), this is still a real port identity -- e.g. for
|
|
112
|
-
// tools/recover.mjs's snapshotName(), which namespaces by port
|
|
113
|
-
// UNCONDITIONALLY (D-4) and must never produce a "no port" name just because
|
|
114
|
-
// nothing redirected the seam. Falls back to 6510 only if the URL has no
|
|
115
|
-
// parseable port at all.
|
|
116
|
-
//
|
|
117
|
-
// PORT TRIAGE (01.6.2-09, D-18): kept, same reasoning as DEFAULT_ENDPOINT
|
|
118
|
-
// above -- this fallback describes the same human-launched, reserved-band
|
|
119
|
-
// (6510-6599) instance, never a broker-allocated one, so 6510 stays correct
|
|
120
|
-
// here too.
|
|
121
|
-
let activePort: number = (() => {
|
|
122
|
-
try {
|
|
123
|
-
const p = Number(new URL(DEFAULT_ENDPOINT).port);
|
|
124
|
-
return Number.isInteger(p) && p > 0 ? p : 6510;
|
|
125
|
-
} catch {
|
|
126
|
-
return 6510;
|
|
127
|
-
}
|
|
128
|
-
})();
|
|
129
|
-
// Not part of the seam redirect itself (rpc()/readEpoch() never consult
|
|
130
|
-
// this) -- carried purely as identity metadata so a caller like
|
|
131
|
-
// tools/recover.mjs's capture record can note whether a dump came from a
|
|
132
|
-
// pooled instance or the unpooled default, without needing its own separate
|
|
133
|
-
// channel back to whatever acquired the lease. Extra, optional field on
|
|
134
|
-
// useInstance()'s object arg -- a caller passing only {port,url,epochFile}
|
|
135
|
-
// (the documented minimum) still works exactly as before, defaulting to
|
|
136
|
-
// false.
|
|
137
|
-
let activePooled = false;
|
|
138
|
-
|
|
139
|
-
export interface UseInstanceOptions {
|
|
140
|
-
port: number;
|
|
141
|
-
url: string;
|
|
142
|
-
epochFile: string;
|
|
143
|
-
pooled?: boolean;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Redirect the transport seam to a specific pooled (or fallback) instance.
|
|
148
|
-
* MUST reset the MCP handshake (`initialized = false`): the handshake
|
|
149
|
-
* belongs to the endpoint it was performed against, and continuing to use a
|
|
150
|
-
* "logged in" flag from a DIFFERENT endpoint would silently talk to the new
|
|
151
|
-
* instance without ever having initialized a session there. Warns on stderr
|
|
152
|
-
* if called while a session is already open against the previous instance,
|
|
153
|
-
* since that is a real behaviour change the caller should notice.
|
|
154
|
-
*/
|
|
155
|
-
export function useInstance({ port, url, epochFile, pooled = false }: UseInstanceOptions): void {
|
|
156
|
-
if (initialized) {
|
|
157
|
-
console.error(
|
|
158
|
-
`warn: useInstance(port ${port}) called while a session was already open against ` +
|
|
159
|
-
`${activeUrl} -- resetting the handshake. If this is mid-procedure, make sure that was intended.`
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
activeUrl = url;
|
|
163
|
-
activeEpochFile = epochFile;
|
|
164
|
-
activePort = port;
|
|
165
|
-
activePooled = pooled;
|
|
166
|
-
initialized = false;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Read-only accessor: the instance the seam is currently pointed at. */
|
|
170
|
-
export function activeInstance(): ActiveInstance {
|
|
171
|
-
return { port: activePort, url: activeUrl, epochFile: activeEpochFile, pooled: activePooled };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Forbidden tool names. Checked by exact string match before any network
|
|
175
|
-
// call is made -- see call() below. Never remove vice_disk_list from this
|
|
176
|
-
// list; see the project's own hazard note (CLAUDE.md, STATE.md blockers).
|
|
177
|
-
//
|
|
178
|
-
// "tools_list" added (01.4-01 task 1, the phase's tracer slice): the host's
|
|
179
|
-
// own generic-surface meta-tool, which the manifest lists as an ordinary
|
|
180
|
-
// forwardable tool. Reuses this exact same array and the exact same two
|
|
181
|
-
// enforcement seams (this guard, and vice-proxy.ts's registration-loop skip
|
|
182
|
-
// + CallToolRequestSchema override) -- no new mechanism, per 01.4-RESEARCH.md
|
|
183
|
-
// Pattern 1 ("one array, no new mechanism"). See denyListRefusalMessage()
|
|
184
|
-
// below for why this entry's hazard shape differs from vice_disk_list's own.
|
|
185
|
-
//
|
|
186
|
-
// "tools_call", "initialize", "notifications_initialized" added (01.4-01
|
|
187
|
-
// task 2): closes the fix in full rather than partially, per
|
|
188
|
-
// 01.4-RESEARCH.md's own primary recommendation. A repo-wide grep (.claude/,
|
|
189
|
-
// .planning/, and this package's own test file) for any SANCTIONED caller of
|
|
190
|
-
// any of these four names AS A TOOL -- i.e. dispatched through tools/call,
|
|
191
|
-
// not the unrelated MCP-protocol "initialize" JSON-RPC method vice.ts's own
|
|
192
|
-
// ensureInitialized() sends, which is the transport handshake, never a tool
|
|
193
|
-
// lookup through this DENY_LIST -- found none. Every recorded hit is either
|
|
194
|
-
// this phase's own research/todo documents and incident records (excluded by
|
|
195
|
-
// the plan's own instruction), or the pre-existing stand-in-host test
|
|
196
|
-
// fixture proving the nested-argument bypass this task closes (repointed
|
|
197
|
-
// below to assert closure, not deleted). 01.4-RESEARCH.md's Open Question 1
|
|
198
|
-
// and Assumption A2 both predicted this: every recorded use on file is an ad
|
|
199
|
-
// hoc fallback probe performed because a named tool went missing, not a
|
|
200
|
-
// designed dependency -- confirmed still true by this grep.
|
|
201
|
-
export const DENY_LIST: readonly string[] = [
|
|
202
|
-
"vice_disk_list",
|
|
203
|
-
"tools_list",
|
|
204
|
-
"tools_call",
|
|
205
|
-
"initialize",
|
|
206
|
-
"notifications_initialized",
|
|
207
|
-
];
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Renders an accurate refusal message for a DENY_LIST entry, keyed by hazard
|
|
211
|
-
* shape rather than one wording reused verbatim for every entry (01.4-01
|
|
212
|
-
* task 1, T-01.4-02): vice_disk_list crashes the shared host VICE MCP server
|
|
213
|
-
* directly -- a CRASH hazard, the project's original hazard note. Every
|
|
214
|
-
* other DENY_LIST entry is a generic-surface meta-tool (tools_list,
|
|
215
|
-
* tools_call, and -- if 01.4-01 task 2's own grep clears them -- initialize
|
|
216
|
-
* and notifications_initialized) whose hazard is a different shape: it is a
|
|
217
|
-
* confused-deputy BYPASS, because it can carry a forbidden tool name as a
|
|
218
|
-
* NESTED argument, sidestepping this exact outer-name-only guard (see
|
|
219
|
-
* .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md).
|
|
220
|
-
* It does not itself crash anything. Telling an agent the wrong hazard shape
|
|
221
|
-
* for what is otherwise the same permanent refusal invites a pointless
|
|
222
|
-
* retry -- so this is one array (DENY_LIST) and one message-rendering
|
|
223
|
-
* function, reused at every call site, rather than duplicated refusal text
|
|
224
|
-
* per site (currently vice.ts's call() guard below and vice-proxy.ts's
|
|
225
|
-
* CallToolRequestSchema override; task 2 adds the retooled bypass test as a
|
|
226
|
-
* third consumer of this same function's output shape, not a fourth
|
|
227
|
-
* inline copy).
|
|
228
|
-
*/
|
|
229
|
-
export function denyListRefusalMessage(toolName: string): string {
|
|
230
|
-
if (toolName === "vice_disk_list") {
|
|
231
|
-
return (
|
|
232
|
-
`${toolName} is permanently forbidden -- it is known to crash the shared host VICE MCP server ` +
|
|
233
|
-
`(see CLAUDE.md's hazard note). Recovery requires a manual, host-side restart. Refusing to ` +
|
|
234
|
-
`serialise this request; retrying will not help.`
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
return (
|
|
238
|
-
`${toolName} is permanently forbidden -- it is a generic-surface meta-tool that can carry a ` +
|
|
239
|
-
`forbidden tool name as a nested argument, bypassing this exact outer-name-only guard (see ` +
|
|
240
|
-
`.planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md). ` +
|
|
241
|
-
`It does not itself crash the host. Refusing to serialise this request; retrying will not help.`
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export interface ViceErrorOptions {
|
|
246
|
-
code?: number | string;
|
|
247
|
-
data?: unknown;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export class ViceError extends Error {
|
|
251
|
-
code?: number | string;
|
|
252
|
-
data?: unknown;
|
|
253
|
-
|
|
254
|
-
constructor(message: string, { code, data }: ViceErrorOptions = {}) {
|
|
255
|
-
super(message);
|
|
256
|
-
this.name = "ViceError";
|
|
257
|
-
this.code = code;
|
|
258
|
-
this.data = data;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export interface MachineRestartedErrorOptions {
|
|
263
|
-
baselineEpoch?: number | null;
|
|
264
|
-
currentEpoch?: number | null;
|
|
265
|
-
where?: string;
|
|
266
|
-
lastToolCall?: string | null;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Thrown when a reconnect happened and the emulator's identity across that
|
|
271
|
-
* reconnect could not be proven -- either the epoch file shows it changed,
|
|
272
|
-
* or nothing (no epoch file, no surviving armed checkpoint) could prove it
|
|
273
|
-
* didn't (D-3, D-4). Carries the evidence a caller needs to write a void
|
|
274
|
-
* note: the epochs compared, where in the pipeline the check ran, and the
|
|
275
|
-
* last tool call attempted before detection (see lastToolCall() below).
|
|
276
|
-
*/
|
|
277
|
-
export class MachineRestartedError extends ViceError {
|
|
278
|
-
baselineEpoch?: number | null;
|
|
279
|
-
currentEpoch?: number | null;
|
|
280
|
-
where?: string;
|
|
281
|
-
lastToolCall?: string | null;
|
|
282
|
-
|
|
283
|
-
constructor(message: string, { baselineEpoch, currentEpoch, where, lastToolCall }: MachineRestartedErrorOptions = {}) {
|
|
284
|
-
super(message);
|
|
285
|
-
this.name = "MachineRestartedError";
|
|
286
|
-
this.baselineEpoch = baselineEpoch;
|
|
287
|
-
this.currentEpoch = currentEpoch;
|
|
288
|
-
this.where = where;
|
|
289
|
-
this.lastToolCall = lastToolCall;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
let reqId = 0;
|
|
294
|
-
|
|
295
|
-
export interface RpcOptions {
|
|
296
|
-
timeoutMs?: number;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
interface JsonRpcErrorPayload {
|
|
300
|
-
code?: number;
|
|
301
|
-
message?: string;
|
|
302
|
-
data?: unknown;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
interface JsonRpcResponsePayload {
|
|
306
|
-
error?: JsonRpcErrorPayload;
|
|
307
|
-
result?: unknown;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
311
|
-
* an array. Matches vice-broker.mts's / vice-broker-client.ts's own
|
|
312
|
-
* isPlainObject() predicate exactly -- the same narrowing discipline this
|
|
313
|
-
* module tree uses everywhere a parsed JSON value's fields are touched. */
|
|
314
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
315
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Raw JSON-RPC round trip. Wraps every call in a client-side abort timeout --
|
|
320
|
-
* vice_run_until's own `cycles` timeout is documented as "not yet
|
|
321
|
-
* implemented", so nothing upstream protects us from a hung request; this is
|
|
322
|
-
* that protection, at the transport layer, for every call this seam makes.
|
|
323
|
-
*/
|
|
324
|
-
async function rpc(method: string, params: unknown, { timeoutMs = DEFAULT_TIMEOUT_MS }: RpcOptions = {}): Promise<unknown> {
|
|
325
|
-
const id = ++reqId;
|
|
326
|
-
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
327
|
-
let res: Response;
|
|
328
|
-
try {
|
|
329
|
-
res = await fetch(activeUrl, {
|
|
330
|
-
method: "POST",
|
|
331
|
-
headers: {
|
|
332
|
-
"Content-Type": "application/json",
|
|
333
|
-
Accept: "application/json, text/event-stream",
|
|
334
|
-
},
|
|
335
|
-
body,
|
|
336
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
337
|
-
});
|
|
338
|
-
} catch (e) {
|
|
339
|
-
const err = e as Error;
|
|
340
|
-
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
341
|
-
throw new ViceError(
|
|
342
|
-
`${method} timed out after ${timeoutMs}ms -- the host VICE MCP server may be hung or unreachable. ` +
|
|
343
|
-
`Recovery is a HOST-SIDE restart, which this container cannot perform. Run ` +
|
|
344
|
-
`tools/vice-launcher.sh on the HOST -- its broker launches a boot-fresh instance on demand, ` +
|
|
345
|
-
`supervises it, and respawns a crashed one with backoff, logging the crash ` +
|
|
346
|
-
`for the still-open root-cause investigation (see .planning/STATE.md).`
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
throw new ViceError(`transport error calling ${method}: ${err.message}`);
|
|
350
|
-
}
|
|
351
|
-
const contentType = res.headers.get("content-type") || "";
|
|
352
|
-
const text = await res.text();
|
|
353
|
-
let payload: JsonRpcResponsePayload;
|
|
354
|
-
if (contentType.includes("text/event-stream")) {
|
|
355
|
-
// SSE-framed body: parse `data:` lines, take the last JSON payload.
|
|
356
|
-
const dataLines = text
|
|
357
|
-
.split("\n")
|
|
358
|
-
.filter((l) => l.startsWith("data:"))
|
|
359
|
-
.map((l) => l.slice(5).trim())
|
|
360
|
-
.filter(Boolean);
|
|
361
|
-
if (!dataLines.length) {
|
|
362
|
-
throw new ViceError(`no data: lines in SSE response for ${method}`);
|
|
363
|
-
}
|
|
364
|
-
const parsed: unknown = JSON.parse(dataLines[dataLines.length - 1]);
|
|
365
|
-
payload = isPlainObject(parsed) ? (parsed as JsonRpcResponsePayload) : {};
|
|
366
|
-
} else {
|
|
367
|
-
try {
|
|
368
|
-
const parsed: unknown = JSON.parse(text);
|
|
369
|
-
payload = isPlainObject(parsed) ? (parsed as JsonRpcResponsePayload) : {};
|
|
370
|
-
} catch {
|
|
371
|
-
throw new ViceError(`non-JSON response for ${method}: ${text.slice(0, 200)}`);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (payload.error) {
|
|
375
|
-
throw new ViceError(payload.error.message || `RPC error calling ${method}`, {
|
|
376
|
-
code: payload.error.code,
|
|
377
|
-
data: payload.error.data,
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
return payload.result;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
let initialized = false;
|
|
384
|
-
async function ensureInitialized(): Promise<void> {
|
|
385
|
-
if (initialized) return;
|
|
386
|
-
await rpc("initialize", {
|
|
387
|
-
protocolVersion: "2024-11-05",
|
|
388
|
-
capabilities: {},
|
|
389
|
-
clientInfo: { name: "vice-recover", version: "1.0" },
|
|
390
|
-
});
|
|
391
|
-
initialized = true;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// The host server has been observed to drop connections and recover on its own,
|
|
395
|
-
// but the outage outlasts a short backoff -- a 6s total budget was measured as
|
|
396
|
-
// too short. These values give it ~50s to come back before we declare it dead
|
|
397
|
-
// and point the operator at tools/vice-launcher.sh (host-only; this
|
|
398
|
-
// container cannot restart it itself) -- its on-demand broker launches a
|
|
399
|
-
// fresh instance and respawns a crashed one with backoff automatically
|
|
400
|
-
// (01.6.2-09: the retiring per-instance supervisor, vice-supervisor.sh, is
|
|
401
|
-
// superseded by this same launcher/broker).
|
|
402
|
-
//
|
|
403
|
-
// IMPORTANT: under that broker, this retry can now SUCCEED -- against a
|
|
404
|
-
// brand-new, blank machine with no disk attached and no checkpoints armed,
|
|
405
|
-
// not the one this session started with. That is exactly why
|
|
406
|
-
// beginSession()/assertSameMachine() exist below: a retry that starts
|
|
407
|
-
// working again is no longer proof that nothing happened. Do not remove the
|
|
408
|
-
// identity check while this broker (or any future host-side recovery)
|
|
409
|
-
// exists, and do not remove it without also removing the retry -- they are
|
|
410
|
-
// one mitigation in two halves, not two independent features.
|
|
411
|
-
const RECONNECT_ATTEMPTS = 5;
|
|
412
|
-
const RECONNECT_BACKOFF_MS = [2000, 5000, 12000, 30000, 0];
|
|
413
|
-
const nap = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* A single dropped connection used to be fatal: the error propagated straight
|
|
417
|
-
* out and, worse, `initialized` stayed true, so every later call spoke into a
|
|
418
|
-
* dead session. The host server has been observed both to drop a connection
|
|
419
|
-
* mid-request and to come back moments later, so transport failures are
|
|
420
|
-
* retried with backoff and the session handshake is redone.
|
|
421
|
-
*
|
|
422
|
-
* Only TRANSPORT failures are retried. An RPC-level error (the server answered
|
|
423
|
-
* and said no) is a real answer and is never retried -- retrying a rejected
|
|
424
|
-
* tool call would just repeat a mistake, and for a tool with side effects could
|
|
425
|
-
* repeat it destructively.
|
|
426
|
-
*
|
|
427
|
-
* Under host-side recovery (tools/vice-launcher.sh's on-demand broker), this
|
|
428
|
-
* retry can now SUCCEED -- against a brand-new, blank machine with no disk
|
|
429
|
-
* attached, no checkpoints armed and the CPU halted at the BASIC prompt. A
|
|
430
|
-
* success here is therefore no longer proof that nothing happened; it is
|
|
431
|
-
* exactly the signal the session-identity section below
|
|
432
|
-
* (readEpoch/assertSameMachine) exists to catch. Do not remove one half of
|
|
433
|
-
* this pairing without the other.
|
|
434
|
-
*/
|
|
435
|
-
async function withReconnect(toolName: string, args: Record<string, unknown>, opts: RpcOptions): Promise<unknown> {
|
|
436
|
-
lastCallSummary = summarizeCall(toolName, args);
|
|
437
|
-
let lastErr: Error | undefined;
|
|
438
|
-
for (let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++) {
|
|
439
|
-
try {
|
|
440
|
-
await ensureInitialized();
|
|
441
|
-
return await rpc("tools/call", { name: toolName, arguments: args }, opts);
|
|
442
|
-
} catch (e) {
|
|
443
|
-
const err = e as Error;
|
|
444
|
-
const transport = /transport error|timed out|no data: lines|non-JSON response/i.test(err.message);
|
|
445
|
-
if (!transport) throw err;
|
|
446
|
-
lastErr = err;
|
|
447
|
-
initialized = false; // force a fresh handshake -- the old session is gone
|
|
448
|
-
// Session-identity signal (D-3): this is the ONLY place that knows a
|
|
449
|
-
// reconnect was forced. Bump the counter every attempt, not just on
|
|
450
|
-
// eventual success -- call()'s cheap epoch check and
|
|
451
|
-
// assertSameMachine()'s checkpoint-fallback probe both key off this.
|
|
452
|
-
reconnectCount++;
|
|
453
|
-
if (attempt < RECONNECT_ATTEMPTS - 1) {
|
|
454
|
-
console.error(
|
|
455
|
-
`warn: ${toolName} transport failure (attempt ${attempt + 1}/${RECONNECT_ATTEMPTS}), ` +
|
|
456
|
-
`reconnecting in ${RECONNECT_BACKOFF_MS[attempt]}ms: ${err.message}`
|
|
457
|
-
);
|
|
458
|
-
await nap(RECONNECT_BACKOFF_MS[attempt]);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
throw new ViceError(
|
|
463
|
-
`${toolName} failed after ${RECONNECT_ATTEMPTS} transport attempts against ${activeUrl} ` +
|
|
464
|
-
`(port ${activePort}): ${lastErr?.message} -- recovery is a HOST-SIDE restart, which this ` +
|
|
465
|
-
`container cannot perform. Run tools/vice-launcher.sh on the HOST (see its header comment) -- ` +
|
|
466
|
-
`its broker launches a boot-fresh instance on demand, supervises it, and respawns a crashed one ` +
|
|
467
|
-
`with backoff, logging the crash for the still-open root-cause investigation.`
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// ------------------------------------------------------- session identity
|
|
472
|
-
//
|
|
473
|
-
// WHY this lives in the transport seam, not in tools/recover.mjs: this file
|
|
474
|
-
// is the only place that knows a reconnect happened at all. Once
|
|
475
|
-
// tools/vice-launcher.sh's on-demand broker is respawning x64sc on the host,
|
|
476
|
-
// withReconnect()'s retry-with-backoff above starts SUCCEEDING again -- but
|
|
477
|
-
// potentially against a completely different, freshly-booted machine.
|
|
478
|
-
// Turning that "quiet success" back into a loud, checkable signal is the
|
|
479
|
-
// whole point of this section (D-3, D-4).
|
|
480
|
-
//
|
|
481
|
-
// Module-level state, deliberately NOT per-call-argument: there is one
|
|
482
|
-
// active recovery session per process (recover.mjs's CLI runs one verb at a
|
|
483
|
-
// time), so beginSession()/assertSameMachine() read and reset this state
|
|
484
|
-
// directly rather than threading it through every call() site.
|
|
485
|
-
export interface SessionInfo {
|
|
486
|
-
baseline: EpochResult;
|
|
487
|
-
epochPath: string;
|
|
488
|
-
startedAt: string;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
let currentSession: SessionInfo | null = null; // set by beginSession(): { baseline, epochPath, startedAt }
|
|
492
|
-
let reconnectCount = 0; // reset by beginSession(); incremented by withReconnect(); "consumed" (reset) by assertSameMachine()
|
|
493
|
-
let lastCallSummary: string | null = null; // last tool call attempted, for D-4 evidence in a void note
|
|
494
|
-
|
|
495
|
-
/** `${toolName} ${args}`, truncated to ~120 chars -- D-4 wants the last call before a
|
|
496
|
-
* detected restart, not a full transcript. */
|
|
497
|
-
function summarizeCall(toolName: string, args: unknown): string {
|
|
498
|
-
let argsStr: string;
|
|
499
|
-
try {
|
|
500
|
-
argsStr = JSON.stringify(args);
|
|
501
|
-
} catch {
|
|
502
|
-
argsStr = String(args);
|
|
503
|
-
}
|
|
504
|
-
const full = `${toolName} ${argsStr}`;
|
|
505
|
-
return full.length > 120 ? `${full.slice(0, 117)}...` : full;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
export interface EpochResult {
|
|
509
|
-
present: boolean;
|
|
510
|
-
epoch: number | null;
|
|
511
|
-
spawned_at: string | null;
|
|
512
|
-
pid: number | null;
|
|
513
|
-
path: string;
|
|
514
|
-
reason?: string;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Read the supervisor's epoch file. Synchronous -- this is a plain, cheap
|
|
519
|
-
* file read; the whole point of the epoch check is that it costs zero MCP
|
|
520
|
-
* traffic, unlike the checkpoint-fallback probe. NEVER throws: absence is
|
|
521
|
-
* normal (no supervisor running at all) and must not be an error (D-3) --
|
|
522
|
-
* the harness has to keep working exactly as it does today with no
|
|
523
|
-
* supervisor.
|
|
524
|
-
*
|
|
525
|
-
* Treats the file's contents as untrusted, host-written input (T-jty-01):
|
|
526
|
-
* JSON.parse in try/catch, `epoch` must decode to a finite integer, unknown
|
|
527
|
-
* fields are ignored, and no path derived from the file's contents is ever
|
|
528
|
-
* opened.
|
|
529
|
-
*/
|
|
530
|
-
export function readEpoch(path: string = activeEpochFile): EpochResult {
|
|
531
|
-
const absent: EpochResult = { present: false, epoch: null, spawned_at: null, pid: null, path };
|
|
532
|
-
let raw: string;
|
|
533
|
-
try {
|
|
534
|
-
raw = readFileSync(path, "utf8");
|
|
535
|
-
} catch {
|
|
536
|
-
return { ...absent, reason: "epoch file absent" };
|
|
537
|
-
}
|
|
538
|
-
let parsed: unknown;
|
|
539
|
-
try {
|
|
540
|
-
parsed = JSON.parse(raw);
|
|
541
|
-
} catch {
|
|
542
|
-
return { ...absent, reason: "epoch file present but not valid JSON" };
|
|
543
|
-
}
|
|
544
|
-
if (!isPlainObject(parsed) || !Number.isInteger(parsed.epoch)) {
|
|
545
|
-
return { ...absent, reason: 'epoch file present but its "epoch" field is not a finite integer' };
|
|
546
|
-
}
|
|
547
|
-
return {
|
|
548
|
-
present: true,
|
|
549
|
-
epoch: parsed.epoch as number,
|
|
550
|
-
spawned_at: typeof parsed.spawned_at === "string" ? parsed.spawned_at : null,
|
|
551
|
-
pid: typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? parsed.pid : null,
|
|
552
|
-
path,
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
export interface BeginSessionOptions {
|
|
557
|
-
epochPath?: string;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Start a new identity-tracking session: capture the current epoch as the
|
|
562
|
-
* baseline every later check compares against, and zero the reconnect
|
|
563
|
-
* counter so a PRIOR session's reconnects (e.g. from a previous `recover()`
|
|
564
|
-
* run inside the same `reproduce()` process) don't leak into this one.
|
|
565
|
-
*/
|
|
566
|
-
export function beginSession({ epochPath = activeEpochFile }: BeginSessionOptions = {}): SessionInfo {
|
|
567
|
-
const baseline = readEpoch(epochPath);
|
|
568
|
-
reconnectCount = 0;
|
|
569
|
-
currentSession = { baseline, epochPath, startedAt: new Date().toISOString() };
|
|
570
|
-
return currentSession;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/** Read-only accessor: how many transport-forced reconnects since the last
|
|
574
|
-
* beginSession() (or the last assertSameMachine() consumption -- see there). */
|
|
575
|
-
export function sessionReconnects(): number {
|
|
576
|
-
return reconnectCount;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/** Read-only accessor: the last tool call attempted (name + truncated args),
|
|
580
|
-
* for D-4 evidence -- populated even for calls that ultimately failed. */
|
|
581
|
-
export function lastToolCall(): string | null {
|
|
582
|
-
return lastCallSummary;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export type CallFn = (toolName: string, args?: Record<string, unknown>, opts?: RpcOptions) => Promise<unknown>;
|
|
586
|
-
|
|
587
|
-
export interface AssertSameMachineOptions {
|
|
588
|
-
where: string;
|
|
589
|
-
armedCheckpoints?: number[];
|
|
590
|
-
reconnected?: boolean;
|
|
591
|
-
call?: CallFn;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
* Prove (or fail to prove) that the machine behind `session` is still the
|
|
596
|
-
* one that was running at `beginSession()` time. See the plan's `<behavior>`
|
|
597
|
-
* block for the six rules this implements; in short:
|
|
598
|
-
*
|
|
599
|
-
* - If the epoch file proves a change (baseline and current both present,
|
|
600
|
-
* different values) -> MachineRestartedError, always, reconnect or not.
|
|
601
|
-
* - If the epoch file proves NO change (both present, same value) -> pass,
|
|
602
|
-
* no further (MCP) check needed.
|
|
603
|
-
* - Otherwise (no epoch evidence either way) and no reconnect happened ->
|
|
604
|
-
* pass, and no MCP call is made at all -- the whole point of gating the
|
|
605
|
-
* checkpoint probe behind `reconnected`.
|
|
606
|
-
* - Otherwise (no epoch evidence, but a reconnect DID happen): fall back to
|
|
607
|
-
* asking whether a checkpoint the harness itself already armed (never a
|
|
608
|
-
* new sentinel -- checkpoint work is itself a crash suspect) is still
|
|
609
|
-
* listed. Present -> pass. Absent, or nothing to probe with -> void.
|
|
610
|
-
*
|
|
611
|
-
* `reconnected` defaults to whether ANY transport-forced reconnect has
|
|
612
|
-
* happened since the last beginSession()/assertSameMachine() call --
|
|
613
|
-
* calling this function CONSUMES that count (resets it to 0) so a later,
|
|
614
|
-
* unrelated assertSameMachine() call (e.g. after this stage's own armed
|
|
615
|
-
* checkpoint has since been deleted) doesn't re-trigger a probe against
|
|
616
|
-
* checkpoints that are supposed to be gone by then.
|
|
617
|
-
*/
|
|
618
|
-
export async function assertSameMachine(
|
|
619
|
-
session: SessionInfo,
|
|
620
|
-
{
|
|
621
|
-
where,
|
|
622
|
-
armedCheckpoints = [],
|
|
623
|
-
reconnected = sessionReconnects() > 0,
|
|
624
|
-
call: callFn = call,
|
|
625
|
-
}: AssertSameMachineOptions
|
|
626
|
-
): Promise<void> {
|
|
627
|
-
// Consume the module-level reconnect signal now -- see the doc comment
|
|
628
|
-
// above for why this matters for later, unrelated checks in the same
|
|
629
|
-
// session.
|
|
630
|
-
reconnectCount = 0;
|
|
631
|
-
|
|
632
|
-
const currentEpoch = readEpoch(session.epochPath);
|
|
633
|
-
|
|
634
|
-
if (session.baseline.present && currentEpoch.present) {
|
|
635
|
-
if (currentEpoch.epoch !== session.baseline.epoch) {
|
|
636
|
-
throw new MachineRestartedError(
|
|
637
|
-
`${where}: the emulator restarted -- epoch changed from ${session.baseline.epoch} to ` +
|
|
638
|
-
`${currentEpoch.epoch}. This run is void; re-run it.`,
|
|
639
|
-
{ baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
return; // epoch proves this is still the same machine -- no MCP call needed
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
if (!reconnected) {
|
|
646
|
-
return; // nothing to check, and nothing checked -- no MCP call made
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// A reconnect happened and the epoch file could not confirm sameness
|
|
650
|
-
// (either no broker is running at all, or its epoch file just isn't
|
|
651
|
-
// there to compare against). The checkpoint-fallback probe is the only
|
|
652
|
-
// identity signal left -- and it deliberately reuses checkpoints the
|
|
653
|
-
// harness already armed for its own reasons; arming a new sentinel
|
|
654
|
-
// checkpoint was rejected because checkpoint work is itself one of the two
|
|
655
|
-
// leading crash suspects (see STATE.md's HAZARD CANDIDATE entry).
|
|
656
|
-
if (armedCheckpoints.length === 0) {
|
|
657
|
-
throw new MachineRestartedError(
|
|
658
|
-
`${where}: a reconnect happened and identity could not be proven -- no epoch file to compare ` +
|
|
659
|
-
`and no armed checkpoint to probe. Unproven is not the same as fine; re-run the capture. If ` +
|
|
660
|
-
`this recurs, run tools/vice-launcher.sh on the HOST so future runs have an epoch file to check.`,
|
|
661
|
-
{ baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
|
|
662
|
-
);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
let listed: unknown;
|
|
666
|
-
try {
|
|
667
|
-
listed = await callFn("vice_checkpoint_list", {});
|
|
668
|
-
} catch (e) {
|
|
669
|
-
const err = e as Error;
|
|
670
|
-
throw new MachineRestartedError(
|
|
671
|
-
`${where}: a reconnect happened and the checkpoint-fallback probe itself failed (${err.message}) -- ` +
|
|
672
|
-
`identity could not be proven. Re-run the capture.`,
|
|
673
|
-
{ baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
const checkpoints = isPlainObject(listed) && Array.isArray(listed.checkpoints) ? listed.checkpoints : [];
|
|
677
|
-
const liveIds = new Set((checkpoints as Array<Record<string, unknown>>).map((c) => c.checkpoint_num as number));
|
|
678
|
-
const stillPresent = armedCheckpoints.some((id) => liveIds.has(id));
|
|
679
|
-
if (!stillPresent) {
|
|
680
|
-
throw new MachineRestartedError(
|
|
681
|
-
`${where}: a reconnect happened and none of the harness's own armed checkpoints ` +
|
|
682
|
-
`(${armedCheckpoints.join(", ")}) are listed by vice_checkpoint_list -- the emulator restarted. ` +
|
|
683
|
-
`This run is void; re-run it.`,
|
|
684
|
-
{ baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
|
|
685
|
-
);
|
|
686
|
-
}
|
|
687
|
-
// The armed checkpoint survived the reconnect -- demonstrably the same machine.
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Call a vice_* tool by name and return its parsed JSON result.
|
|
692
|
-
*
|
|
693
|
-
* Refuses any tool on DENY_LIST before any network request is made -- this
|
|
694
|
-
* check is the first line of the function body, deliberately, so the deny
|
|
695
|
-
* list is enforced even if a future edit reorders the rest of the function.
|
|
696
|
-
*/
|
|
697
|
-
export async function call(toolName: string, args: Record<string, unknown> = {}, opts: RpcOptions = {}): Promise<unknown> {
|
|
698
|
-
if (DENY_LIST.includes(toolName)) {
|
|
699
|
-
throw new ViceError(denyListRefusalMessage(toolName));
|
|
700
|
-
}
|
|
701
|
-
const reconnectsBefore = reconnectCount;
|
|
702
|
-
const result = await withReconnect(toolName, args, opts);
|
|
703
|
-
// Session-identity fast path (D-3, D-4): if THIS call needed a reconnect,
|
|
704
|
-
// do the cheap epoch check (a synchronous file read, zero extra MCP
|
|
705
|
-
// traffic) right here -- the earliest and loudest possible detection
|
|
706
|
-
// point. A changed epoch throws immediately. We deliberately do NOT run
|
|
707
|
-
// the checkpoint-fallback probe here: that would be a re-entrant call(),
|
|
708
|
-
// and reconnectCount staying > 0 is exactly the module flag
|
|
709
|
-
// assertSameMachine() consumes at its next checkpoint instead.
|
|
710
|
-
if (reconnectCount > reconnectsBefore && currentSession) {
|
|
711
|
-
const nowEpoch = readEpoch(currentSession.epochPath);
|
|
712
|
-
if (currentSession.baseline.present && nowEpoch.present && nowEpoch.epoch !== currentSession.baseline.epoch) {
|
|
713
|
-
throw new MachineRestartedError(
|
|
714
|
-
`${toolName}: the emulator restarted mid-call -- epoch changed from ` +
|
|
715
|
-
`${currentSession.baseline.epoch} to ${nowEpoch.epoch} after a reconnect. This run is void; re-run it.`,
|
|
716
|
-
{
|
|
717
|
-
baselineEpoch: currentSession.baseline.epoch,
|
|
718
|
-
currentEpoch: nowEpoch.epoch,
|
|
719
|
-
where: `call(${toolName})`,
|
|
720
|
-
lastToolCall: lastCallSummary,
|
|
721
|
-
}
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content?.[0];
|
|
726
|
-
if (!content || content.type !== "text") {
|
|
727
|
-
throw new ViceError(`unexpected tool result shape from ${toolName}: ${JSON.stringify(result)}`);
|
|
728
|
-
}
|
|
729
|
-
try {
|
|
730
|
-
return JSON.parse(content.text ?? "");
|
|
731
|
-
} catch {
|
|
732
|
-
return content.text; // a few tools may return plain text; hand it back verbatim
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// Alias -- some call sites read more naturally as callTool(...).
|
|
737
|
-
export const callTool: typeof call = call;
|
|
738
|
-
|
|
739
|
-
export interface ToolInfo {
|
|
740
|
-
name: string;
|
|
741
|
-
description?: string;
|
|
742
|
-
inputSchema?: unknown;
|
|
743
|
-
[key: string]: unknown;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
export interface ServerInfoPayload {
|
|
747
|
-
tools?: ToolInfo[];
|
|
748
|
-
[key: string]: unknown;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* The server's tools/list result (name, description, inputSchema per tool),
|
|
753
|
-
* with every DENY_LIST tool STRIPPED OUT.
|
|
754
|
-
*
|
|
755
|
-
* Filtering here rather than at each render site is deliberate: this is the
|
|
756
|
-
* single choke point every consumer goes through -- the `tools` CLI verb, its
|
|
757
|
-
* `--json` dump, and recover.mjs -- so a forbidden tool is not merely marked
|
|
758
|
-
* as forbidden, it never appears at all. An agent cannot be tempted by a tool
|
|
759
|
-
* it never learns exists, and a discovery listing that shows a tool the seam
|
|
760
|
-
* would refuse anyway is just an invitation to try it.
|
|
761
|
-
*
|
|
762
|
-
* This does NOT replace the DENY_LIST guard in call(). Discovery filtering
|
|
763
|
-
* and call-time refusal are independent layers: one hides the tool, the other
|
|
764
|
-
* refuses it even when the name was obtained some other way.
|
|
765
|
-
*/
|
|
766
|
-
export async function serverInfo(): Promise<unknown> {
|
|
767
|
-
await ensureInitialized();
|
|
768
|
-
const payload = await rpc("tools/list", {});
|
|
769
|
-
if (!isPlainObject(payload) || !Array.isArray(payload.tools)) return payload;
|
|
770
|
-
const tools = payload.tools as ToolInfo[];
|
|
771
|
-
return { ...payload, tools: tools.filter((t) => !DENY_LIST.includes(t?.name)) };
|
|
772
|
-
}
|