c64bridge 1.0.7 → 1.1.0

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 (132) hide show
  1. package/.github/skills/assembly-program/SKILL.md +37 -0
  2. package/.github/skills/basic-program/SKILL.md +40 -0
  3. package/.github/skills/cross-platform-demo/SKILL.md +32 -0
  4. package/.github/skills/drive-manager/SKILL.md +33 -0
  5. package/.github/skills/graphics-demo/SKILL.md +30 -0
  6. package/.github/skills/hello-world/SKILL.md +36 -0
  7. package/.github/skills/memory-debug/SKILL.md +32 -0
  8. package/.github/skills/pr-converge/SKILL.md +33 -0
  9. package/.github/skills/printer-job/SKILL.md +30 -0
  10. package/.github/skills/sid-music/SKILL.md +35 -0
  11. package/.github/skills/software-launch/SKILL.md +31 -0
  12. package/.github/skills/stream-control/SKILL.md +29 -0
  13. package/.github/skills/system-control/SKILL.md +32 -0
  14. package/AGENTS.md +28 -7
  15. package/README.md +69 -14
  16. package/data/assembly/assembly-spec.md +2 -0
  17. package/dist/c64Client.js +238 -60
  18. package/dist/config.js +48 -26
  19. package/dist/device.js +92 -37
  20. package/dist/diagnostics.js +62 -2
  21. package/dist/hostPaths.js +128 -0
  22. package/dist/loggingHttpClient.js +59 -1
  23. package/dist/mcp-server.js +409 -286
  24. package/dist/numericArg.js +55 -0
  25. package/dist/petscii.js +13 -1
  26. package/dist/platform.js +12 -3
  27. package/dist/prompts/registry.js +6 -1
  28. package/dist/tools/access.js +66 -0
  29. package/dist/tools/debug.js +6 -0
  30. package/dist/tools/developer.js +67 -34
  31. package/dist/tools/errors.js +54 -3
  32. package/dist/tools/input.js +23 -6
  33. package/dist/tools/machineControl.js +112 -20
  34. package/dist/tools/memory.js +135 -30
  35. package/dist/tools/meta/artifacts.js +5 -5
  36. package/dist/tools/meta/background.js +216 -54
  37. package/dist/tools/meta/compilation.js +6 -5
  38. package/dist/tools/meta/config.js +15 -6
  39. package/dist/tools/meta/filesystem.js +16 -14
  40. package/dist/tools/meta/graphics.js +48 -20
  41. package/dist/tools/meta/memory.js +45 -19
  42. package/dist/tools/meta/program.js +10 -7
  43. package/dist/tools/meta/screen.js +116 -25
  44. package/dist/tools/pauseGuard.js +23 -0
  45. package/dist/tools/pollValidator.js +53 -8
  46. package/dist/tools/programRunners.js +62 -18
  47. package/dist/tools/registry/batch.js +16 -4
  48. package/dist/tools/registry/config.js +9 -2
  49. package/dist/tools/registry/disk.js +12 -2
  50. package/dist/tools/registry/drive.js +3 -0
  51. package/dist/tools/registry/extract.js +3 -0
  52. package/dist/tools/registry/graphics.js +59 -6
  53. package/dist/tools/registry/index.js +17 -1
  54. package/dist/tools/registry/memory.js +11 -1
  55. package/dist/tools/registry/platform.js +4 -1
  56. package/dist/tools/registry/printer.js +2 -1
  57. package/dist/tools/registry/program.js +5 -1
  58. package/dist/tools/registry/rag.js +1 -0
  59. package/dist/tools/registry/sound.js +12 -1
  60. package/dist/tools/registry/stream.js +2 -1
  61. package/dist/tools/registry/system.js +8 -1
  62. package/dist/tools/schema.js +7 -7
  63. package/dist/tools/streaming.js +34 -4
  64. package/dist/tools/translation/assembler.js +247 -24
  65. package/dist/tools/translation/basicDetokenizer.js +80 -0
  66. package/dist/tools/translation/basicTokenizer.js +8 -3
  67. package/dist/tools/translation/disassembler.js +14 -0
  68. package/dist/tools/types.js +258 -7
  69. package/dist/tools/vice.js +5 -16
  70. package/dist/vicBitmap.js +1 -1
  71. package/dist/vice/mockServer.js +3 -0
  72. package/dist/vice/process.js +19 -0
  73. package/dist/vice/resourcePolicy.js +36 -0
  74. package/dist/vice/viceClient.js +22 -5
  75. package/doc/vice/support-matrix.md +17 -11
  76. package/mcp/schemas/c64_batch.schema.json +2 -1
  77. package/mcp/schemas/c64_config.schema.json +25 -475
  78. package/mcp/schemas/c64_debug.schema.json +3 -602
  79. package/mcp/schemas/c64_disk.schema.json +17 -344
  80. package/mcp/schemas/c64_drive.schema.json +2 -157
  81. package/mcp/schemas/c64_extract.schema.json +10 -364
  82. package/mcp/schemas/c64_graphics.schema.json +19 -426
  83. package/mcp/schemas/c64_input.schema.json +5 -318
  84. package/mcp/schemas/c64_memory.schema.json +23 -491
  85. package/mcp/schemas/c64_printer.schema.json +3 -258
  86. package/mcp/schemas/c64_program.schema.json +16 -491
  87. package/mcp/schemas/c64_rag.schema.json +1 -81
  88. package/mcp/schemas/c64_select_backend.schema.json +2 -36
  89. package/mcp/schemas/c64_sound.schema.json +20 -822
  90. package/mcp/schemas/c64_stream.schema.json +4 -75
  91. package/mcp/schemas/c64_system.schema.json +28 -348
  92. package/mcp/schemas/c64_vice.schema.json +2 -71
  93. package/mcp/server.json +1 -1
  94. package/mcp/tools.json +1983 -6910
  95. package/mcp.json +21 -1
  96. package/package.json +16 -8
  97. package/scripts/mockC64Server.mjs +44 -0
  98. package/scripts/run-coverage.mjs +27 -1
  99. package/scripts/smoke-run.sh +3 -2
  100. package/scripts/start.mjs +23 -1
  101. package/server.json +35 -2
  102. package/dist/.tsbuildinfo +0 -1
  103. package/doc/agent-state-spec.md +0 -243
  104. package/doc/audit/secret-handling.md +0 -61
  105. package/doc/plans/bun/baseline-metrics.md +0 -16
  106. package/doc/plans/bun/migration-checklist.md +0 -55
  107. package/doc/plans/bun/migration.md +0 -83
  108. package/doc/plans/improve-knowledge/notes.md +0 -43
  109. package/doc/plans/improve-knowledge/plan.md +0 -121
  110. package/doc/plans/improve-knowledge/rollout.md +0 -110
  111. package/doc/plans/meta-tools/README.md +0 -110
  112. package/doc/plans/meta-tools/analysis.md +0 -272
  113. package/doc/plans/meta-tools/catalog.md +0 -435
  114. package/doc/plans/meta-tools/notes.md +0 -192
  115. package/doc/plans/meta-tools/rollout.md +0 -368
  116. package/doc/plans/prompts/README.md +0 -96
  117. package/doc/plans/prompts/user-prompt-ideas.md +0 -147
  118. package/doc/plans/tool-consolidation/notes.md +0 -28
  119. package/doc/plans/tool-consolidation/plan.md +0 -169
  120. package/doc/plans/tool-consolidation/rollout.md +0 -104
  121. package/doc/plans/vice-backend-switch/plan.md +0 -486
  122. package/doc/plans/vice-backend-switch-2/plan.md +0 -48
  123. package/doc/plans/vice-support/notes.md +0 -63
  124. package/doc/plans/vice-support/plan.md +0 -88
  125. package/doc/plans/vice-support/rollout.md +0 -99
  126. package/doc/reviews/audit.md +0 -143
  127. package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
  128. package/doc/reviews/hardening/01-fable/prompt.md +0 -98
  129. package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
  130. package/doc/reviews/hardening/01-fable/review.md +0 -1234
  131. package/doc/reviews/vice-support-review.md +0 -185
  132. package/scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json +0 -2513
