@henols/vice-mcp 0.2.2 → 0.2.3

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 (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
package/text-tools.ts ADDED
@@ -0,0 +1,778 @@
1
+ #!/usr/bin/env node
2
+ // text-tools.ts
3
+ //
4
+ // THE ONE place a text-channel tool handler lives (plan 41-06, CHAN-03).
5
+ // Every handler here takes the text channel's own halt authority through
6
+ // withTextChannelLock() (text-protocol.ts, plan 41-02) around its whole
7
+ // logical operation and never issues a bare command() outside it --
8
+ // command() itself refuses when channel-lock.ts's mutex is not held by the
9
+ // text channel, so a handler that forgot to acquire would be refused, not
10
+ // silently allowed through.
11
+ //
12
+ // No handler here accepts a caller-supplied command string, now or later.
13
+ // The generic remote-execution seam this project rejected on the record for
14
+ // host_tool (a single per-binary run-arbitrary-command op) would be WORSE
15
+ // here, over a channel that is unauthenticated and can read and write host
16
+ // files -- broker-launch.mts's own bind-widening warning already states
17
+ // this for this same channel. Every outbound command below is a fixed
18
+ // literal drawn from TEXT_COMMAND_ALLOWLIST; text-protocol.ts's command()
19
+ // itself refuses anything else BY NAME (D-01), and neither handler below
20
+ // ever builds a command string from an argument.
21
+ //
22
+ // SESSION LIFECYCLE -- MEASURED, not this plan's assumed default (Rule 1
23
+ // deviation, see SUMMARY): StockConnectSession (stock-connect.ts) carries no
24
+ // text session at all, and ensureStockSession()/withStockSession() never
25
+ // call textConnect() -- as of this plan, textConnect()/textDisconnect() are
26
+ // invoked ONLY from test code. So there is no session-lifetime-held text
27
+ // session anywhere in production code to reuse. Each call below is its OWN
28
+ // textConnect()/textDisconnect() pair -- the ONLY acquisition path these two
29
+ // tools have, not a second one competing with a first.
30
+ //
31
+ // ADAPTER CHOICE -- MEASURED, deviates from this plan's literal instruction
32
+ // (Rule 1 deviation, see SUMMARY): withStockSession() and
33
+ // withDerivedTool(..., { needsSession: true }, ...) (stock-dispatch.ts) both
34
+ // wrap the ENTIRE delegated handler call in withChannelLockHeld(), which
35
+ // acquires channel-lock.ts's SINGLE, cross-channel mutex for `channel:
36
+ // "binary"` for the whole call. A handler reached through either adapter
37
+ // that then called withTextChannelLock() internally would be a SECOND
38
+ // acquireChannelLock() call while the first (binary) is still held by the
39
+ // very same call stack -- channel-lock.ts is not reentrant and does not
40
+ // distinguish "the same logical caller" from "a different one" -- so the
41
+ // inner acquire would queue behind itself and could only ever resolve by
42
+ // expiring CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS (630s by default) and erroring: a
43
+ // de facto deadlock, not a working call. Neither handler below needs a
44
+ // binary session or the binary channel's authority at all, so both are
45
+ // registered in stock-dispatch.ts with
46
+ // `withDerivedTool(toolName, { needsSession: false }, handler)` instead --
47
+ // the SAME existing adapter configuration `vice_diagnose`/
48
+ // `vice_symbols_load` already use, never a third adapter. Each handler
49
+ // resolves the lease through `deps.ensureLease()` itself (free to call
50
+ // repeatedly -- ensureStockSession()'s own header comment) and takes ONLY
51
+ // the text channel's own lock, via withTextChannelLock(), around its one
52
+ // command().
53
+ //
54
+ // WHAT NOT TO DO:
55
+ // - Never accept a caller-supplied, free-text command string anywhere in
56
+ // this module's public surface (D-01).
57
+ // - Never call TextMonitorClient.command() outside withTextChannelLock().
58
+ // - Never register either handler through withStockSession() or
59
+ // withDerivedTool(..., { needsSession: true }, ...) -- see the ADAPTER
60
+ // CHOICE comment above for the self-deadlock this would cause.
61
+ // - Never dial a raw host/port or open a second broker lease -- both
62
+ // handlers obtain lease coordinates through deps.ensureLease() (the SAME
63
+ // provider ensureStockSession() itself calls) and dial only through
64
+ // textConnect().
65
+ // - Never write a third error converter -- reuse convertHandshakeError()
66
+ // and convertWireError() (stock-handler.ts) exactly as every other stock
67
+ // handler does; a ChannelLockTimeoutError is passed through verbatim
68
+ // (its own `.message` IS channelLockRefusalMessage()'s output), matching
69
+ // withChannelLockHeld()'s own discipline in stock-dispatch.ts.
70
+ // - Never embed a phase number in any string or template literal here.
71
+ import { textConnect, textDisconnect } from "./text-connect.ts";
72
+ import { withTextChannelLock, buildTextCommand, type TextMonitorClient } from "./text-protocol.ts";
73
+ import { MonitorOwnershipError } from "./vice-broker-client.ts";
74
+ import { ChannelLockTimeoutError } from "./channel-lock.ts";
75
+ import { isErrorText, derivedAnswer, convertHandshakeError, convertWireError, type StockToolResult } from "./stock-handler.ts";
76
+ import { parseAccessMap, accessMapRanges, type AccessMap, type AccessMapRangesOptions } from "./textmon-memmap.ts";
77
+ import { parseCpuHistory } from "./textmon-cpuhistory.ts";
78
+ import { parseBacktrace } from "./textmon-backtrace.ts";
79
+ import { parseFlatProfile } from "./textmon-profile.ts";
80
+ import { parseIoRegisters } from "./textmon-registers.ts";
81
+ import {
82
+ classifyTextCapabilityResponse,
83
+ probeTextCapability,
84
+ textCapabilityVerdictFor,
85
+ textCapabilityRefusalMessage,
86
+ textCapabilityIdentityWarning,
87
+ type TextCapabilityIdentity,
88
+ type TextCapabilityBrokerIdentity,
89
+ } from "./text-capability-probe.ts";
90
+ import type { StockDispatchDeps } from "./stock-dispatch.ts";
91
+
92
+ /**
93
+ * Shared preamble every handler below runs: resolve the lease
94
+ * (`deps.ensureLease()` -- the SAME provider ensureStockSession() itself
95
+ * calls, never a second acquisition), open a fresh text-monitor session
96
+ * through textConnect(), hold channel-lock.ts's mutex for `channel: "text"`
97
+ * around exactly one command via withTextChannelLock(), and always tear the
98
+ * session down again (textDisconnect()) whether `fn` succeeded or threw.
99
+ */
100
+ async function withTextTool(
101
+ toolName: string,
102
+ deps: StockDispatchDeps,
103
+ fn: (client: TextMonitorClient) => Promise<StockToolResult>,
104
+ ): Promise<StockToolResult> {
105
+ const leaseOutcome = await deps.ensureLease();
106
+ if (!leaseOutcome.ok) {
107
+ return isErrorText(leaseOutcome.message);
108
+ }
109
+ const lease = leaseOutcome.lease;
110
+ if (lease === null) {
111
+ return isErrorText(
112
+ `${toolName}: VICE_MCP_URL is set, so there is no broker-managed instance and no broker control session to ` +
113
+ `claim the text-monitor socket through -- unset VICE_MCP_URL to use the on-demand broker, or connect to a ` +
114
+ `broker-managed instance directly.`,
115
+ );
116
+ }
117
+
118
+ let session;
119
+ try {
120
+ session = await textConnect({
121
+ host: lease.host,
122
+ remoteMonitorPort: lease.remoteMonitorPort,
123
+ targetId: lease.targetId,
124
+ brokerControl: lease.brokerControl,
125
+ });
126
+ } catch (err) {
127
+ if (err instanceof MonitorOwnershipError) {
128
+ return convertHandshakeError(toolName, err);
129
+ }
130
+ return convertWireError(toolName, err);
131
+ }
132
+
133
+ try {
134
+ return await withTextChannelLock(toolName, () => fn(session.client), { timeoutMs: deps.channelLockTimeoutMs });
135
+ } catch (err) {
136
+ // ChannelLockTimeoutError's own `.message` IS
137
+ // channelLockRefusalMessage()'s output -- passed through verbatim below,
138
+ // never routed through convertWireError(), matching
139
+ // withChannelLockHeld()'s (stock-dispatch.ts) own discipline for the
140
+ // binary side.
141
+ if (err instanceof ChannelLockTimeoutError) {
142
+ return isErrorText(err.message);
143
+ }
144
+ return convertWireError(toolName, err);
145
+ } finally {
146
+ try {
147
+ await textDisconnect(session);
148
+ } catch (releaseErr) {
149
+ console.error(`${toolName}: textDisconnect after use did not complete: ${String(releaseErr)}`);
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * `vice_device_console` -- takes NO arguments at all. Issues the single
156
+ * allowlisted verb `device c:` (the colon is required; the spelling without
157
+ * it is a syntax error the monitor rejects) inside withTextChannelLock(),
158
+ * and answers with the framed response plus a statement that the default
159
+ * device (memspace) was reset to the main CPU. This is an explicit tool, not
160
+ * auto-healing on the stepping path (D-03) -- nothing here is called from
161
+ * `ADVANCE_INSTRUCTIONS` or `EXECUTE_UNTIL_RETURN`; no shipped tool can
162
+ * contaminate `default_memspace` today (drive checkpoints are deferred past
163
+ * this milestone), so auto-healing would add a text round trip and a mutex
164
+ * acquisition to the hottest binary-side path to defend a route nothing
165
+ * currently opens.
166
+ */
167
+ export async function handleDeviceConsole(_args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
168
+ return withTextTool("vice_device_console", deps, async (client) => {
169
+ const response = await client.command("device c:");
170
+ return derivedAnswer({
171
+ response,
172
+ note: "default device (memspace) reset to the main CPU",
173
+ });
174
+ });
175
+ }
176
+
177
+ /**
178
+ * `vice_warp_set` -- takes exactly one parameter, `enabled: boolean`,
179
+ * refused BY NAME (no byte written to the socket, no lease resolved, no
180
+ * connection attempted) whenever it is not a boolean. Selects `warp on` or
181
+ * `warp off` by branch -- never a caller-supplied string concatenated into
182
+ * the command line -- and answers with the framed response, which per the
183
+ * existing measurement reports warp's own state, so the answer carries the
184
+ * OBSERVED state rather than an assumption that the write took. There is no
185
+ * runtime `WarpMode` *resource* on stock; this is a monitor *command*, and
186
+ * warp requested at launch time is a separate mechanism -- this tool changes
187
+ * neither of those facts.
188
+ */
189
+ export async function handleWarpSet(args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
190
+ const enabled = args.enabled;
191
+ if (typeof enabled !== "boolean") {
192
+ return isErrorText(
193
+ `vice_warp_set: "enabled" must be a boolean (got ${JSON.stringify(enabled)}) -- refusing before any ` +
194
+ `text-monitor byte is written`,
195
+ );
196
+ }
197
+ const command = enabled ? "warp on" : "warp off";
198
+ return withTextTool("vice_warp_set", deps, async (client) => {
199
+ const response = await client.command(command);
200
+ return derivedAnswer({
201
+ requested: enabled,
202
+ response,
203
+ note:
204
+ "there is no runtime WarpMode resource on stock -- this is a monitor command, and warp requested at " +
205
+ "launch time is a separate mechanism; the response above carries warp's OWN observed state, not an " +
206
+ "assumption that this write took",
207
+ });
208
+ });
209
+ }
210
+
211
+ /** True iff `value` is a representable, non-negative whole number bounded
212
+ * to the C64's 16-bit address space -- the shared narrowing for
213
+ * `startAddress`/`endAddress`. Declared locally, per this module tree's
214
+ * own "repeated per file, never centrally imported" convention
215
+ * (disasm-decoder.ts). */
216
+ function isValidAddressArg(value: unknown): value is number {
217
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 0xffff;
218
+ }
219
+
220
+ /** True iff `value` is a representable integer 1 through 4096 -- the
221
+ * `maxRanges` narrowing. */
222
+ function isValidMaxRangesArg(value: unknown): value is number {
223
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 4096;
224
+ }
225
+
226
+ /** Per-column execute counts over the WHOLE parsed map (never the
227
+ * `maxRanges`-truncated projection) -- so the counts stay meaningful even
228
+ * when the emitted range list itself is truncated. */
229
+ function executeCounts(map: AccessMap): { io: number; rom: number; ram: number } {
230
+ let io = 0;
231
+ let rom = 0;
232
+ let ram = 0;
233
+ for (const entry of map.entries) {
234
+ if (entry.io.execute) io++;
235
+ if (entry.rom.execute) rom++;
236
+ if (entry.ram.execute) ram++;
237
+ }
238
+ return { io, rom, ram };
239
+ }
240
+
241
+ /**
242
+ * `vice_memmap_show` -- dials the single allowlisted, unparameterized verb
243
+ * `memmapshow` (D-42-1: `startAddress`/`endAddress`/`maxRanges` are
244
+ * client-side projection FILTERS applied after parsing; none of the three
245
+ * ever reaches the socket, and the dialed command is the frozen literal,
246
+ * never a built string). Refuses any argument outside its documented
247
+ * bounds BEFORE any lease is resolved or any byte is written, mirroring
248
+ * `handleWarpSet()`'s shape. Retrofitted in plan 42-07 with the same
249
+ * classify-before-parse ordering the four PARSE-02 handlers use: `chis`
250
+ * shares FEATURE_CPUMEMHISTORY with `memmapshow`, so a disabled build is
251
+ * named by capability, command, binary and remedy -- never a parser
252
+ * refusal. Plan 42-01 wrote this handler before text-capability-probe.ts
253
+ * existed, so it originally handed a disabled-stub reply straight to the
254
+ * parser (a parse error that reads like a defect in this project, exactly
255
+ * the outcome PARSE-04 forbids). On a genuine parse refusal, answers
256
+ * `isErrorText` naming the tool, the refusal code, and the offending
257
+ * line/lineNumber -- never a partial or best-effort access map.
258
+ */
259
+ export async function handleMemmapShow(args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
260
+ const { startAddress, endAddress, maxRanges } = args;
261
+
262
+ if (startAddress !== undefined && !isValidAddressArg(startAddress)) {
263
+ return isErrorText(
264
+ `vice_memmap_show: "startAddress" must be an integer 0 through 65535 (got ${JSON.stringify(startAddress)}) -- ` +
265
+ `refusing before any text-monitor byte is written`,
266
+ );
267
+ }
268
+ if (endAddress !== undefined && !isValidAddressArg(endAddress)) {
269
+ return isErrorText(
270
+ `vice_memmap_show: "endAddress" must be an integer 0 through 65535 (got ${JSON.stringify(endAddress)}) -- ` +
271
+ `refusing before any text-monitor byte is written`,
272
+ );
273
+ }
274
+ if (
275
+ startAddress !== undefined &&
276
+ endAddress !== undefined &&
277
+ isValidAddressArg(startAddress) &&
278
+ isValidAddressArg(endAddress) &&
279
+ startAddress > endAddress
280
+ ) {
281
+ return isErrorText(
282
+ `vice_memmap_show: "startAddress" (${JSON.stringify(startAddress)}) must not be greater than "endAddress" ` +
283
+ `(${JSON.stringify(endAddress)}) -- refusing before any text-monitor byte is written`,
284
+ );
285
+ }
286
+ if (maxRanges !== undefined && !isValidMaxRangesArg(maxRanges)) {
287
+ return isErrorText(
288
+ `vice_memmap_show: "maxRanges" must be an integer 1 through 4096 (got ${JSON.stringify(maxRanges)}) -- ` +
289
+ `refusing before any text-monitor byte is written`,
290
+ );
291
+ }
292
+
293
+ const { identity, brokerIdentity } = await capabilityIdentityFor(deps);
294
+ const identityWarning = textCapabilityIdentityWarning(identity, brokerIdentity);
295
+
296
+ return withTextTool("vice_memmap_show", deps, async (client) => {
297
+ const response = await client.command("memmapshow", { timeoutMs: 30000 });
298
+
299
+ const classification = classifyTextCapabilityResponse("memmapshow", response);
300
+ if (classification.outcome !== "capable") {
301
+ const verdict = await probeTextCapability({ command: "memmapshow", identity, brokerIdentity, dial: async () => response });
302
+ return isErrorText(withIdentityWarning(`vice_memmap_show: ${textCapabilityRefusalMessage([verdict])}`, identityWarning));
303
+ }
304
+
305
+ const parsed = parseAccessMap(response);
306
+ if (!parsed.ok) {
307
+ return isErrorText(
308
+ withIdentityWarning(
309
+ `vice_memmap_show: memmapshow's response could not be parsed (${parsed.refusal.code} at line ` +
310
+ `${parsed.refusal.lineNumber}: ${JSON.stringify(parsed.refusal.line)}) -- ${parsed.refusal.message}`,
311
+ identityWarning,
312
+ ),
313
+ );
314
+ }
315
+
316
+ const rangesOpts: AccessMapRangesOptions = {};
317
+ if (isValidAddressArg(startAddress)) rangesOpts.startAddress = startAddress;
318
+ if (isValidAddressArg(endAddress)) rangesOpts.endAddress = endAddress;
319
+ if (isValidMaxRangesArg(maxRanges)) rangesOpts.maxRanges = maxRanges;
320
+ const projection = accessMapRanges(parsed.value, rangesOpts);
321
+
322
+ return derivedAnswer({
323
+ command: "memmapshow",
324
+ ...projection,
325
+ executeCounts: executeCounts(parsed.value),
326
+ ...(identityWarning !== "" ? { identityWarning } : {}),
327
+ });
328
+ });
329
+ }
330
+
331
+ /**
332
+ * `vice_memmap_zap` -- takes NO parameters at all (D-42-1 again, the
333
+ * strictest form: not even a client-side projection filter exists here to
334
+ * validate). Dials exactly two frozen allowlisted literals in sequence:
335
+ * `memmapzap` (plan 43-01, EVID-05/EVID-06's proven-dialable bracket-clear
336
+ * primitive), then `memmapshow`, so the answer carries an OBSERVABLE
337
+ * post-condition rather than a bare acknowledgement -- `memmapzap` alone
338
+ * returns nothing that proves anything was cleared.
339
+ *
340
+ * The build-capability verdict is BORROWED from classifying the second
341
+ * dial's reply (`classifyTextCapabilityResponse("memmapshow", ...)`),
342
+ * exactly `handleMemmapShow`'s own ordering above -- deliberately NOT by
343
+ * adding `"memmapzap"` to `CPUHISTORY_GATED_COMMANDS`
344
+ * (text-capability-probe.ts). Whether a build without FEATURE_CPUMEMHISTORY
345
+ * prints that same disabled stub for `memmapzap` itself is NOT known from
346
+ * any source this project has read; `memmapshow`'s own disabled stub IS
347
+ * source-traced (text-capability-probe.ts's own header). Classifying the
348
+ * `memmapshow` reply instead of inventing a fresh claim about `memmapzap`'s
349
+ * own stub means this handler asserts nothing beyond what is already
350
+ * proven -- and it still refuses correctly on a disabled build, because
351
+ * `memmapshow` shares the exact same build flag and would refuse right
352
+ * alongside it.
353
+ *
354
+ * On a genuine parse refusal of the `memmapshow` reply, answers
355
+ * `isErrorText` naming the tool, the refusal code, the line number and the
356
+ * offending line -- never a partial or best-effort answer, mirroring
357
+ * `handleMemmapShow`'s own discipline exactly -- WITH ONE MEASURED
358
+ * EXCEPTION, discovered live against genuine stock VICE (plan 43-03 Task 1):
359
+ * `no-data-lines` (header present, zero data lines -- `parseAccessMap()`'s
360
+ * own deliberate refusal, `textmon-memmap.ts`'s "never decoded as a
361
+ * zero-entry access map") is the GUARANTEED shape of a real `memmapshow`
362
+ * dialed immediately after a real `memmapzap`, inside this SAME
363
+ * `withTextChannelLock()` hold, with the machine halted the whole time --
364
+ * nothing can have executed between the two dials, so there is no
365
+ * "truncated wire reply vs. genuinely nothing recorded" ambiguity left to
366
+ * guard against for THIS caller specifically (the general ambiguity
367
+ * `parseAccessMap()`'s refusal exists to catch is real for an ARBITRARY
368
+ * caller of `memmapshow`, which `handleMemmapShow` above still refuses on,
369
+ * unchanged). Treated here, and ONLY here, as a confirmed zero-entry map
370
+ * (`{ entries: [] }`) rather than a refusal -- the literal fact this
371
+ * exact caller has already proven.
372
+ *
373
+ * The answer never carries a `ranges` key: this tool answers "is the map
374
+ * clear", not "what is in it" -- `vice_memmap_show` is the verb for the
375
+ * latter. It answers only the post-zap `addressesWithRecordedAccess` and
376
+ * `addressesQueried` (accessMapRanges()'s own denominator discipline,
377
+ * unbounded -- no `startAddress`/`endAddress`/`maxRanges` options exist here
378
+ * to narrow it) plus the same `executeCounts` triple `handleMemmapShow`
379
+ * reports.
380
+ */
381
+ export async function handleMemmapZap(_args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
382
+ const { identity, brokerIdentity } = await capabilityIdentityFor(deps);
383
+ const identityWarning = textCapabilityIdentityWarning(identity, brokerIdentity);
384
+
385
+ return withTextTool("vice_memmap_zap", deps, async (client) => {
386
+ await client.command("memmapzap", { timeoutMs: 30000 });
387
+ const response = await client.command("memmapshow", { timeoutMs: 30000 });
388
+
389
+ const classification = classifyTextCapabilityResponse("memmapshow", response);
390
+ if (classification.outcome !== "capable") {
391
+ const verdict = await probeTextCapability({ command: "memmapshow", identity, brokerIdentity, dial: async () => response });
392
+ return isErrorText(withIdentityWarning(`vice_memmap_zap: ${textCapabilityRefusalMessage([verdict])}`, identityWarning));
393
+ }
394
+
395
+ const parsed = parseAccessMap(response);
396
+ let accessMap: AccessMap;
397
+ if (parsed.ok) {
398
+ accessMap = parsed.value;
399
+ } else if (parsed.refusal.code === "no-data-lines") {
400
+ // Live-measured (plan 43-03 Task 1): this IS what a real memmapzap
401
+ // followed immediately by memmapshow, in the same locked session,
402
+ // looks like -- see the doc comment above.
403
+ accessMap = { entries: [] };
404
+ } else {
405
+ return isErrorText(
406
+ withIdentityWarning(
407
+ `vice_memmap_zap: memmapshow's response (dialed after memmapzap) could not be parsed (${parsed.refusal.code} at line ` +
408
+ `${parsed.refusal.lineNumber}: ${JSON.stringify(parsed.refusal.line)}) -- ${parsed.refusal.message}`,
409
+ identityWarning,
410
+ ),
411
+ );
412
+ }
413
+
414
+ const projection = accessMapRanges(accessMap, {});
415
+
416
+ return derivedAnswer({
417
+ command: "memmapzap",
418
+ addressesWithRecordedAccess: projection.addressesWithRecordedAccess,
419
+ addressesQueried: projection.addressesQueried,
420
+ executeCounts: executeCounts(accessMap),
421
+ ...(identityWarning !== "" ? { identityWarning } : {}),
422
+ });
423
+ });
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Plan 42-07: the four remaining text formats -- chis, prof flat, bt, io --
428
+ // plus the shared capability-identity helper every one of the five text
429
+ // tools in this file (including handleMemmapShow above, retrofitted) uses to
430
+ // classify a raw reply for build capability BEFORE handing it to a parser
431
+ // (PARSE-04, D-42-2). See text-capability-probe.ts's own header comment for
432
+ // the full three-outcome design this section leans on.
433
+ // ---------------------------------------------------------------------------
434
+
435
+ /** THE ONE place that decides what identity a capability answer is
436
+ * attributed to (D-42-2), used by all five text tools in this file.
437
+ * `identity` comes from `deps.resolvedBinaryPath`/
438
+ * `deps.resolvedBinaryPathIsResolved` -- the SAME single dispatch-layer
439
+ * resolution `stock-dispatch.ts`'s own `StockDispatchDeps` doc comment
440
+ * documents, never re-resolved here. Every tool registered in this file
441
+ * runs ONLY on the stock backend (STOCK_DERIVED_TOOLS), so `backend` is
442
+ * always the "stock" literal -- never invented for a caller this module
443
+ * could not actually be talking to.
444
+ *
445
+ * `brokerIdentity`, when obtainable, is the broker's OWN reported identity
446
+ * (`BrokerControlSession.hostState()`'s `backend`/`vice_bin` fields) for
447
+ * `probeTextCapability()`'s own cross-check (D-42-2). A failed `hostState()`
448
+ * call (a broker predating the field, no lease at all, or a control-plane
449
+ * hiccup) is ABSENT EVIDENCE, never disagreement -- `brokerIdentity` is
450
+ * simply omitted, matching `probeTextCapability()`'s own documented
451
+ * treatment of an omitted broker identity.
452
+ */
453
+ async function capabilityIdentityFor(
454
+ deps: StockDispatchDeps,
455
+ ): Promise<{ identity: TextCapabilityIdentity; brokerIdentity?: TextCapabilityBrokerIdentity }> {
456
+ const identity: TextCapabilityIdentity = {
457
+ backend: "stock",
458
+ binPath: deps.resolvedBinaryPath ?? "",
459
+ resolved: deps.resolvedBinaryPathIsResolved ?? false,
460
+ };
461
+
462
+ const leaseOutcome = await deps.ensureLease();
463
+ if (!leaseOutcome.ok || leaseOutcome.lease === null) {
464
+ return { identity };
465
+ }
466
+
467
+ try {
468
+ const hostStateResult = await leaseOutcome.lease.brokerControl.hostState();
469
+ if (!hostStateResult.ok) return { identity };
470
+ return {
471
+ identity,
472
+ brokerIdentity: { backend: hostStateResult.hostState.backend, binPath: hostStateResult.hostState.vice_bin },
473
+ };
474
+ } catch {
475
+ return { identity };
476
+ }
477
+ }
478
+
479
+ /** Appends `warning` (from {@link textCapabilityIdentityWarning}) on its own
480
+ * line after `text` when non-empty; returns `text` unchanged when there is
481
+ * nothing to report. Used on every POST-LEASE error path in every handler
482
+ * below (plan 42-13, G3) -- never on a pre-dial argument-validation refusal,
483
+ * which returns before an identity is ever resolved and has nothing to
484
+ * attribute a warning to. */
485
+ function withIdentityWarning(text: string, warning: string): string {
486
+ return warning === "" ? text : `${text}\n${warning}`;
487
+ }
488
+
489
+ /**
490
+ * `vice_cpu_history` -- dials `chis`, optionally parameterized with a
491
+ * caller-chosen decimal row count via `buildTextCommand()` (the ONE place
492
+ * such a string is built, D-42-1). An omitted `count` dials the bare frozen
493
+ * verb; a supplied one is validated and rendered by the builder alone --
494
+ * this handler never states or duplicates the builder's own bound.
495
+ * Classifies the raw reply for build capability before parsing (PARSE-04):
496
+ * `chis` shares FEATURE_CPUMEMHISTORY with `memmapshow`, so a disabled build
497
+ * is named by capability, command, binary and remedy -- never a parser
498
+ * refusal.
499
+ */
500
+ export async function handleCpuHistory(args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
501
+ const { count } = args;
502
+ let command = "chis";
503
+ if (count !== undefined) {
504
+ const built = buildTextCommand("chis", count);
505
+ if (!built.ok) {
506
+ return isErrorText(`vice_cpu_history: ${built.message} -- refusing before any text-monitor byte is written`);
507
+ }
508
+ command = built.command;
509
+ }
510
+
511
+ const { identity, brokerIdentity } = await capabilityIdentityFor(deps);
512
+ const identityWarning = textCapabilityIdentityWarning(identity, brokerIdentity);
513
+
514
+ return withTextTool("vice_cpu_history", deps, async (client) => {
515
+ const response = await client.command(command, { timeoutMs: 30000 });
516
+
517
+ const classification = classifyTextCapabilityResponse("chis", response);
518
+ if (classification.outcome !== "capable") {
519
+ const verdict = await probeTextCapability({ command: "chis", identity, brokerIdentity, dial: async () => response });
520
+ return isErrorText(withIdentityWarning(`vice_cpu_history: ${textCapabilityRefusalMessage([verdict])}`, identityWarning));
521
+ }
522
+
523
+ const parsed = parseCpuHistory(response);
524
+ if (!parsed.ok) {
525
+ return isErrorText(
526
+ withIdentityWarning(
527
+ `vice_cpu_history: chis's response could not be parsed (${parsed.refusal.code} at line ` +
528
+ `${parsed.refusal.lineNumber}: ${JSON.stringify(parsed.refusal.line)}) -- ${parsed.refusal.message}`,
529
+ identityWarning,
530
+ ),
531
+ );
532
+ }
533
+
534
+ return derivedAnswer({
535
+ command,
536
+ entries: parsed.value.entries,
537
+ count: parsed.value.entries.length,
538
+ ...(identityWarning !== "" ? { identityWarning } : {}),
539
+ });
540
+ });
541
+ }
542
+
543
+ /**
544
+ * `vice_profile_flat` -- dials `prof flat`, optionally parameterized with a
545
+ * caller-chosen decimal row count via `buildTextCommand()`, exactly mirroring
546
+ * `handleCpuHistory()`'s argument shape. `prof flat` carries NO build-time
547
+ * guard at all -- the classifier can only ever return `capable` or
548
+ * `indeterminate` for this verb -- but is still classified before parsing so
549
+ * an indeterminate (empty/unframeable) reply is a named state rather than a
550
+ * silent empty success.
551
+ */
552
+ export async function handleProfileFlat(args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
553
+ const { count } = args;
554
+ let command = "prof flat";
555
+ if (count !== undefined) {
556
+ const built = buildTextCommand("prof flat", count);
557
+ if (!built.ok) {
558
+ return isErrorText(`vice_profile_flat: ${built.message} -- refusing before any text-monitor byte is written`);
559
+ }
560
+ command = built.command;
561
+ }
562
+
563
+ const { identity, brokerIdentity } = await capabilityIdentityFor(deps);
564
+ const identityWarning = textCapabilityIdentityWarning(identity, brokerIdentity);
565
+
566
+ return withTextTool("vice_profile_flat", deps, async (client) => {
567
+ const response = await client.command(command, { timeoutMs: 30000 });
568
+
569
+ const classification = classifyTextCapabilityResponse("prof flat", response);
570
+ if (classification.outcome !== "capable") {
571
+ const verdict = await probeTextCapability({ command: "prof flat", identity, brokerIdentity, dial: async () => response });
572
+ return isErrorText(withIdentityWarning(`vice_profile_flat: ${textCapabilityRefusalMessage([verdict])}`, identityWarning));
573
+ }
574
+
575
+ const parsed = parseFlatProfile(response);
576
+ if (!parsed.ok) {
577
+ // A profiler that was never started (profiling-not-started, plan
578
+ // 42-13) is a named state the owning module recognises, not a parse
579
+ // defect -- rendering it through the wrapper below would make a
580
+ // legitimate external condition (the profiler subsystem simply has
581
+ // nothing recorded yet) read as a bug in this project. This changes
582
+ // the DESCRIPTION of the cold state only: the separate, still-open
583
+ // inability for this tree to start profiling on the user's behalf
584
+ // (Window #55) is unaffected. Every other code keeps the wrapper.
585
+ if (parsed.refusal.code === "profiling-not-started") {
586
+ return isErrorText(withIdentityWarning(`vice_profile_flat: ${parsed.refusal.message}`, identityWarning));
587
+ }
588
+ return isErrorText(
589
+ withIdentityWarning(
590
+ `vice_profile_flat: prof flat's response could not be parsed (${parsed.refusal.code} at line ` +
591
+ `${parsed.refusal.lineNumber}: ${JSON.stringify(parsed.refusal.line)}) -- ${parsed.refusal.message}`,
592
+ identityWarning,
593
+ ),
594
+ );
595
+ }
596
+
597
+ return derivedAnswer({
598
+ command,
599
+ entries: parsed.value.entries,
600
+ count: parsed.value.entries.length,
601
+ decimalSeparator: parsed.value.decimalSeparator,
602
+ ...(identityWarning !== "" ? { identityWarning } : {}),
603
+ });
604
+ });
605
+ }
606
+
607
+ /** True iff `value` is a representable integer 1 through 64 -- the shared
608
+ * bound the fork's own `vice_backtrace` tool description already declares
609
+ * ("Max stack frames to show (default: 16, max: 64)"). This is the ONE
610
+ * exception D-42-1 states explicitly: `depth` is a client-side projection
611
+ * FILTER applied to the PARSED frame list, never part of the "bt" command
612
+ * itself (the verb takes no wire parameter at all), so it is validated
613
+ * locally rather than through `buildTextCommand()`. */
614
+ function isValidBacktraceDepthArg(value: unknown): value is number {
615
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 64;
616
+ }
617
+
618
+ /**
619
+ * `vice_backtrace` -- takes the name the fork's own tool already advertises
620
+ * (D-42-4): landing the text-monitor implementation under the SAME name,
621
+ * with a backward-compatible argument shape (the fork's optional numeric
622
+ * `depth` stays optional and stays numeric), makes this a SHARED tool
623
+ * rather than a second vocabulary for one capability. Dials the bare,
624
+ * unparameterized `bt` verb -- `depth`, when supplied, truncates the
625
+ * PARSED frames only, and the answer reports both the returned count and
626
+ * the total so truncation is never silently a function of the argument.
627
+ * `bt` carries no build-time guard; classified before parsing anyway so an
628
+ * indeterminate reply is a named state.
629
+ */
630
+ export async function handleBacktrace(args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
631
+ const { depth } = args;
632
+ if (depth !== undefined && !isValidBacktraceDepthArg(depth)) {
633
+ return isErrorText(
634
+ `vice_backtrace: "depth" must be an integer 1 through 64 (got ${JSON.stringify(depth)}) -- refusing before ` +
635
+ `any text-monitor byte is written`,
636
+ );
637
+ }
638
+
639
+ const { identity, brokerIdentity } = await capabilityIdentityFor(deps);
640
+ const identityWarning = textCapabilityIdentityWarning(identity, brokerIdentity);
641
+
642
+ return withTextTool("vice_backtrace", deps, async (client) => {
643
+ const response = await client.command("bt", { timeoutMs: 30000 });
644
+
645
+ const classification = classifyTextCapabilityResponse("bt", response);
646
+ if (classification.outcome !== "capable") {
647
+ const verdict = await probeTextCapability({ command: "bt", identity, brokerIdentity, dial: async () => response });
648
+ return isErrorText(withIdentityWarning(`vice_backtrace: ${textCapabilityRefusalMessage([verdict])}`, identityWarning));
649
+ }
650
+
651
+ const parsed = parseBacktrace(response);
652
+ if (!parsed.ok) {
653
+ return isErrorText(
654
+ withIdentityWarning(
655
+ `vice_backtrace: bt's response could not be parsed (${parsed.refusal.code} at line ` +
656
+ `${parsed.refusal.lineNumber}: ${JSON.stringify(parsed.refusal.line)}) -- ${parsed.refusal.message}`,
657
+ identityWarning,
658
+ ),
659
+ );
660
+ }
661
+
662
+ const totalCount = parsed.value.frames.length;
663
+ const truncated = isValidBacktraceDepthArg(depth) && depth < totalCount;
664
+ const frames = isValidBacktraceDepthArg(depth) ? parsed.value.frames.slice(0, depth) : parsed.value.frames;
665
+
666
+ return derivedAnswer({
667
+ command: "bt",
668
+ currentPc: parsed.value.currentPc,
669
+ frames,
670
+ returnedCount: frames.length,
671
+ totalCount,
672
+ truncated,
673
+ ...(identityWarning !== "" ? { identityWarning } : {}),
674
+ });
675
+ });
676
+ }
677
+
678
+ /**
679
+ * `vice_io_registers` -- dials `io $aaaa` for a REQUIRED `address` argument:
680
+ * unlike the other three parameterized tools, this one refuses a MISSING
681
+ * address outright, because the bare "io" verb dumps every chip and this
682
+ * tool decodes the chip covering exactly one. `address` is validated and
683
+ * rendered by `buildTextCommand()` alone. `io` carries no build-time guard
684
+ * -- `classifyTextCapabilityResponse()` can only ever return `capable` or
685
+ * `indeterminate` for this verb -- but it still degrades PER-CHIP at
686
+ * runtime with its own two fixed strings, a fact the classifier alone
687
+ * cannot see; the probe module's own renderer (`textCapabilityRefusalMessage()`)
688
+ * is always consulted below, whatever this classification said, since that
689
+ * is the one place the chip-degradation-vs-missing-capability distinction
690
+ * is drawn.
691
+ *
692
+ * CR-02 (corrected): `io`'s outcome is decided by THIS CALL's OWN `address`
693
+ * argument, not by a property of the binary -- so it must never be
694
+ * classified through `probeTextCapability()`'s memoised entry point. The
695
+ * old wiring did exactly that, and a second call to a different address
696
+ * silently received the FIRST call's cached verdict: one call's evidence
697
+ * answering another call's question. This handler instead builds its
698
+ * verdict directly from the response THIS call just received, via
699
+ * `textCapabilityVerdictFor()` (no dial, no cache read, no cache write),
700
+ * and hands that verdict to the unchanged `textCapabilityRefusalMessage()`
701
+ * -- still the one place the chip-degradation-vs-missing-capability
702
+ * distinction is drawn, just applied to the right response. Do not
703
+ * "simplify" this back onto `probeTextCapability()`; `io` is additionally
704
+ * excluded from that cache's domain structurally (`NEVER_CACHED_COMMANDS`
705
+ * in text-capability-probe.ts) so this handler could not reach it that way
706
+ * even by accident.
707
+ */
708
+ export async function handleIoRegisters(args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
709
+ const { address } = args;
710
+ if (address === undefined) {
711
+ return isErrorText(
712
+ `vice_io_registers: "address" is REQUIRED (the bare "io" verb dumps every chip; this tool decodes the chip ` +
713
+ `covering ONE address) -- refusing before any text-monitor byte is written`,
714
+ );
715
+ }
716
+ const built = buildTextCommand("io", address);
717
+ if (!built.ok) {
718
+ return isErrorText(`vice_io_registers: ${built.message} -- refusing before any text-monitor byte is written`);
719
+ }
720
+ const command = built.command;
721
+
722
+ const { identity, brokerIdentity } = await capabilityIdentityFor(deps);
723
+ const identityWarning = textCapabilityIdentityWarning(identity, brokerIdentity);
724
+
725
+ return withTextTool("vice_io_registers", deps, async (client) => {
726
+ const response = await client.command(command, { timeoutMs: 30000 });
727
+
728
+ // "io" is never gated behind FEATURE_CPUMEMHISTORY -- classifyTextCapabilityResponse()
729
+ // only ever returns "capable" or "indeterminate" for this verb (see
730
+ // CPUHISTORY_GATED_COMMANDS in text-capability-probe.ts), so "capable" here is
731
+ // not the same as "nothing to refuse": the probe module's own renderer is
732
+ // always consulted below, since it is what additionally catches io's own
733
+ // per-chip runtime degradation. For this verb the classifier can therefore
734
+ // return only capable or indeterminate, NEVER missing -- that invariant is
735
+ // stated here in prose rather than by a computed-and-discarded call, since
736
+ // this handler has nothing further to do with the classifier's answer.
737
+ //
738
+ // CR-02: this verdict is built from the response THIS call just
739
+ // received, via textCapabilityVerdictFor() -- NOT via probeTextCapability(),
740
+ // which would classify on a possibly-stale cached verdict built from a
741
+ // DIFFERENT call's address. io's outcome is per-call, per-address content,
742
+ // never a binary-wide capability fact, so it never reaches the memoised
743
+ // entry point at all.
744
+ const verdict = textCapabilityVerdictFor({ command: "io", response, identity, ...(brokerIdentity !== undefined ? { brokerIdentity } : {}) });
745
+ const refusalMessage = textCapabilityRefusalMessage([verdict]);
746
+ if (refusalMessage !== "") {
747
+ return isErrorText(withIdentityWarning(`vice_io_registers: ${refusalMessage}`, identityWarning));
748
+ }
749
+
750
+ const parsed = parseIoRegisters(response);
751
+ if (!parsed.ok) {
752
+ // A chip this parser does not decode (unsupported-chip, plan 42-11,
753
+ // WR-02) is a legitimately different reply whose register dump read
754
+ // cleanly -- reporting it through the parse-failure wrapper below
755
+ // would make an external chip difference read as a defect in this
756
+ // project. Render the parser's own message verbatim, under the tool
757
+ // name only. Every other refusal code keeps the wrapper unchanged.
758
+ if (parsed.refusal.code === "unsupported-chip") {
759
+ return isErrorText(withIdentityWarning(`vice_io_registers: ${parsed.refusal.message}`, identityWarning));
760
+ }
761
+ return isErrorText(
762
+ withIdentityWarning(
763
+ `vice_io_registers: io's response could not be parsed (${parsed.refusal.code} at line ` +
764
+ `${parsed.refusal.lineNumber}: ${JSON.stringify(parsed.refusal.line)}) -- ${parsed.refusal.message}`,
765
+ identityWarning,
766
+ ),
767
+ );
768
+ }
769
+
770
+ return derivedAnswer({
771
+ command,
772
+ sections: parsed.value.sections,
773
+ unrecognisedLines: parsed.value.unrecognisedLines,
774
+ unrecognisedLineCount: parsed.value.unrecognisedLines.length,
775
+ ...(identityWarning !== "" ? { identityWarning } : {}),
776
+ });
777
+ });
778
+ }