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
@@ -5,6 +5,7 @@ Copyright (C) 2025 Christian Gleissner
5
5
  Licensed under the GNU General Public License v2.0 or later.
6
6
  See <https://www.gnu.org/licenses/> for details.
7
7
  */
8
+ const LOAD_CHECK_BYTES = 16;
8
9
  /**
9
10
  * Load poll configuration from environment variables with defaults.
10
11
  */
@@ -156,7 +157,49 @@ async function pollBasicOutcome(client, logger, config) {
156
157
  * to determine if program is alive or crashed.
157
158
  * First waits for RUN to appear, then monitors for activity across multiple memory regions.
158
159
  */
159
- async function pollAsmOutcome(client, logger, config) {
160
+ function toHex(bytes) {
161
+ return Array.from(bytes, (value) => value.toString(16).toUpperCase().padStart(2, "0")).join(" ");
162
+ }
163
+ /**
164
+ * Reads back up to 16 bytes at the load address and compares them with the
165
+ * assembled program. A U2-family firmware answers run_prg with success without
166
+ * loading anything, so this is the evidence that the program reached memory.
167
+ * Needs confirmation on U2 hardware; it is exercised against mocks only.
168
+ */
169
+ async function checkProgramLoaded(client, logger, program) {
170
+ const length = Math.min(LOAD_CHECK_BYTES, program.bytes.length, 0x10000 - program.address);
171
+ if (length <= 0) {
172
+ return { loadCheck: "skipped" };
173
+ }
174
+ const expected = program.bytes.subarray(0, length);
175
+ let found;
176
+ try {
177
+ found = await client.readMemoryRaw(program.address, length);
178
+ }
179
+ catch (error) {
180
+ logger.debug("Unable to read back the loaded program", { error });
181
+ return { loadCheck: "unavailable" };
182
+ }
183
+ if (!buffersEqual(found.subarray(0, length), expected)) {
184
+ return { loadCheck: "matched", load: { address: program.address, expected: toHex(expected), found: toHex(found.subarray(0, length)) } };
185
+ }
186
+ return { loadCheck: "matched" };
187
+ }
188
+ async function pollAsmOutcome(client, logger, config, program) {
189
+ let loadCheck = "skipped";
190
+ if (program) {
191
+ const check = await checkProgramLoaded(client, logger, program);
192
+ loadCheck = check.loadCheck;
193
+ if (check.load) {
194
+ return {
195
+ status: "not_loaded",
196
+ type: "ASM",
197
+ reason: `memory at $${program.address.toString(16).toUpperCase().padStart(4, "0")} does not contain the assembled program`,
198
+ loadCheck: "matched",
199
+ load: check.load,
200
+ };
201
+ }
202
+ }
160
203
  const startTime = Date.now();
161
204
  let pollCount = 0;
162
205
  let runDetected = false;
@@ -173,7 +216,7 @@ async function pollAsmOutcome(client, logger, config) {
173
216
  if (returnedToBasicAfterSys(screen)) {
174
217
  // The typed SYS line is followed by READY., so the routine ran and returned with RTS.
175
218
  logger.debug("ASM program returned to BASIC", { elapsed: Date.now() - startTime });
176
- return { status: "ok", type: "ASM" };
219
+ return { status: "ok", type: "ASM", loadCheck };
177
220
  }
178
221
  if (upperScreen.includes("RUN") || upperScreen.includes("SYS") || upperScreen.includes("ERROR")) {
179
222
  runDetected = true;
@@ -204,7 +247,7 @@ async function pollAsmOutcome(client, logger, config) {
204
247
  // A routine that works for a while before RTS shows READY. only after the first screen read.
205
248
  if (returnedToBasicAfterSys(await client.readScreen())) {
206
249
  logger.debug("ASM program returned to BASIC", { pollCount, elapsed: Date.now() - startTime });
207
- return { status: "ok", type: "ASM" };
250
+ return { status: "ok", type: "ASM", loadCheck };
208
251
  }
209
252
  }
210
253
  catch (error) {
@@ -235,14 +278,16 @@ async function pollAsmOutcome(client, logger, config) {
235
278
  return {
236
279
  status: "ok",
237
280
  type: "ASM",
281
+ loadCheck,
238
282
  };
239
283
  }
240
- // No activity detected - consider it crashed
241
- logger.debug("ASM program crashed - no VIC/CIA/TI/screen progression", { pollCount, elapsed: Date.now() - startTime });
284
+ // No screen change: the program may be running silently, so this is not evidence of a crash.
285
+ logger.debug("ASM program unconfirmed - no screen progression", { pollCount, elapsed: Date.now() - startTime });
242
286
  return {
243
- status: "crashed",
287
+ status: "unconfirmed",
244
288
  type: "ASM",
245
289
  reason: "no program-visible screen progression within window",
290
+ loadCheck,
246
291
  };
247
292
  }
248
293
  /**
@@ -254,7 +299,7 @@ async function pollAsmOutcome(client, logger, config) {
254
299
  * @param config - Optional poll configuration (uses environment defaults if not provided)
255
300
  * @returns Promise resolving to poll result
256
301
  */
257
- export async function pollForProgramOutcome(type, client, logger, config) {
302
+ export async function pollForProgramOutcome(type, client, logger, config, program) {
258
303
  const defaults = loadPollConfig();
259
304
  const pollConfig = {
260
305
  maxMs: config?.maxMs ?? defaults.maxMs,
@@ -271,5 +316,5 @@ export async function pollForProgramOutcome(type, client, logger, config) {
271
316
  if (type === "BASIC") {
272
317
  return pollBasicOutcome(client, logger, pollConfig);
273
318
  }
274
- return pollAsmOutcome(client, logger, pollConfig);
319
+ return pollAsmOutcome(client, logger, pollConfig, program);
275
320
  }
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { resolveHostPath } from "../hostPaths.js";
2
3
  import { assemblyToPrgDetailed, AssemblyError } from "./translation/assembler.js";
3
4
  import { basicToPrg } from "./translation/basicTokenizer.js";
4
5
  import { defineToolModule, OPERATION_DISCRIMINATOR, } from "./types.js";
@@ -117,10 +118,13 @@ function attemptAutoFixBasicProgram(program, errors) {
117
118
  }
118
119
  if (notes.length > 0) {
119
120
  const updated = content.length > 0 ? `${lineNumber} ${content}` : `${lineNumber}`;
121
+ const before = rawLine.trim();
120
122
  lines[index] = updated;
121
123
  changes.push({
122
124
  line: lineNumber,
123
125
  notes,
126
+ before,
127
+ after: updated,
124
128
  });
125
129
  }
126
130
  }
@@ -206,6 +210,10 @@ const uploadBasicArgsSchema = objectSchema({
206
210
  description: "Run post-execution polling to ensure the BASIC program executed without errors.",
207
211
  default: false,
208
212
  }),
213
+ autoFix: optionalSchema(booleanSchema({
214
+ description: "When a BASIC error is detected on screen, append a missing closing quote or parentheses to the failing line and run the program again (default true). The rewritten lines are reported with before and after text. Set false for programs with side effects, which must not run twice.",
215
+ default: true,
216
+ })),
209
217
  },
210
218
  required: ["program"],
211
219
  additionalProperties: false,
@@ -218,7 +226,7 @@ const uploadAsmArgsSchema = objectSchema({
218
226
  minLength: 1,
219
227
  }),
220
228
  verify: booleanSchema({
221
- description: "Run post-execution polling to confirm the assembly program remained stable.",
229
+ description: "Treat missing evidence of execution as an error. The bytes read back at the load address must always match the assembled program (a mismatch is an error either way). With verify:true, a program that loaded but changed nothing on screen is an error; with verify:false it returns success with execution:\"unconfirmed\" (SID players, border effects and JMP * loops change no screen RAM).",
222
230
  default: false,
223
231
  }),
224
232
  },
@@ -287,7 +295,7 @@ async function executeUploadRunBasic(rawArgs, ctx) {
287
295
  errors: errors.map((error) => ({ line: error.line, type: error.type })),
288
296
  });
289
297
  const normalizedErrors = normalizeRuntimeErrors(errors);
290
- const fixAttempt = attemptAutoFixBasicProgram(activeProgram, errors);
298
+ const fixAttempt = parsed.autoFix === false ? undefined : attemptAutoFixBasicProgram(activeProgram, errors);
291
299
  if (!fixAttempt) {
292
300
  const data = {
293
301
  kind: "basic_runtime_error",
@@ -296,9 +304,12 @@ async function executeUploadRunBasic(rawArgs, ctx) {
296
304
  ...(screenOutput ? { screen: screenOutput } : {}),
297
305
  autoFix: {
298
306
  attempted: false,
307
+ ...(parsed.autoFix === false ? { disabled: true } : {}),
299
308
  },
300
309
  };
301
- return structuredExecutionError("Detected BASIC runtime errors after execution.", data);
310
+ return structuredExecutionError(parsed.autoFix === false
311
+ ? "Detected BASIC runtime errors after execution. Auto-fix is disabled (autoFix:false), so the program was not rewritten or run again."
312
+ : "Detected BASIC runtime errors after execution.", data);
302
313
  }
303
314
  ctx.logger.info("Attempting BASIC auto-fix", {
304
315
  changes: fixAttempt.changes.map((change) => ({
@@ -384,9 +395,14 @@ async function executeUploadRunBasic(rawArgs, ctx) {
384
395
  }));
385
396
  }
386
397
  }
398
+ const changedLines = autoFixInfo
399
+ ? autoFixInfo.changes.map((change) => `line ${change.line}: "${change.before}" -> "${change.after}"`).join("; ")
400
+ : "";
387
401
  const message = autoFixInfo
388
- ? "Detected BASIC errors on execution; applied auto-fix and re-ran successfully."
389
- : "BASIC program uploaded and executed successfully.";
402
+ ? `Detected BASIC errors on execution; applied auto-fix and ran the program a second time (changed lines: ${changedLines}). The second run showed no BASIC error on screen.${verified ? "" : " Execution was not verified."}`
403
+ : verified
404
+ ? "BASIC program uploaded and executed successfully (verified)."
405
+ : "BASIC program uploaded and started. No BASIC error was visible on the screen immediately afterwards; pass verify:true to poll for the outcome.";
390
406
  const metadata = {
391
407
  success: true,
392
408
  entryAddress,
@@ -399,6 +415,7 @@ async function executeUploadRunBasic(rawArgs, ctx) {
399
415
  applied: true,
400
416
  changes: autoFixInfo.changes,
401
417
  originalErrors: autoFixInfo.originalErrors,
418
+ programSource: activeProgram,
402
419
  },
403
420
  }
404
421
  : {}),
@@ -414,8 +431,10 @@ async function executeUploadRunBasic(rawArgs, ctx) {
414
431
  ...(autoFixInfo
415
432
  ? {
416
433
  autoFix: {
434
+ applied: true,
417
435
  changes: autoFixInfo.changes,
418
436
  originalErrors: autoFixInfo.originalErrors,
437
+ programSource: activeProgram,
419
438
  },
420
439
  }
421
440
  : {}),
@@ -452,18 +471,35 @@ async function executeUploadRunAsm(rawArgs, ctx) {
452
471
  details: extractFailureDetails(result.details),
453
472
  }));
454
473
  }
455
- // Poll for ASM execution outcome
474
+ // Read back the program bytes (load evidence), then poll for signs of execution.
456
475
  let verified = false;
476
+ let execution;
477
+ let loadCheck;
478
+ const loadAddress = prg.length >= 2 ? prg.readUInt16LE(0) : 0;
457
479
  try {
458
- const outcome = await pollForProgramOutcome("ASM", ctx.client, ctx.logger);
459
- if (outcome.status === "crashed") {
460
- ctx.logger.warn("Polling detected ASM program crash", { reason: outcome.reason });
461
- return toolErrorResult(new ToolExecutionError("Assembly program appears to have crashed (no screen changes detected)", {
462
- details: { reason: outcome.reason },
463
- }));
464
- }
465
- if (shouldVerify) {
466
- verified = true;
480
+ const outcome = await pollForProgramOutcome("ASM", ctx.client, ctx.logger, undefined, {
481
+ address: loadAddress,
482
+ bytes: prg.subarray(2),
483
+ });
484
+ if (outcome.type === "ASM") {
485
+ loadCheck = outcome.loadCheck;
486
+ if (outcome.status === "not_loaded") {
487
+ ctx.logger.warn("ASM program was not loaded", { reason: outcome.reason });
488
+ return toolErrorResult(new ToolExecutionError(`Assembly program was not loaded: ${outcome.reason}. The backend reported success, but the bytes read back at the load address differ from the assembled program (a program that overwrites its own first bytes also looks like this).`, { details: { reason: outcome.reason, ...(outcome.load ? { loadAddress: outcome.load.address, expected: outcome.load.expected, found: outcome.load.found } : {}) } }));
489
+ }
490
+ if (outcome.status === "unconfirmed") {
491
+ if (shouldVerify) {
492
+ ctx.logger.warn("ASM execution could not be verified", { reason: outcome.reason });
493
+ return toolErrorResult(new ToolExecutionError("Assembly program was loaded, but no screen change was observed, so its execution could not be verified", { details: { reason: outcome.reason, loadCheck: outcome.loadCheck, execution: "unconfirmed" } }));
494
+ }
495
+ execution = "unconfirmed";
496
+ }
497
+ else {
498
+ execution = "confirmed";
499
+ if (shouldVerify) {
500
+ verified = true;
501
+ }
502
+ }
467
503
  }
468
504
  }
469
505
  catch (pollError) {
@@ -480,13 +516,20 @@ async function executeUploadRunAsm(rawArgs, ctx) {
480
516
  entryAddress,
481
517
  prgSize: prg.length,
482
518
  resources: ["c64://assembly/6510-spec", "c64://guide/bootstrap"],
519
+ ...(loadCheck ? { loadCheck } : {}),
520
+ ...(execution ? { execution } : {}),
483
521
  ...(shouldVerify && verified ? { verified: true } : {}),
484
522
  };
485
- const base = textResult("Assembly program assembled, uploaded, and executed successfully.", {
523
+ const message = execution === "unconfirmed"
524
+ ? "Assembly program assembled, uploaded and loaded. No screen change was observed, so it is not confirmed that the program is running (a SID player, a border effect or a JMP * loop looks like this)."
525
+ : "Assembly program assembled, uploaded, and executed successfully.";
526
+ const base = textResult(message, {
486
527
  success: true,
487
528
  entryAddress,
488
529
  prgSize: prg.length,
489
530
  details: result.details ?? null,
531
+ ...(loadCheck ? { loadCheck } : {}),
532
+ ...(execution ? { execution } : {}),
490
533
  ...(shouldVerify && verified ? { verified: true } : {}),
491
534
  });
492
535
  return { ...base, structuredContent: { type: "json", data } };
@@ -550,8 +593,9 @@ async function executeRunPrg(rawArgs, ctx) {
550
593
  if (ctx.platform.id === "vice") {
551
594
  clearViceSymbols();
552
595
  if (parsed.symbolsFile) {
596
+ const symbolsPath = resolveHostPath(parsed.symbolsFile, { purpose: "read" });
553
597
  try {
554
- const content = await fs.promises.readFile(parsed.symbolsFile, "utf8");
598
+ const content = await fs.promises.readFile(symbolsPath, "utf8");
555
599
  const syms = parseViceSymbolFile(content);
556
600
  setViceSymbols(syms.entries());
557
601
  symbolsLoaded = syms.size;
@@ -718,7 +762,7 @@ export const programRunnersModule = defineToolModule({
718
762
  "Stage PRG files without running when the user wants to inspect memory first.",
719
763
  "Confirm the Ultimate filesystem path (e.g. //USB0/demo.prg) is accessible before invoking.",
720
764
  ],
721
- supportedPlatforms: ["c64u", "u2"],
765
+ supportedPlatforms: ["c64u", "u2", "vice"],
722
766
  async execute(args, ctx) {
723
767
  return executeLoadPrg(args, ctx);
724
768
  },
@@ -3,6 +3,7 @@ import { getPlatformStatus } from "../../platform.js";
3
3
  import { arraySchema, booleanSchema, objectSchema, optionalSchema, stringSchema, } from "../schema.js";
4
4
  import { jsonResult } from "../responses.js";
5
5
  import { unknownErrorResult } from "../errors.js";
6
+ export const MAX_BATCH_COMMANDS = 64;
6
7
  const commandSchema = objectSchema({
7
8
  description: "A single batch command.",
8
9
  properties: {
@@ -23,6 +24,7 @@ const executeBatchArgsSchema = objectSchema({
23
24
  commands: arraySchema(commandSchema, {
24
25
  description: "Ordered list of commands to execute.",
25
26
  minItems: 1,
27
+ maxItems: MAX_BATCH_COMMANDS,
26
28
  }),
27
29
  stopOnError: optionalSchema(booleanSchema({
28
30
  description: "Halt the batch after the first failure. Default: true.",
@@ -49,6 +51,9 @@ export const batchModuleGroup = defineToolModule({
49
51
  description: "Execute multiple c64bridge tool calls in a single request. Reduces latency for multi-step workflows.",
50
52
  summary: "Runs an ordered list of tool commands and returns all results with per-command timing.",
51
53
  inputSchema: executeBatchArgsSchema.jsonSchema,
54
+ // Annotated as the worst case; the access check runs per command when it is dispatched.
55
+ effect: "destructive",
56
+ effectOf: () => "read",
52
57
  tags: ["batch", "orchestration", "grouped"],
53
58
  examples: [
54
59
  {
@@ -80,6 +85,7 @@ export const batchModuleGroup = defineToolModule({
80
85
  // Lazy import to avoid circular dependency at module-init time.
81
86
  const { toolRegistry } = await import("./index.js");
82
87
  const results = [];
88
+ const imageBlocks = [];
83
89
  let succeeded = 0;
84
90
  let failed = 0;
85
91
  const batchStart = Date.now();
@@ -120,8 +126,11 @@ export const batchModuleGroup = defineToolModule({
120
126
  });
121
127
  const elapsedMs = Date.now() - cmdStart;
122
128
  const isError = toolResult.isError === true || toolResult.metadata?.success === false;
123
- const text = toolResult.content.map((c) => c.text).join("\n");
129
+ const text = toolResult.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
124
130
  const resultPayload = toolResult.structuredContent?.data ?? text;
131
+ // Image blocks (capture_frame format "png") have no text form, so they are passed through after the summary.
132
+ const images = toolResult.content.filter((c) => c.type === "image");
133
+ imageBlocks.push(...images);
125
134
  if (isError) {
126
135
  results.push({ index: i, tool: toolName, description: label, success: false, error: text, elapsedMs });
127
136
  failed++;
@@ -129,13 +138,14 @@ export const batchModuleGroup = defineToolModule({
129
138
  break;
130
139
  }
131
140
  else {
132
- results.push({ index: i, tool: toolName, description: label, success: true, result: resultPayload, elapsedMs });
141
+ results.push({ index: i, tool: toolName, description: label, success: true, result: resultPayload, elapsedMs, ...(images.length > 0 ? { images: images.length } : {}) });
133
142
  succeeded++;
134
143
  }
135
144
  }
136
145
  catch (err) {
137
146
  const elapsedMs = Date.now() - cmdStart;
138
- const message = err instanceof Error ? err.message : String(err);
147
+ // Keep the validation detail (allowed values, bounds) that the CallTool error path would show.
148
+ const message = unknownErrorResult(err).content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
139
149
  results.push({ index: i, tool: toolName, description: label, success: false, error: message, elapsedMs });
140
150
  failed++;
141
151
  if (stopOnError)
@@ -152,7 +162,9 @@ export const batchModuleGroup = defineToolModule({
152
162
  results,
153
163
  };
154
164
  ctx.logger.info("Batch complete", { total: parsed.commands.length, succeeded, failed, elapsedMs: totalElapsedMs });
155
- return jsonResult(summary, { success: failed === 0, succeeded, failed, total: parsed.commands.length });
165
+ const summaryResult = jsonResult(summary, { success: failed === 0, succeeded, failed, total: parsed.commands.length });
166
+ const outcome = imageBlocks.length > 0 ? { ...summaryResult, content: [...summaryResult.content, ...imageBlocks] } : summaryResult;
167
+ return failed > 0 ? { ...outcome, isError: true } : outcome;
156
168
  }
157
169
  catch (error) {
158
170
  return unknownErrorResult(error);
@@ -60,7 +60,7 @@ const configOperations = [
60
60
  },
61
61
  {
62
62
  op: "batch_update",
63
- schema: extendSchemaWithOp("batch_update", ensureDescriptor(developerDescriptorIndex, "config_batch_update").inputSchema, { description: "Apply multiple configuration updates in a single request." }),
63
+ schema: extendSchemaWithOp("batch_update", ensureDescriptor(developerDescriptorIndex, "config_batch_update").inputSchema, { description: "Apply multiple configuration updates in a single request. Pass `updates` as {category: {item: value}}; categories as top-level keys are still accepted at runtime." }),
64
64
  handler: async (rawArgs, ctx) => invokeModuleTool(developerModule, "config_batch_update", rawArgs, ctx),
65
65
  },
66
66
  {
@@ -169,6 +169,13 @@ export const configModuleGroup = defineToolModule({
169
169
  description: "Configuration operations available via the c64_config tool.",
170
170
  variants: configOperations.map((operation) => operation.schema),
171
171
  }),
172
+ operationEffects: {
173
+ list: "read", get: "read", set: "write", batch_update: "write", load_flash: "destructive",
174
+ save_flash: "destructive", reset_defaults: "destructive", read_debugreg: "read",
175
+ write_debugreg: "destructive", info: "read", version: "read", snapshot: "write",
176
+ restore: "write", diff: "read", shuffle: "write",
177
+ },
178
+ effectOf: (op, args) => (op === "restore" && args.applyToFlash === true ? "destructive" : undefined),
172
179
  tags: ["config", "diagnostics", "grouped"],
173
180
  operationPlatforms: {
174
181
  load_flash: ["c64u", "u2"],
@@ -200,7 +207,7 @@ export const configModuleGroup = defineToolModule({
200
207
  {
201
208
  name: "Snapshot config",
202
209
  description: "Write configuration snapshot to disk",
203
- arguments: { op: "snapshot", path: "./snapshots/c64_json" },
210
+ arguments: { op: "snapshot", path: "./snapshots/c64.json" },
204
211
  },
205
212
  ],
206
213
  execute: createOperationDispatcher("c64_config", configOperationHandlers),
@@ -251,6 +251,11 @@ const diskOperations = [
251
251
  schema: extendSchemaWithOp("find_and_run", ensureDescriptor(metaDescriptorIndex, "find_and_run_program_by_name").inputSchema, { description: "Search for a PRG/CRT by name substring and run the first match." }),
252
252
  handler: async (rawArgs, ctx) => invokeModuleTool(metaModule, "find_and_run_program_by_name", rawArgs, ctx),
253
253
  },
254
+ {
255
+ op: "find_paths",
256
+ schema: extendSchemaWithOp("find_paths", ensureDescriptor(metaDescriptorIndex, "find_paths_by_name").inputSchema, { description: "Search Ultimate storage for paths whose names contain a substring, without running anything." }),
257
+ handler: async (rawArgs, ctx) => invokeModuleTool(metaModule, "find_paths_by_name", rawArgs, ctx),
258
+ },
254
259
  ];
255
260
  const diskOperationHandlers = createOperationHandlers(diskOperations);
256
261
  export const diskModuleGroup = defineToolModule({
@@ -258,7 +263,7 @@ export const diskModuleGroup = defineToolModule({
258
263
  summary: "Grouped disk image management, mounting, and discovery tools.",
259
264
  supportedPlatforms: ["c64u", "u2", "vice"],
260
265
  resources: ["c64://guide/bootstrap"],
261
- prompts: ["drive-management"],
266
+ prompts: ["drive-manager"],
262
267
  defaultTags: ["storage", "drive"],
263
268
  workflowHints: [
264
269
  "Summarise drive state before and after mounts so the user can confirm hardware changes.",
@@ -273,14 +278,19 @@ export const diskModuleGroup = defineToolModule({
273
278
  description: "Disk operations available via the c64_disk tool.",
274
279
  variants: diskOperations.map((operation) => operation.schema),
275
280
  }),
281
+ operationEffects: {
282
+ list_drives: "read", mount: "write", unmount: "write", file_info: "read",
283
+ create_image: "destructive", find_and_run: "write", find_paths: "read",
284
+ },
276
285
  tags: ["storage", "drive", "grouped"],
277
286
  // The VICE Binary Monitor has no command to detach a disk image, so unmount is Ultimate-only.
278
- operationPlatforms: { unmount: ["c64u", "u2"], file_info: ["c64u", "u2"], create_image: ["c64u", "u2"], find_and_run: ["c64u", "u2"] },
287
+ operationPlatforms: { unmount: ["c64u", "u2"], file_info: ["c64u", "u2"], create_image: ["c64u", "u2"], find_and_run: ["c64u", "u2"], find_paths: ["c64u", "u2"] },
279
288
  operationToolNames: {
280
289
  unmount: "drive_remove",
281
290
  file_info: "file_info",
282
291
  create_image: "create_image",
283
292
  find_and_run: "find_and_run_program_by_name",
293
+ find_paths: "find_paths_by_name",
284
294
  },
285
295
  examples: [
286
296
  {
@@ -49,6 +49,9 @@ export const driveModuleGroup = defineToolModule({
49
49
  description: "Drive operations available via the c64_drive tool.",
50
50
  variants: driveOperations.map((operation) => operation.schema),
51
51
  }),
52
+ operationEffects: {
53
+ reset: "write", power_on: "write", power_off: "write", load_rom: "destructive", set_mode: "write",
54
+ },
52
55
  tags: ["drive", "hardware", "grouped"],
53
56
  operationPlatforms: { load_rom: ["c64u", "u2"] },
54
57
  operationToolNames: { load_rom: "drive_load_rom" },
@@ -49,6 +49,9 @@ export const extractModule = defineToolModule({
49
49
  description: "Extraction operations available via the c64_extract tool.",
50
50
  variants: extractOperations.map((operation) => operation.schema),
51
51
  }),
52
+ operationEffects: {
53
+ sprites: "write", charset: "write", memory_dump: "write", fs_stats: "read", firmware_health: "read",
54
+ },
52
55
  tags: ["extract", "diagnostics", "grouped"],
53
56
  operationPlatforms: { fs_stats: ["c64u", "u2"] },
54
57
  operationToolNames: { fs_stats: "filesystem_stats_by_extension" },
@@ -3,8 +3,10 @@ import { createOperationDispatcher, defineToolModule, discriminatedUnionSchema,
3
3
  import { graphicsModule, graphicsOperationHandlers as groupedGraphicsHandlers } from "../graphics.js";
4
4
  import { booleanSchema, literalSchema, numberSchema, objectSchema, optionalSchema, stringSchema, } from "../schema.js";
5
5
  import { buildDescriptorIndex, ensureDescriptor, extendSchemaWithOp, createOperationHandlers, } from "./utils.js";
6
+ import { Jimp } from "jimp";
6
7
  import { jsonResult } from "../responses.js";
7
- import { ToolError, toolErrorResult, unknownErrorResult, } from "../errors.js";
8
+ import { VIC_PALETTE } from "../../vicBitmap.js";
9
+ import { ToolError, ToolValidationError, toolErrorResult, unknownErrorResult, } from "../errors.js";
8
10
  const graphicsDescriptorIndex = buildDescriptorIndex(graphicsModule);
9
11
  const captureFrameArgsSchema = objectSchema({
10
12
  description: "Capture one or more complete video frames from the active backend.",
@@ -18,9 +20,13 @@ const captureFrameArgsSchema = objectSchema({
18
20
  default: 1,
19
21
  }), 1),
20
22
  includePixels: optionalSchema(booleanSchema({
21
- description: "Include pixel payload bytes in the response.",
22
- default: true,
23
- }), true),
23
+ description: "Include raw pixel payload bytes in the response. Off by default because a frame is about 100 KB; the response is rejected above 2 MiB, so lower count when it is on.",
24
+ default: false,
25
+ }), false),
26
+ format: optionalSchema(stringSchema({
27
+ description: "Set to png to receive each frame as an MCP image block (C64 palette). At most 4 frames per call.",
28
+ enum: ["png"],
29
+ })),
24
30
  encoding: optionalSchema(stringSchema({
25
31
  description: "Encoding used for the pixel payload when included.",
26
32
  enum: ["base64", "hex"],
@@ -30,6 +36,30 @@ const captureFrameArgsSchema = objectSchema({
30
36
  required: ["op"],
31
37
  additionalProperties: false,
32
38
  });
39
+ /** Largest raw pixel payload (encoded size, all frames) one capture_frame response may carry. */
40
+ export const MAX_CAPTURE_PAYLOAD_BYTES = 2 * 1024 * 1024;
41
+ export const MAX_PNG_FRAMES = 4;
42
+ /** Renders one captured frame (one palette index per byte) as a PNG image content block. */
43
+ export async function frameToPngContent(frame) {
44
+ const { width, height } = frame;
45
+ if (frame.bitsPerPixel > 8 || frame.pixels.length < width * height) {
46
+ throw new ToolValidationError("PNG output needs indexed frames with one byte per pixel; use includePixels for raw data", {
47
+ path: "$.format",
48
+ details: { bitsPerPixel: frame.bitsPerPixel, width, height, byteLength: frame.pixels.length },
49
+ });
50
+ }
51
+ const rgba = Buffer.alloc(width * height * 4);
52
+ for (let i = 0; i < width * height; i += 1) {
53
+ const [r, g, b] = VIC_PALETTE[frame.pixels[i] & 0x0f];
54
+ rgba[i * 4] = r;
55
+ rgba[i * 4 + 1] = g;
56
+ rgba[i * 4 + 2] = b;
57
+ rgba[i * 4 + 3] = 0xff;
58
+ }
59
+ const image = new Jimp({ width, height, data: rgba });
60
+ const png = await image.getBuffer("image/png");
61
+ return { type: "image", data: Buffer.from(png).toString("base64"), mimeType: "image/png" };
62
+ }
33
63
  // ---------------------------------------------------------------------------
34
64
  // get_display_state helpers — VIC-II register decoding
35
65
  // ---------------------------------------------------------------------------
@@ -107,10 +137,27 @@ const graphicsOperations = [
107
137
  count: parsed.count,
108
138
  includePixels: parsed.includePixels,
109
139
  encoding: parsed.encoding,
140
+ format: parsed.format,
110
141
  });
142
+ if (parsed.format === "png" && (parsed.count ?? 1) > MAX_PNG_FRAMES) {
143
+ throw new ToolValidationError(`count must be at most ${MAX_PNG_FRAMES} when format is png`, {
144
+ path: "$.count",
145
+ details: { count: parsed.count, maximum: MAX_PNG_FRAMES },
146
+ });
147
+ }
111
148
  const capture = await ctx.client.captureFrames({ count: parsed.count });
112
- const includePixels = parsed.includePixels !== false;
149
+ const includePixels = parsed.includePixels === true;
113
150
  const encoding = parsed.encoding ?? "base64";
151
+ if (includePixels) {
152
+ const rawBytes = capture.frames.reduce((sum, frame) => sum + frame.pixels.length, 0);
153
+ const encodedBytes = encoding === "hex" ? rawBytes * 2 : Math.ceil(rawBytes / 3) * 4;
154
+ if (encodedBytes > MAX_CAPTURE_PAYLOAD_BYTES) {
155
+ throw new ToolValidationError(`Raw pixel response would be ${encodedBytes} bytes, above the ${MAX_CAPTURE_PAYLOAD_BYTES} byte limit; lower count or use format png`, { path: "$.count", details: { count: parsed.count, encodedBytes, limitBytes: MAX_CAPTURE_PAYLOAD_BYTES } });
156
+ }
157
+ }
158
+ const images = parsed.format === "png"
159
+ ? await Promise.all(capture.frames.map((frame) => frameToPngContent(frame)))
160
+ : [];
114
161
  const frames = capture.frames.map((frame, index) => {
115
162
  const pixels = includePixels
116
163
  ? {
@@ -131,7 +178,7 @@ const graphicsOperations = [
131
178
  ...(pixels ? { pixels } : {}),
132
179
  };
133
180
  });
134
- return jsonResult({
181
+ const result = jsonResult({
135
182
  backend: capture.backend,
136
183
  count: frames.length,
137
184
  frames,
@@ -141,7 +188,9 @@ const graphicsOperations = [
141
188
  count: frames.length,
142
189
  includePixels,
143
190
  encoding: includePixels ? encoding : null,
191
+ format: parsed.format ?? null,
144
192
  });
193
+ return images.length > 0 ? { ...result, content: [...result.content, ...images] } : result;
145
194
  }
146
195
  catch (error) {
147
196
  if (error instanceof ToolError) {
@@ -223,6 +272,10 @@ export const graphicsModuleGroup = defineToolModule({
223
272
  description: "Graphics operations available via the c64_graphics tool.",
224
273
  variants: graphicsOperations.map((operation) => operation.schema),
225
274
  }),
275
+ operationEffects: {
276
+ capture_frame: "read", render_petscii_art: "write", render_petscii_text: "write",
277
+ render_sprite: "write", render_bitmap: "write", get_display_state: "read",
278
+ },
226
279
  tags: ["graphics", "vic", "grouped"],
227
280
  examples: [
228
281
  {
@@ -1,3 +1,5 @@
1
+ import { ToolValidationError } from "../errors.js";
2
+ import { assertAccess } from "../access.js";
1
3
  import { getPlatformStatus, setPlatform } from "../../platform.js";
2
4
  import { programModule } from "./program.js";
3
5
  import { memoryModuleGroup as memoryModule } from "./memory.js";
@@ -56,11 +58,25 @@ export const toolRegistry = {
56
58
  };
57
59
  const entry = toolMap.get(name);
58
60
  if (!entry) {
59
- throw new Error(`Unknown tool: ${name}`);
61
+ const allowed = Array.from(toolMap.keys()).sort();
62
+ throw new ToolValidationError(`Unknown tool: ${name}`, {
63
+ path: "$.name",
64
+ details: { allowed, received: name },
65
+ });
66
+ }
67
+ // Single enforcement point for C64BRIDGE_ACCESS. Batch commands and background task starts
68
+ // also arrive here (the batch tool and c64_system start_task declare their effect per call).
69
+ const effect = entry.module.effectOf ? entry.module.effectOf(name, args) : "destructive";
70
+ if (effect) {
71
+ assertAccess(name, getOperation(args), effect);
60
72
  }
61
73
  return entry.module.invoke(name, args, enrichedCtx);
62
74
  },
63
75
  };
76
+ function getOperation(args) {
77
+ const op = typeof args === "object" && args !== null ? args.op : undefined;
78
+ return typeof op === "string" && op.length > 0 ? op : undefined;
79
+ }
64
80
  export function describeToolModules() {
65
81
  return modules.map((module) => ({
66
82
  domain: module.domain,