@@ -1,5 +1,6 @@
1
1
  import { defineToolModule } from "./types.js";
2
- import { objectSchema } from "./schema.js";
2
+ import { booleanSchema, numberSchema, objectSchema, optionalSchema } from "./schema.js";
3
+ import { waitForScreenText, MAX_WAIT_TIMEOUT_MS } from "./meta/screen.js";
3
4
  import { jsonResult, textResult } from "./responses.js";
4
5
  import { Buffer } from "node:buffer";
5
6
  import { ToolError, ToolExecutionError, toolErrorResult, unknownErrorResult, } from "./errors.js";
@@ -31,14 +32,104 @@ function isMissingMenuScreenEndpoint(error) {
31
32
  && "response" in error
32
33
  && error.response?.status === 404;
33
34
  }
34
- const resetArgsSchema = createNoArgsSchema("No arguments required to reset the C64.");
35
- const rebootArgsSchema = createNoArgsSchema("No arguments required to reboot the C64 firmware.");
35
+ function createWaitSchema(description) {
36
+ return objectSchema({
37
+ description,
38
+ properties: {
39
+ waitForReady: optionalSchema(booleanSchema({
40
+ description: "Wait until the BASIC READY. prompt is visible before returning. A timeout is reported as ready=false, not as an error.",
41
+ default: false,
42
+ }), false),
43
+ readyTimeoutMs: optionalSchema(numberSchema({
44
+ description: "Maximum time to wait for READY. when waitForReady is true (1-120000 ms).",
45
+ integer: true,
46
+ minimum: 1,
47
+ maximum: MAX_WAIT_TIMEOUT_MS,
48
+ default: 15000,
49
+ }), 15000),
50
+ },
51
+ additionalProperties: false,
52
+ });
53
+ }
54
+ const READY_SETTLE_MS = 300;
55
+ /** After this long, a screen identical to the one before the reset counts as the new boot screen. */
56
+ const READY_UNCHANGED_GRACE_MS = 3000;
57
+ const SNAPSHOT_TIMEOUT_MS = 2000;
58
+ /** Resolves to undefined when the operation does not settle within `ms`; the late result is ignored. */
59
+ async function withinMs(operation, ms) {
60
+ let timer;
61
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), Math.max(0, ms)); });
62
+ try {
63
+ return await Promise.race([operation, timeout]);
64
+ }
65
+ finally {
66
+ if (timer)
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+ /** Screen text before the reset, used to tell a stale READY. from the new one. Best effort: undefined when unreadable. */
71
+ async function screenBeforeReset(ctx, parsed) {
72
+ if (parsed.waitForReady !== true)
73
+ return undefined;
74
+ try {
75
+ return await withinMs(ctx.client.readScreen(), SNAPSHOT_TIMEOUT_MS);
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Polls the screen until READY. appears or the timeout elapses. Never throws.
83
+ * A screen identical to the one read before the reset is ignored for the first
84
+ * few seconds, so the READY. still visible from before the reset is not taken
85
+ * for the new one. Each read is bounded by the remaining time.
86
+ */
87
+ async function waitForReadyPrompt(ctx, timeoutMs, before) {
88
+ const start = Date.now();
89
+ await new Promise((resolve) => setTimeout(resolve, Math.min(READY_SETTLE_MS, timeoutMs)));
90
+ let lastError;
91
+ const tolerantClient = {
92
+ async readScreen() {
93
+ const remaining = timeoutMs - (Date.now() - start);
94
+ try {
95
+ const screen = await withinMs(ctx.client.readScreen(), remaining);
96
+ if (screen === undefined)
97
+ return "";
98
+ lastError = undefined;
99
+ if (before !== undefined && screen === before && Date.now() - start < READY_UNCHANGED_GRACE_MS)
100
+ return "";
101
+ return screen;
102
+ }
103
+ catch (error) {
104
+ lastError = error instanceof Error ? error.message : String(error);
105
+ return ""; // device not reachable yet
106
+ }
107
+ },
108
+ };
109
+ const remaining = Math.max(1, timeoutMs - (Date.now() - start));
110
+ const outcome = await waitForScreenText(tolerantClient, { pattern: "READY.", timeoutMs: remaining, intervalMs: 250 });
111
+ return { ready: outcome.matched, readyElapsedMs: Date.now() - start, ...(outcome.matched || !lastError ? {} : { lastError }) };
112
+ }
113
+ async function withReadyResult(ctx, parsed, before, baseMessage, details) {
114
+ if (parsed.waitForReady !== true) {
115
+ return textResult(baseMessage, { success: true, details });
116
+ }
117
+ const { ready, readyElapsedMs, lastError } = await waitForReadyPrompt(ctx, parsed.readyTimeoutMs ?? 15000, before);
118
+ const message = ready
119
+ ? `${baseMessage} READY. prompt reached after ${readyElapsedMs} ms.`
120
+ : `${baseMessage} The READY. prompt was not seen within ${readyElapsedMs} ms; the machine may still be starting.`
121
+ + (lastError ? ` The last screen read failed: ${lastError}` : "");
122
+ return textResult(message, { success: true, details, ready, readyElapsedMs, ...(lastError ? { readyError: lastError } : {}) });
123
+ }
124
+ const resetArgsSchema = createWaitSchema("Reset the C64. Optionally wait for the READY. prompt.");
125
+ const rebootArgsSchema = createWaitSchema("Reboot the C64 firmware. Optionally wait for the READY. prompt.");
36
126
  const pauseArgsSchema = createNoArgsSchema("No arguments required to pause the machine.");
37
127
  const resumeArgsSchema = createNoArgsSchema("No arguments required to resume the machine after a pause.");
38
128
  const poweroffArgsSchema = createNoArgsSchema("No arguments required to power off the machine.");
39
- const powerCycleArgsSchema = createNoArgsSchema("No arguments required to power cycle the active system.");
129
+ const powerCycleArgsSchema = createWaitSchema("Power cycle the active system. Optionally wait for the READY. prompt.");
40
130
  const menuButtonArgsSchema = createNoArgsSchema("No arguments required to toggle the Ultimate menu button.");
41
131
  const readMenuScreenArgsSchema = createNoArgsSchema("No arguments required to read the active Ultimate menu screen matrix.");
132
+ const POWER_RESTORE_NOTE = "The machine cannot be restarted by software afterwards: power must be restored at the outlet, and the machine starts by itself only if automatic startup on power restoration is enabled.";
42
133
  export const machineControlModule = defineToolModule({
43
134
  domain: "machine",
44
135
  summary: "Power, reset, pause/resume, and diagnostic controls for the C64 and Ultimate hardware.",
@@ -66,8 +157,9 @@ export const machineControlModule = defineToolModule({
66
157
  supportedPlatforms: ["c64u", "u2", "vice"],
67
158
  async execute(args, ctx) {
68
159
  try {
69
- resetArgsSchema.parse(args ?? {});
160
+ const parsed = resetArgsSchema.parse(args ?? {});
70
161
  ctx.logger.info("Resetting C64");
162
+ const before = await screenBeforeReset(ctx, parsed);
71
163
  const result = await ctx.client.reset();
72
164
  if (!result.success) {
73
165
  throw new ToolExecutionError("C64 backend reported failure while resetting", {
@@ -75,10 +167,7 @@ export const machineControlModule = defineToolModule({
75
167
  });
76
168
  }
77
169
  const details = toRecord(result.details) ?? {};
78
- return textResult("C64 reset command issued successfully.", {
79
- success: true,
80
- details,
81
- });
170
+ return await withReadyResult(ctx, parsed, before, "C64 reset command issued successfully.", details);
82
171
  }
83
172
  catch (error) {
84
173
  if (error instanceof ToolError) {
@@ -104,8 +193,9 @@ export const machineControlModule = defineToolModule({
104
193
  supportedPlatforms: ["c64u", "u2", "vice"],
105
194
  async execute(args, ctx) {
106
195
  try {
107
- rebootArgsSchema.parse(args ?? {});
196
+ const parsed = rebootArgsSchema.parse(args ?? {});
108
197
  ctx.logger.info("Rebooting C64");
198
+ const before = await screenBeforeReset(ctx, parsed);
109
199
  const result = await ctx.client.reboot();
110
200
  if (!result.success) {
111
201
  throw new ToolExecutionError("C64 backend reported failure while rebooting", {
@@ -113,10 +203,7 @@ export const machineControlModule = defineToolModule({
113
203
  });
114
204
  }
115
205
  const details = toRecord(result.details) ?? {};
116
- return textResult("C64 reboot command issued successfully.", {
117
- success: true,
118
- details,
119
- });
206
+ return await withReadyResult(ctx, parsed, before, "C64 reboot command issued successfully.", details);
120
207
  }
121
208
  catch (error) {
122
209
  if (error instanceof ToolError) {
@@ -144,7 +231,8 @@ export const machineControlModule = defineToolModule({
144
231
  try {
145
232
  pauseArgsSchema.parse(args ?? {});
146
233
  ctx.logger.info("Pausing C64 execution");
147
- const result = await ctx.client.pause();
234
+ // An explicit pause stays in effect until an explicit resume, even if tool calls pause and resume meanwhile.
235
+ const result = ctx.client.explicitPause ? await ctx.client.explicitPause(await ctx.client.pinFacade?.()) : await ctx.client.pause();
148
236
  if (!result.success) {
149
237
  throw new ToolExecutionError("C64 backend reported failure while pausing", {
150
238
  details: normaliseFailure(result.details),
@@ -182,7 +270,7 @@ export const machineControlModule = defineToolModule({
182
270
  try {
183
271
  resumeArgsSchema.parse(args ?? {});
184
272
  ctx.logger.info("Resuming C64 execution");
185
- const result = await ctx.client.resume();
273
+ const result = ctx.client.explicitResume ? await ctx.client.explicitResume(await ctx.client.pinFacade?.()) : await ctx.client.resume();
186
274
  if (!result.success) {
187
275
  throw new ToolExecutionError("C64 backend reported failure while resuming", {
188
276
  details: normaliseFailure(result.details),
@@ -204,7 +292,7 @@ export const machineControlModule = defineToolModule({
204
292
  },
205
293
  {
206
294
  name: "poweroff",
207
- description: "Power off the machine via Ultimate firmware. See safety notes in c64://guide/bootstrap.",
295
+ description: `Power off the machine via Ultimate firmware. See safety notes in c64://guide/bootstrap. ${POWER_RESTORE_NOTE}`,
208
296
  summary: "Attempts a controlled shutdown through the Ultimate control interface.",
209
297
  inputSchema: poweroffArgsSchema.jsonSchema,
210
298
  tags: ["power"],
@@ -227,7 +315,10 @@ export const machineControlModule = defineToolModule({
227
315
  });
228
316
  }
229
317
  const details = toRecord(result.details) ?? {};
230
- return textResult("Power off command acknowledged.", {
318
+ const message = ctx.platform?.id === "vice"
319
+ ? "Power off command acknowledged."
320
+ : `Power off command acknowledged. ${POWER_RESTORE_NOTE}`;
321
+ return textResult(message, {
231
322
  success: true,
232
323
  details,
233
324
  });
@@ -257,8 +348,9 @@ export const machineControlModule = defineToolModule({
257
348
  supportedPlatforms: ["c64u", "u2", "vice"],
258
349
  async execute(args, ctx) {
259
350
  try {
260
- powerCycleArgsSchema.parse(args ?? {});
351
+ const parsed = powerCycleArgsSchema.parse(args ?? {});
261
352
  ctx.logger.info("Power cycling active backend");
353
+ const before = await screenBeforeReset(ctx, parsed);
262
354
  const result = await ctx.client.powerCycle();
263
355
  if (!result.success) {
264
356
  throw new ToolExecutionError("Power cycle did not complete", {
@@ -266,7 +358,7 @@ export const machineControlModule = defineToolModule({
266
358
  });
267
359
  }
268
360
  const details = toRecord(result.details) ?? {};
269
- return textResult("Power-cycle request completed.", { success: true, details });
361
+ return await withReadyResult(ctx, parsed, before, "Power-cycle request completed.", details);
270
362
  }
271
363
  catch (error) {
272
364
  if (error instanceof ToolError)
@@ -9,6 +9,10 @@ import { disassemble, formatDisassembly } from "./translation/disassembler.js";
9
9
  import { getViceSymbols } from "./translation/symbolRegistry.js";
10
10
  import { resolveAddressSymbol } from "../knowledge.js";
11
11
  import { stripHexByteSeparators } from "./hex.js";
12
+ import { resolveHostPath } from "../hostPaths.js";
13
+ import { petsciiToAscii } from "../petscii.js";
14
+ import { listBasicProgram, MAX_BASIC_IMAGE_BYTES } from "./translation/basicDetokenizer.js";
15
+ import { assertAddressRange, assertNotBareHex, parseNumericLiteral } from "../numericArg.js";
12
16
  function toRecord(details) {
13
17
  if (details && typeof details === "object") {
14
18
  return details;
@@ -120,6 +124,11 @@ const readMemoryArgsSchema = objectSchema({
120
124
  maximum: 4096,
121
125
  default: 256,
122
126
  }),
127
+ format: optionalSchema(stringSchema({
128
+ description: "Output layout. hex (default) returns one hex string; dump adds a 16-bytes-per-line listing with addresses and a printable PETSCII column.",
129
+ enum: ["hex", "dump"],
130
+ default: "hex",
131
+ }), "hex"),
123
132
  },
124
133
  required: ["address"],
125
134
  additionalProperties: false,
@@ -166,11 +175,19 @@ async function executeReadScreen(rawArgs, ctx) {
166
175
  try {
167
176
  readScreenArgsSchema.parse(rawArgs ?? {});
168
177
  ctx.logger.info("Reading C64 screen contents");
169
- const screen = await ctx.client.readScreen();
178
+ const client = ctx.client;
179
+ const detailed = typeof client.readScreenDetailed === "function" ? await client.readScreenDetailed() : undefined;
180
+ const screen = detailed ? detailed.text : await ctx.client.readScreen();
170
181
  return textResult(`Current screen contents:\n${screen}`, {
171
182
  success: true,
172
183
  screen,
173
184
  length: screen.length,
185
+ ...(detailed
186
+ ? {
187
+ screenBase: `$${detailed.baseAddress.toString(16).toUpperCase().padStart(4, "0")}`,
188
+ charset: detailed.charset,
189
+ }
190
+ : {}),
174
191
  });
175
192
  }
176
193
  catch (error) {
@@ -180,10 +197,77 @@ async function executeReadScreen(rawArgs, ctx) {
180
197
  return unknownErrorResult(error);
181
198
  }
182
199
  }
200
+ const listBasicArgsSchema = objectSchema({
201
+ description: "List the BASIC program currently in memory. Reads the start ($2B/$2C) and end ($2D/$2E) pointers and detokenises the program text.",
202
+ properties: {},
203
+ additionalProperties: false,
204
+ });
205
+ async function readRange(ctx, start, length) {
206
+ const chunks = [];
207
+ for (let offset = 0; offset < length; offset += 4096) {
208
+ const count = Math.min(4096, length - offset);
209
+ const address = `$${(start + offset).toString(16).toUpperCase().padStart(4, "0")}`;
210
+ const result = await ctx.client.readMemory(address, String(count));
211
+ if (!result.success) {
212
+ throw new ToolExecutionError("C64 backend reported failure while reading memory", { details: normaliseFailure(result.details) });
213
+ }
214
+ const bytes = parseFirmwareHex(result.data, "BASIC program read").bytes;
215
+ if (bytes.length !== count) {
216
+ throw new ToolExecutionError("Memory read returned an unexpected number of bytes", { details: { address, expected: count, received: bytes.length } });
217
+ }
218
+ chunks.push(bytes);
219
+ }
220
+ return Buffer.concat(chunks);
221
+ }
222
+ async function executeListBasic(rawArgs, ctx) {
223
+ try {
224
+ listBasicArgsSchema.parse(rawArgs ?? {});
225
+ const pointers = await readRange(ctx, 0x002b, 4);
226
+ const start = pointers[0] | (pointers[1] << 8);
227
+ const end = pointers[2] | (pointers[3] << 8);
228
+ const fmt = (value) => `$${value.toString(16).toUpperCase().padStart(4, "0")}`;
229
+ if (end <= start) {
230
+ return jsonResult({ start: fmt(start), end: fmt(end), lines: [], listing: "" }, { success: true, lineCount: 0 });
231
+ }
232
+ const length = end - start;
233
+ if (length > MAX_BASIC_IMAGE_BYTES) {
234
+ throw new ToolExecutionError("BASIC end pointer is implausible", { details: { start: fmt(start), end: fmt(end) } });
235
+ }
236
+ const image = await readRange(ctx, start, length);
237
+ const lines = listBasicProgram(image, start);
238
+ const listing = lines.map((line) => `${line.number} ${line.text}`).join("\n");
239
+ return jsonResult({ start: fmt(start), end: fmt(end), lines, listing }, { success: true, lineCount: lines.length });
240
+ }
241
+ catch (error) {
242
+ if (error instanceof ToolError)
243
+ return toolErrorResult(error);
244
+ return unknownErrorResult(error);
245
+ }
246
+ }
247
+ /** 16 bytes per line: `$ADDR: HH HH ... |text|`. Bytes with no printable PETSCII glyph show as `.`. */
248
+ export function formatHexDump(start, bytes) {
249
+ const lines = [];
250
+ for (let offset = 0; offset < bytes.length; offset += 16) {
251
+ const row = bytes.subarray(offset, offset + 16);
252
+ const hex = Array.from(row, (b) => b.toString(16).toUpperCase().padStart(2, "0")).join(" ").padEnd(16 * 3 - 1, " ");
253
+ const text = Array.from(row, (b) => {
254
+ if (b === 0x20 || b === 0xa0)
255
+ return " ";
256
+ const ch = petsciiToAscii(Uint8Array.of(b));
257
+ return ch.length === 1 && ch > " " && ch <= "~" ? ch : ".";
258
+ }).join("");
259
+ const address = ((start + offset) & 0xffff).toString(16).toUpperCase().padStart(4, "0");
260
+ lines.push(`$${address}: ${hex} |${text}|`);
261
+ }
262
+ return lines;
263
+ }
183
264
  async function executeReadMemory(rawArgs, ctx) {
184
265
  try {
185
266
  const parsed = readMemoryArgsSchema.parse(rawArgs ?? {});
186
267
  ctx.logger.info("Reading C64 memory", { address: parsed.address, length: parsed.length });
268
+ // Validate before any device call so a bad address is a validation error.
269
+ const readStart = parseAddressArg(parsed.address, "$.address");
270
+ assertAddressRange(readStart, parsed.length ?? 256, "$.length");
187
271
  const result = await ctx.client.readMemory(parsed.address, String(parsed.length));
188
272
  if (!result.success) {
189
273
  throw new ToolExecutionError("C64 backend reported failure while reading memory", {
@@ -193,15 +277,21 @@ async function executeReadMemory(rawArgs, ctx) {
193
277
  const detailRecord = toRecord(result.details) ?? {};
194
278
  const resolvedAddress = resolveAddressLabel(detailRecord, parsed.address);
195
279
  const resolvedLength = resolveLength(detailRecord) ?? parsed.length;
280
+ const dumpLines = parsed.format === "dump" && typeof result.data === "string"
281
+ ? formatHexDump(readStart, parseFirmwareHex(result.data, "memory read").bytes)
282
+ : undefined;
196
283
  const data = {
197
284
  success: true,
198
285
  address: resolvedAddress,
199
286
  length: resolvedLength,
200
287
  hexData: result.data ?? null,
288
+ ...(dumpLines ? { dump: dumpLines } : {}),
201
289
  details: detailRecord,
202
290
  };
203
291
  // Many clients show only the text content, so it carries the bytes too.
204
- const hexSummary = typeof result.data === "string" && result.data.length > 0 ? `\n${result.data}` : "";
292
+ const hexSummary = dumpLines
293
+ ? `\n${dumpLines.join("\n")}`
294
+ : typeof result.data === "string" && result.data.length > 0 ? `\n${result.data}` : "";
205
295
  const base = textResult(`Read ${resolvedLength} bytes starting at ${resolvedAddress}.${hexSummary}`, data);
206
296
  return {
207
297
  ...base,
@@ -226,6 +316,7 @@ async function executeWriteMemory(rawArgs, ctx) {
226
316
  const expectedInfo = parsed.expected ? parseUserHex(parsed.expected, "$.expected") : undefined;
227
317
  const maskInfo = parsed.mask ? parseUserHex(parsed.mask, "$.mask") : undefined;
228
318
  const shouldVerify = parsed.verify || Boolean(expectedInfo) || Boolean(maskInfo);
319
+ assertAddressRange(parseAddressArg(parsed.address, "$.address"), writeInfo.bytes.length, "$.bytes");
229
320
  if (!shouldVerify) {
230
321
  const result = await ctx.client.writeMemory(parsed.address, writeInfo.canonical);
231
322
  if (!result.success) {
@@ -249,7 +340,7 @@ async function executeWriteMemory(rawArgs, ctx) {
249
340
  let paused = false;
250
341
  try {
251
342
  if (canPause) {
252
- const pauseResult = await ctx.client.pause(pinnedFacade);
343
+ const pauseResult = ctx.client.acquirePause ? await ctx.client.acquirePause(pinnedFacade) : await ctx.client.pause(pinnedFacade);
253
344
  if (!pauseResult.success) {
254
345
  throw new ToolExecutionError("C64 backend reported failure while pausing", {
255
346
  details: normaliseFailure(pauseResult.details),
@@ -339,7 +430,8 @@ async function executeWriteMemory(rawArgs, ctx) {
339
430
  verificationMetadata.preReadMismatches = preMismatches;
340
431
  }
341
432
  if (paused) { // A failed resume here leaves the machine paused: a recoverable operation failure, not a log.
342
- const resumeResult = await ctx.client.resume(pinnedFacade);
433
+ const resumeResult = ctx.client.releasePause ? await ctx.client.releasePause(pinnedFacade) : await ctx.client.resume(pinnedFacade);
434
+ paused = false; // released once, whatever the outcome: the finally block must not release again
343
435
  if (!resumeResult.success) {
344
436
  throw new ToolExecutionError("Write completed but the machine could not be resumed", {
345
437
  details: {
@@ -366,7 +458,7 @@ async function executeWriteMemory(rawArgs, ctx) {
366
458
  finally {
367
459
  if (paused) {
368
460
  try {
369
- const resumeResult = await ctx.client.resume(pinnedFacade);
461
+ const resumeResult = ctx.client.releasePause ? await ctx.client.releasePause(pinnedFacade) : await ctx.client.resume(pinnedFacade);
370
462
  if (!resumeResult.success) {
371
463
  ctx.logger.warn("C64 resume reported failure after write", {
372
464
  details: normaliseFailure(resumeResult.details),
@@ -406,7 +498,7 @@ const disassembleArgsSchema = objectSchema({
406
498
  default: 64,
407
499
  }),
408
500
  instructionCount: optionalSchema(numberSchema({
409
- description: "Stop after this many instructions (overrides length as a termination condition).",
501
+ description: "Stop after this many instructions. When given, it replaces length: 3 x instructionCount bytes are read (fewer near $FFFF).",
410
502
  integer: true,
411
503
  minimum: 1,
412
504
  maximum: 512,
@@ -418,9 +510,13 @@ const disassembleArgsSchema = objectSchema({
418
510
  async function executeDisassemble(rawArgs, ctx) {
419
511
  try {
420
512
  const parsed = disassembleArgsSchema.parse(rawArgs ?? {});
421
- const length = parsed.length ?? 64;
422
- ctx.logger.info("Disassembling VICE memory", { address: parsed.address, length });
423
513
  const address = parseAddressArg(parsed.address, "$.address");
514
+ // An instruction is at most 3 bytes, so 3 x instructionCount bytes always
515
+ // cover the requested instructions; clamp at the end of the address space.
516
+ const length = parsed.instructionCount !== undefined
517
+ ? Math.min(3 * parsed.instructionCount, 0x10000 - address)
518
+ : parsed.length ?? 64;
519
+ ctx.logger.info("Disassembling VICE memory", { address: parsed.address, length });
424
520
  validateRangeWithinAddressSpace(address, length, "$.address");
425
521
  const bytes = await ctx.client.readMemoryRaw(address, length);
426
522
  const symbols = ctx.platform?.id === "vice" ? getViceSymbols() : undefined;
@@ -432,6 +528,14 @@ async function executeDisassemble(rawArgs, ctx) {
432
528
  address: addrHex,
433
529
  bytesRead: bytes.length,
434
530
  instructionCount: lines.length,
531
+ lines: lines.map((line) => ({
532
+ address: `$${line.address.toString(16).toUpperCase().padStart(4, "0")}`,
533
+ bytes: line.bytes.map((value) => value.toString(16).toUpperCase().padStart(2, "0")),
534
+ ...(line.label ? { label: line.label } : {}),
535
+ mnemonic: line.mnemonic,
536
+ operand: line.operand,
537
+ undocumented: line.undocumented,
538
+ })),
435
539
  });
436
540
  }
437
541
  catch (error) {
@@ -446,26 +550,16 @@ async function executeDisassemble(rawArgs, ctx) {
446
550
  // ---------------------------------------------------------------------------
447
551
  function parseAddressArg(raw, fieldPath) {
448
552
  const trimmed = raw.trim();
449
- let addr;
450
- if (/^\$[0-9A-Fa-f]+$/.test(trimmed)) {
451
- addr = parseInt(trimmed.slice(1), 16);
452
- }
453
- else if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
454
- addr = parseInt(trimmed.slice(2), 16);
455
- }
456
- else if (/^[0-9]+$/.test(trimmed)) {
457
- addr = parseInt(trimmed, 10);
458
- }
459
- else {
553
+ let addr = parseNumericLiteral(trimmed, fieldPath);
554
+ if (addr === undefined) {
460
555
  const resolved = resolveAddressSymbol(trimmed);
461
556
  if (resolved === undefined) {
557
+ assertNotBareHex(trimmed, fieldPath);
462
558
  throw new ToolValidationError(`Unknown address or symbol: ${trimmed}`, { path: fieldPath });
463
559
  }
464
560
  addr = resolved;
465
561
  }
466
- if (!Number.isInteger(addr) || addr < 0 || addr > 0xffff) {
467
- throw new ToolValidationError("Address must be in range $0000-$FFFF", { path: fieldPath });
468
- }
562
+ assertAddressRange(addr, undefined, fieldPath);
469
563
  return addr;
470
564
  }
471
565
  function fmtAddr(addr) {
@@ -584,14 +678,19 @@ async function executeSearchMemory(rawArgs, ctx) {
584
678
  ctx.logger.info("Searching memory", { start: fmtAddr(start), end: fmtAddr(end), pattern: parsed.pattern });
585
679
  const haystack = await ctx.client.readMemoryRaw(start, len);
586
680
  const matches = [];
587
- outer: for (let i = 0; i <= haystack.length - needle.length && matches.length < maxResults; i++) {
681
+ // The whole range is already read, so counting past maxResults costs no device call.
682
+ let totalMatches = 0;
683
+ outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
588
684
  for (let j = 0; j < needle.length; j++) {
589
685
  if (haystack[i + j] !== needle[j])
590
686
  continue outer;
591
687
  }
592
- matches.push(fmtAddr(start + i));
688
+ totalMatches += 1;
689
+ if (matches.length < maxResults)
690
+ matches.push(fmtAddr(start + i));
593
691
  }
594
- return jsonResult({ found: matches.length, matches, pattern: parsed.pattern.trim(), range: { start: fmtAddr(start), end: fmtAddr(end) } }, { success: true, found: matches.length });
692
+ const truncated = totalMatches > matches.length;
693
+ return jsonResult({ found: matches.length, totalMatches, truncated, matches, pattern: parsed.pattern.trim(), range: { start: fmtAddr(start), end: fmtAddr(end) } }, { success: true, found: matches.length, totalMatches, truncated });
595
694
  }
596
695
  catch (error) {
597
696
  if (error instanceof ToolError)
@@ -628,8 +727,12 @@ async function executeCompareMemory(rawArgs, ctx) {
628
727
  ctx.client.readMemoryRaw(a2, len),
629
728
  ]);
630
729
  const diffs = [];
631
- for (let i = 0; i < len && diffs.length < maxDiffs; i++) {
730
+ let totalDiffs = 0;
731
+ for (let i = 0; i < len; i++) {
632
732
  if (r1[i] !== r2[i]) {
733
+ totalDiffs += 1;
734
+ if (diffs.length >= maxDiffs)
735
+ continue;
633
736
  diffs.push({
634
737
  offset: i,
635
738
  address1: fmtAddr(a1 + i),
@@ -639,8 +742,9 @@ async function executeCompareMemory(rawArgs, ctx) {
639
742
  });
640
743
  }
641
744
  }
642
- const identical = diffs.length === 0;
643
- return jsonResult({ identical, diffCount: diffs.length, diffs, region1: fmtAddr(a1), region2: fmtAddr(a2), length: len }, { success: true, identical, diffCount: diffs.length });
745
+ const identical = totalDiffs === 0;
746
+ const truncated = totalDiffs > diffs.length;
747
+ return jsonResult({ identical, diffCount: totalDiffs, returnedDiffs: diffs.length, truncated, diffs, region1: fmtAddr(a1), region2: fmtAddr(a2), length: len }, { success: true, identical, diffCount: totalDiffs, truncated });
644
748
  }
645
749
  catch (error) {
646
750
  if (error instanceof ToolError)
@@ -671,7 +775,7 @@ async function executeSaveMemory(rawArgs, ctx) {
671
775
  throw new ToolValidationError("endAddress must be ≥ startAddress", { path: "$.endAddress" });
672
776
  const asPrg = parsed.asPrg !== false;
673
777
  const len = end - start + 1;
674
- const resolvedPath = path.resolve(parsed.filePath);
778
+ const resolvedPath = resolveHostPath(parsed.filePath, { purpose: "write" });
675
779
  ctx.logger.info("Saving memory to file", { start: fmtAddr(start), end: fmtAddr(end), filePath: resolvedPath, asPrg });
676
780
  const bytes = await ctx.client.readMemoryRaw(start, len);
677
781
  const header = asPrg ? Buffer.from([start & 0xff, (start >> 8) & 0xff]) : Buffer.alloc(0);
@@ -690,6 +794,7 @@ export const memoryOperationHandlers = {
690
794
  read: async (args, ctx) => executeReadMemory(stripOperationDiscriminator(args), ctx),
691
795
  write: async (args, ctx) => executeWriteMemory(stripOperationDiscriminator(args), ctx),
692
796
  read_screen: async (args, ctx) => executeReadScreen(stripOperationDiscriminator(args), ctx),
797
+ list_basic: async (args, ctx) => executeListBasic(stripOperationDiscriminator(args), ctx),
693
798
  disassemble: async (args, ctx) => executeDisassemble(stripOperationDiscriminator(args), ctx),
694
799
  copy_memory: async (args, ctx) => executeCopyMemory(stripOperationDiscriminator(args), ctx),
695
800
  fill_memory: async (args, ctx) => executeFillMemory(stripOperationDiscriminator(args), ctx),
@@ -697,7 +802,7 @@ export const memoryOperationHandlers = {
697
802
  compare_memory: async (args, ctx) => executeCompareMemory(stripOperationDiscriminator(args), ctx),
698
803
  save_memory: async (args, ctx) => executeSaveMemory(stripOperationDiscriminator(args), ctx),
699
804
  };
700
- export { disassembleArgsSchema, copyMemoryArgsSchema, fillMemoryArgsSchema, searchMemoryArgsSchema, compareMemoryArgsSchema, saveMemoryArgsSchema, };
805
+ export { listBasicArgsSchema, disassembleArgsSchema, copyMemoryArgsSchema, fillMemoryArgsSchema, searchMemoryArgsSchema, compareMemoryArgsSchema, saveMemoryArgsSchema, };
701
806
  export const memoryModule = defineToolModule({
702
807
  domain: "memory",
703
808
  summary: "Screen, main memory, and low-level inspection utilities.",
@@ -3,7 +3,8 @@ import { jsonResult } from "../responses.js";
3
3
  import { ToolError, ToolValidationError, ToolUnsupportedPlatformError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
4
  import { getPlatformStatus } from "../../platform.js";
5
5
  import { promises as fs } from "node:fs";
6
- import { resolve as resolvePath, join as joinPath, relative, sep, isAbsolute } from "node:path";
6
+ import { resolve as resolvePath, join as joinPath, isAbsolute } from "node:path";
7
+ import { isWithin, resolveHostChildPath, resolveHostPath } from "../../hostPaths.js";
7
8
  const bundleRunArtifactsArgsSchema = objectSchema({
8
9
  description: "Gather screen capture, memory snapshot, and debugreg into a structured bundle.",
9
10
  properties: {
@@ -45,7 +46,7 @@ export const tools = [
45
46
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId) || runId === "." || runId === "..") {
46
47
  throw new ToolValidationError("runId must be a non-empty safe filename (letters, digits, dot, underscore, and dash only).", { path: "$.runId" });
47
48
  }
48
- const outputPath = resolvePath(String(parsed.outputPath));
49
+ const outputPath = resolveHostPath(String(parsed.outputPath), { purpose: "write" });
49
50
  const captureScreen = parsed.captureScreen !== false;
50
51
  const platform = ctx.platform?.id ?? getPlatformStatus().id;
51
52
  const captureDebugReg = parsed.captureDebugReg ?? (platform === "c64u");
@@ -105,8 +106,7 @@ function containedPath(base, name) {
105
106
  if (!name || isAbsolute(name) || name.includes("/") || name.includes("\\"))
106
107
  throw new ToolValidationError("Unsafe artifact filename.");
107
108
  const candidate = resolvePath(joinPath(base, name));
108
- const rel = relative(base, candidate);
109
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
109
+ if (candidate === base || !isWithin(base, candidate))
110
110
  throw new ToolValidationError("Artifact path escapes its output directory.");
111
- return candidate;
111
+ return resolveHostChildPath(base, name);
112
112
  }