@henols/c64-re-tools 2.1.0-rc.1 → 2.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 (103) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +24 -9
  3. package/skills/c64-assembler/scripts/assembler.ts +95 -0
  4. package/skills/c64-basic/SKILL.md +17 -9
  5. package/skills/c64-basic/scripts/basic.ts +26 -36
  6. package/skills/c64-disk/SKILL.md +17 -7
  7. package/skills/c64-disk/scripts/disk.ts +130 -104
  8. package/skills/c64-disk/scripts/lib/containers.ts +10 -12
  9. package/skills/c64-docs/SKILL.md +47 -28
  10. package/skills/c64-docs/references/style.md +6 -6
  11. package/skills/c64-docs/scripts/docs.ts +323 -0
  12. package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
  13. package/skills/c64-emulator/SKILL.md +11 -7
  14. package/skills/c64-knowledge/SKILL.md +34 -21
  15. package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
  16. package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
  17. package/skills/c64-memory-map/SKILL.md +18 -10
  18. package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
  19. package/skills/c64-provenance/SKILL.md +33 -24
  20. package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
  21. package/skills/c64-provenance/scripts/lib/leftovers.ts +19 -20
  22. package/skills/c64-provenance/scripts/lib/library.ts +15 -25
  23. package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
  24. package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
  25. package/skills/c64-provenance/scripts/provenance.ts +177 -279
  26. package/skills/c64-reverse-engineering/SKILL.md +13 -12
  27. package/skills/c64-source/SKILL.md +28 -16
  28. package/skills/c64-source/scripts/lib/loader.ts +52 -77
  29. package/skills/c64-source/scripts/lib/source.ts +33 -42
  30. package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
  31. package/skills/c64-static-analysis/SKILL.md +24 -13
  32. package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
  33. package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
  34. package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
  35. package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
  36. package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
  37. package/skills/c64-testing/SKILL.md +48 -32
  38. package/skills/c64-testing/references/recording-format.md +48 -19
  39. package/skills/c64-testing/references/scenario-format.md +24 -19
  40. package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
  41. package/skills/c64-testing/scripts/lib/diff.ts +5 -3
  42. package/skills/c64-testing/scripts/lib/play.ts +244 -54
  43. package/skills/c64-testing/scripts/lib/recording.ts +153 -120
  44. package/skills/c64-testing/scripts/{replay.ts → lib/replay.ts} +28 -86
  45. package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
  46. package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
  47. package/skills/c64-testing/scripts/testing.ts +189 -0
  48. package/skills/c64-unpacker/SKILL.md +42 -25
  49. package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
  50. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
  51. package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
  52. package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
  53. package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
  54. package/src/c64.ts +35 -20
  55. package/src/cli/main.ts +2 -2
  56. package/src/errors.ts +38 -0
  57. package/src/graphics.ts +3 -1
  58. package/src/host/server.ts +3 -2
  59. package/src/host/vice/session.ts +4 -4
  60. package/src/host-client/entry-state.ts +106 -0
  61. package/src/host-client/image.ts +21 -10
  62. package/src/host-client/steps.ts +285 -108
  63. package/src/host-client/transfer.ts +21 -15
  64. package/src/host-client/vice-session.ts +1 -1
  65. package/src/knowledge/database.ts +3 -23
  66. package/src/knowledge/expression.ts +8 -11
  67. package/src/knowledge/history.ts +2 -2
  68. package/src/knowledge/import.ts +13 -14
  69. package/src/knowledge/names.ts +50 -0
  70. package/src/knowledge/program.ts +11 -10
  71. package/src/knowledge/read.ts +16 -21
  72. package/src/knowledge/schema.ts +9 -3
  73. package/src/knowledge/text.ts +5 -5
  74. package/src/knowledge/write.ts +93 -137
  75. package/src/mcp/inputs.ts +105 -0
  76. package/src/mcp/output.ts +2 -13
  77. package/src/mcp/server.ts +12 -60
  78. package/src/mcp/tools/debug.ts +17 -12
  79. package/src/mcp/tools/execution.ts +5 -32
  80. package/src/mcp/tools/input.ts +7 -32
  81. package/src/mcp/tools/media.ts +6 -5
  82. package/src/mcp/tools/memory.ts +11 -38
  83. package/src/mcp/tools/video.ts +6 -5
  84. package/src/native/c64run.ts +1 -1
  85. package/src/native/dxa.ts +7 -6
  86. package/src/native/ghidra/analyze.ts +2 -1
  87. package/src/native/ghidra/index.ts +2 -1
  88. package/src/native/processes.ts +4 -3
  89. package/src/native/status.ts +2 -2
  90. package/src/native/types.ts +3 -7
  91. package/src/opcodes.ts +13 -13
  92. package/src/platform.ts +9 -9
  93. package/src/project.ts +16 -26
  94. package/src/protocol/messages.ts +3 -21
  95. package/src/protocol/vice.ts +7 -7
  96. package/src/script.ts +491 -49
  97. package/skills/c64-assembler/scripts/assemble.ts +0 -138
  98. package/skills/c64-docs/scripts/generate.ts +0 -109
  99. package/skills/c64-docs/scripts/link.ts +0 -239
  100. package/skills/c64-memory-map/scripts/memmap.ts +0 -92
  101. package/skills/c64-testing/scripts/checklist.ts +0 -51
  102. package/skills/c64-testing/scripts/record.ts +0 -96
  103. package/skills/c64-testing/scripts/test.ts +0 -65
@@ -12,7 +12,7 @@ import type { SupervisedProcess } from "./processes.ts";
12
12
  import type { ToolContext } from "./run.ts";
13
13
 
14
14
  /** The command protocol that this module speaks; c64run --version and version name it. */
15
- export const C64RUN_PROTOCOL = 1;
15
+ export const C64RUN_PROTOCOL = 2;
16
16
 
17
17
  /** CPU cycles in one PAL frame: 312 lines of 63 cycles. */
18
18
  export const PAL_FRAME_CYCLES = 19_656;
package/src/native/dxa.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { writeFileSync } from "node:fs";
9
9
 
10
+ import { Refusal } from "../errors.ts";
10
11
  import { SYMBOL_NAME } from "../c64.ts";
11
12
  import { describeError, WireFailure } from "../protocol/messages.ts";
12
13
  import { checkDxaResult, checkRequestBounds, imageRange, seedsInside, type DxaParams, type DxaResult } from "./types.ts";
@@ -61,7 +62,7 @@ export function parseListing(listing: string, start: number, end: number): Pick<
61
62
  const label = /^([0-9a-f]{4})\s+(\.?[A-Za-z_]\w*):\s*$/.exec(line);
62
63
  if (label !== null) {
63
64
  const address = Number.parseInt(label[1]!, 16);
64
- if (address < start || address > end || !SYMBOL_NAME.test(label[2]!)) throw new Error(`label ${label[2]} at $${label[1]} is not usable`);
65
+ if (address < start || address > end || !SYMBOL_NAME.test(label[2]!)) throw new Refusal("operation-failed", `label ${label[2]} at $${label[1]} is not usable`);
65
66
  labels.push({ address, name: label[2]! });
66
67
  continue;
67
68
  }
@@ -72,17 +73,17 @@ export function parseListing(listing: string, start: number, end: number): Pick<
72
73
  }
73
74
  const first = statements[0];
74
75
  const last = statements.at(-1);
75
- if (first === undefined || last === undefined) throw new Error("the listing has no statements");
76
- if (first.address !== start) throw new Error(`the listing starts at $${hex(first.address)}, not at $${hex(start)}`);
77
- if (last.address > end) throw new Error("the listing runs past the end of the program");
76
+ if (first === undefined || last === undefined) throw new Refusal("operation-failed", "the listing has no statements");
77
+ if (first.address !== start) throw new Refusal("operation-failed", `the listing starts at $${hex(first.address)}, not at $${hex(start)}`);
78
+ if (last.address > end) throw new Refusal("operation-failed", "the listing runs past the end of the program");
78
79
  const regions: DxaResult["regions"] = [];
79
80
  statements.forEach((statement, index) => {
80
81
  const next = statements[index + 1]?.address ?? end + 1;
81
82
  const size = next - statement.address;
82
- if (size <= 0) throw new Error(`the listing goes back at $${hex(next)}`);
83
+ if (size <= 0) throw new Refusal("operation-failed", `the listing goes back at $${hex(next)}`);
83
84
  // Data lines count their items; an instruction line dumps exactly its bytes.
84
85
  const expected = statement.data ?? statement.dumped;
85
- if (expected !== size) throw new Error(`the line at $${hex(statement.address)} holds ${expected} bytes, but the next line is ${size} bytes later`);
86
+ if (expected !== size) throw new Refusal("operation-failed", `the line at $${hex(statement.address)} holds ${expected} bytes, but the next line is ${size} bytes later`);
86
87
  const classification = statement.data === undefined ? "code" : "data";
87
88
  const last = regions.at(-1);
88
89
  if (last?.classification === classification) last.end = next - 1;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
6
 
7
+ import { Refusal } from "../../errors.ts";
7
8
  import { describeError, isObject, WireFailure } from "../../protocol/messages.ts";
8
9
  import { checkGhidraResult, checkRequestBounds, GHIDRA_LIMITS, imageRange, seedsInside, type GhidraParams, type GhidraResult } from "../types.ts";
9
10
  import { outputTail, type ToolContext } from "../run.ts";
@@ -74,7 +75,7 @@ export async function analyze(
74
75
  // The version comes from the installation's application.properties, which findGhidra read.
75
76
  result = checkGhidraResult(isObject(exported) ? { ...exported, toolVersion: ghidraVersion(ghidra) } : exported);
76
77
  const [range] = result.coverage;
77
- if (result.coverage.length !== 1 || range === undefined || range.start !== start || range.end !== end) throw new Error("the coverage is not the loaded program");
78
+ if (result.coverage.length !== 1 || range === undefined || range.start !== start || range.end !== end) throw new Refusal("operation-failed", "the coverage is not the loaded program");
78
79
  } catch (error) {
79
80
  context.log?.(`Ghidra result rejected: ${describeError(error)}`);
80
81
  throw new WireFailure("operation-failed", `Ghidra returned an incomplete or inconsistent result. Nothing was imported. The check found: ${describeError(error)}.`);
@@ -9,6 +9,7 @@ import { userInfo } from "node:os";
9
9
  import { dirname, isAbsolute, join, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
+ import { Refusal } from "../../errors.ts";
12
13
  import { WireFailure } from "../../protocol/messages.ts";
13
14
  import { findOnPath, INSTALL_STEPS, isExecutableFile } from "../discover.ts";
14
15
  import type { ProcessSupervisor } from "../processes.ts";
@@ -42,7 +43,7 @@ function installationAt(root: string, source: string): GhidraInstallation {
42
43
  const analyzeHeadless = join(root, "support", `analyzeHeadless${LAUNCHER_SUFFIX}`);
43
44
  let properties: string;
44
45
  try {
45
- if (!isExecutableFile(analyzeHeadless)) throw new Error("no analyzeHeadless");
46
+ if (!isExecutableFile(analyzeHeadless)) throw new Refusal("operation-failed", "no analyzeHeadless");
46
47
  properties = readFileSync(join(root, "Ghidra", "application.properties"), "utf8");
47
48
  } catch {
48
49
  throw new WireFailure("installation-incomplete", `${source} does not point to a Ghidra installation. ${REMEDY}`);
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
+ import { Refusal } from "../errors.ts";
9
10
  import { WireFailure } from "../protocol/messages.ts";
10
11
 
11
12
  /** How long a process group gets between SIGTERM and SIGKILL. */
@@ -189,7 +190,7 @@ export class ProcessSupervisor {
189
190
  /** Starts argv[0] with argv[1..] as the leader of a new process group. */
190
191
  spawn(argv: readonly string[], options: SpawnOptions = {}): SupervisedProcess {
191
192
  const program = argv[0];
192
- if (program === undefined) throw new TypeError("argv must name a command");
193
+ if (program === undefined) throw new Refusal("operation-failed", "argv must name a command");
193
194
  const batch = process.platform === "win32" ? batchInvocation(argv, process.env.ComSpec) : undefined;
194
195
  const { command, args } = batch ?? { command: program, args: argv.slice(1) };
195
196
  let child: ChildProcess;
@@ -333,14 +334,14 @@ export class ProcessSupervisor {
333
334
  // The leader has ended already.
334
335
  }
335
336
  await Promise.race([exited, sleep(this.#graceMs)]);
336
- if (!hasExited(child)) throw new Error(`process ${pid} did not end${failure === undefined ? "" : `: ${failure}`}`);
337
+ if (!hasExited(child)) throw new Refusal("operation-failed", `process ${pid} did not end${failure === undefined ? "" : `: ${failure}`}`);
337
338
  return;
338
339
  }
339
340
  // The group outlives its leader when descendants remain, so wait on the group, not the leader.
340
341
  if (signalGroup(pid, "SIGTERM") && !(await waitForGroupGone(pid, this.#graceMs))) {
341
342
  signalGroup(pid, "SIGKILL");
342
343
  if (!(await waitForGroupGone(pid, KILL_WAIT_MS))) {
343
- throw new Error(`process group ${pid} survived SIGKILL`);
344
+ throw new Refusal("operation-failed", `process group ${pid} survived SIGKILL`);
344
345
  }
345
346
  }
346
347
  // A group of zombies counts as gone before Node reaps the leader and reports its exit;
@@ -6,7 +6,7 @@
6
6
  import { tmpdir } from "node:os";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
 
9
- import { WireFailure } from "../protocol/messages.ts";
9
+ import { isRefusal } from "../errors.ts";
10
10
  import { type ToolStatus } from "../protocol/tools.ts";
11
11
  import { ACME, C64RUN, DXA, findTool, INSTALL_STEPS } from "./discover.ts";
12
12
  import { findGhidra, ghidraVersion, NO_USABLE_JDK, noJdk } from "./ghidra/index.ts";
@@ -55,7 +55,7 @@ async function run(
55
55
  }
56
56
 
57
57
  function missing(name: string, error: unknown): ToolStatus {
58
- if (!(error instanceof WireFailure)) throw error;
58
+ if (!isRefusal(error)) throw error;
59
59
  return { name, found: false, runs: false, problem: error.message };
60
60
  }
61
61
 
@@ -3,6 +3,7 @@
3
3
  // these tools itself: nothing here crosses the Host Runtime wire. Bytes
4
4
  // (a program, an image, a listing) go next to these values as attachments.
5
5
 
6
+ import { Refusal } from "../errors.ts";
6
7
  import { prgRange, REFERENCE_KINDS, SYMBOL_NAME, type ReferenceKind } from "../c64.ts";
7
8
  import { isInteger, isObject, isOneOf, WireFailure } from "../protocol/messages.ts";
8
9
  import { type SourceTree } from "../protocol/tools.ts";
@@ -118,7 +119,7 @@ const isToolVersion = (value: unknown) => value === undefined || (typeof value =
118
119
 
119
120
  /** Ends a result check with the Error that names its first problem. */
120
121
  function fail(message: string): never {
121
- throw new Error(message);
122
+ throw new Refusal("operation-failed", message);
122
123
  }
123
124
 
124
125
  /** Narrows to a list whose items are still unchecked. */
@@ -237,12 +238,7 @@ export function imageRange(kind: ImageKind, image: Uint8Array): { start: number;
237
238
  if (image.length !== 0x10000) throw new WireFailure("invalid-input", "A flat 64 KiB image has exactly 65536 bytes.");
238
239
  return { start: 0, end: 0xffff, body: image };
239
240
  }
240
- try {
241
- return prgRange(image);
242
- } catch (error) {
243
- if (!(error instanceof RangeError)) throw error;
244
- throw new WireFailure("invalid-input", error.message);
245
- }
241
+ return prgRange(image);
246
242
  }
247
243
 
248
244
  /**
package/src/opcodes.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  // spelling, ACME writes only one of them: the generator finds those with a
5
5
  // probe and writes the others as bytes.
6
6
 
7
+ import { formatC64Byte, formatC64Address } from "./c64.ts";
8
+
7
9
  export type Mode = "imp" | "imm" | "zp" | "zpx" | "zpy" | "abs" | "abx" | "aby" | "ind" | "izx" | "izy" | "rel";
8
10
 
9
11
  export interface Opcode {
@@ -75,8 +77,6 @@ export function useKind(opcode: number): UseKind {
75
77
  return "read";
76
78
  }
77
79
 
78
- const hex2 = (value: number) => `$${value.toString(16).padStart(2, "0")}`;
79
- const hex4 = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
80
80
 
81
81
  /** The text of the instruction in `bytes` at `pc`, for example "sta $d011" or "bne $c002". */
82
82
  export function instructionText(bytes: readonly number[], pc: number): string {
@@ -85,17 +85,17 @@ export function instructionText(bytes: readonly number[], pc: number): string {
85
85
  const word = low | ((bytes[2] ?? 0) << 8);
86
86
  const operand: Record<Mode, string> = {
87
87
  imp: "",
88
- imm: `#${hex2(low)}`,
89
- zp: hex2(low),
90
- zpx: `${hex2(low)},x`,
91
- zpy: `${hex2(low)},y`,
92
- abs: hex4(word),
93
- abx: `${hex4(word)},x`,
94
- aby: `${hex4(word)},y`,
95
- ind: `(${hex4(word)})`,
96
- izx: `(${hex2(low)},x)`,
97
- izy: `(${hex2(low)}),y`,
98
- rel: hex4((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
88
+ imm: `#${formatC64Byte(low)}`,
89
+ zp: formatC64Byte(low),
90
+ zpx: `${formatC64Byte(low)},x`,
91
+ zpy: `${formatC64Byte(low)},y`,
92
+ abs: formatC64Address(word),
93
+ abx: `${formatC64Address(word)},x`,
94
+ aby: `${formatC64Address(word)},y`,
95
+ ind: `(${formatC64Address(word)})`,
96
+ izx: `(${formatC64Byte(low)},x)`,
97
+ izy: `(${formatC64Byte(low)}),y`,
98
+ rel: formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
99
99
  };
100
100
  return operand[mode] === "" ? mnemonic : `${mnemonic} ${operand[mode]}`;
101
101
  }
package/src/platform.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  // Names follow "Mapping the Commodore 64". Only platform facts live here:
4
4
  // an address without one belongs to the application.
5
5
 
6
- import { d018Offsets, vicBankBase } from "./c64.ts";
6
+ import { d018Offsets, formatC64Address, vicBankBase } from "./c64.ts";
7
+ import { Refusal } from "./errors.ts";
7
8
 
8
9
  export interface Entry {
9
10
  start: number;
@@ -321,7 +322,6 @@ const AREAS: Array<{ start: number; end: number; area: string; kind: Lookup["kin
321
322
  { start: 0xe000, end: 0xffff, area: "KERNAL ROM or RAM", kind: "platform", meaning: "KERNAL ROM when $01 bit 1 is set, else RAM." },
322
323
  ];
323
324
 
324
- const hex = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
325
325
 
326
326
  export interface Area {
327
327
  name: string;
@@ -353,7 +353,7 @@ export function lookup(address: number): Lookup {
353
353
  const base = block.start + ((address - block.start) % block.period);
354
354
  const register = block.registers.find((entry) => entry.start === base);
355
355
  const mirror = base !== address ? { mirrorOf: base } : {};
356
- if (register === undefined) return { address, kind: "platform", area: block.area, meaning: `Unused register (${hex(base)}): reads give no useful value.`, ...mirror };
356
+ if (register === undefined) return { address, kind: "platform", area: block.area, meaning: `Unused register (${formatC64Address(base)}): reads give no useful value.`, ...mirror };
357
357
  return { address, kind: "platform", area: block.area, name: register.name, meaning: register.meaning, ...mirror };
358
358
  }
359
359
  const entry = [...SYSTEM, ...ROM].find((candidate) => address >= candidate.start && address <= candidate.end);
@@ -455,13 +455,13 @@ export function decode(register: number, value: number, dd00?: number): Decoded
455
455
  return { xScroll: value & 0x07, columns: value & 0x08 ? 40 : 38, multicolorMode: (value & 0x10) !== 0 };
456
456
  case 0xd018: {
457
457
  const { screen, characters, bitmap } = d018Offsets(value);
458
- const result: Decoded = { screenOffset: hex(screen), characterOffset: hex(characters), bitmapOffset: hex(bitmap) };
458
+ const result: Decoded = { screenOffset: formatC64Address(screen), characterOffset: formatC64Address(characters), bitmapOffset: formatC64Address(bitmap) };
459
459
  if (dd00 !== undefined) {
460
460
  const bank = vicBank(dd00);
461
461
  result.bank = bank.bank;
462
- result.screen = hex(bank.start + screen);
463
- result.characters = hex(bank.start + characters);
464
- result.bitmap = hex(bank.start + bitmap);
462
+ result.screen = formatC64Address(bank.start + screen);
463
+ result.characters = formatC64Address(bank.start + characters);
464
+ result.bitmap = formatC64Address(bank.start + bitmap);
465
465
  if (bank.bank === 0 || bank.bank === 2) result.note = "In banks 0 and 2 the VIC-II sees the character ROM at offsets $1000-$1FFF.";
466
466
  } else {
467
467
  result.note = "Offsets are inside the VIC-II bank; give --bank with the $DD00 value for addresses.";
@@ -470,7 +470,7 @@ export function decode(register: number, value: number, dd00?: number): Decoded
470
470
  }
471
471
  case 0xdd00: {
472
472
  const bank = vicBank(value);
473
- return { vicBank: bank.bank, vicAddresses: `${hex(bank.start)}-${hex(bank.end)}` };
473
+ return { vicBank: bank.bank, vicAddresses: `${formatC64Address(bank.start)}-${formatC64Address(bank.end)}` };
474
474
  }
475
475
  case 0xd015:
476
476
  case 0xd017:
@@ -499,6 +499,6 @@ export function decode(register: number, value: number, dd00?: number): Decoded
499
499
  };
500
500
  }
501
501
  default:
502
- throw new RangeError(`no decoder for ${hex(register)}; decoders exist for $0001, $d010, $d011, $d015-$d01f (sprite and interrupt bits), $d016, $d018, $dc0d, $dd00 and $dd0d`);
502
+ throw new Refusal("invalid-input", `There is no decoder for ${formatC64Address(register)}. Decoders exist for $0001, $d010, $d011, $d015-$d01f (sprite and interrupt bits), $d016, $d018, $dc0d, $dd00 and $dd0d`);
503
503
  }
504
504
  }
package/src/project.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // project path is relative to that folder. .c64-re-tools/local/ holds what
5
5
  // the agent saves for itself and ignores itself in git.
6
6
 
7
+ import { Refusal } from "./errors.ts";
7
8
  import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -23,17 +24,6 @@ const SEARCH_DEPTH = 3;
23
24
  /** At most this many projects are named in a refusal. */
24
25
  const LISTED_PROJECTS = 20;
25
26
 
26
- /** A refusal of the project rules, with a stable code, shown to the LLM unchanged. */
27
- export class ProjectError extends Error {
28
- override name = "ProjectError";
29
- readonly code: "invalid-input" | "not-found";
30
-
31
- constructor(code: "invalid-input" | "not-found", message: string) {
32
- super(message);
33
- this.code = code;
34
- }
35
- }
36
-
37
27
  /** How a refusal tells the caller to name a project: "--project <folder>" for a script, "project" for an MCP tool. */
38
28
  export interface ProjectRemedy {
39
29
  remedy?: string;
@@ -95,7 +85,7 @@ function listed(projects: readonly string[]): string {
95
85
  * The project of the working directory `cwd` by the project rules: the nearest
96
86
  * folder at or above it with .c64-re-tools/, below the home folder. Without
97
87
  * one, `cwd` itself, unless projects are below it or it is the home folder:
98
- * then a ProjectError names them and the remedy, so no write lands outside a
88
+ * then a Refusal names them and the remedy, so no write lands outside a
99
89
  * project.
100
90
  */
101
91
  export function findProject(cwd: string = process.cwd(), options: ProjectRemedy = {}): string {
@@ -105,11 +95,11 @@ export function findProject(cwd: string = process.cwd(), options: ProjectRemedy
105
95
  if (isProject(folder)) return folder;
106
96
  }
107
97
  if (start === homedir()) {
108
- throw new ProjectError("invalid-input", `The home folder is not a project folder. Start in a project folder, or ${remedy.charAt(0).toLowerCase()}${remedy.slice(1)}.`);
98
+ throw new Refusal("invalid-input", `The home folder is not a project folder. Start in a project folder, or ${remedy.charAt(0).toLowerCase()}${remedy.slice(1)}.`);
109
99
  }
110
100
  const below = projectsBelow(start);
111
101
  if (below.length > 0) {
112
- throw new ProjectError("invalid-input", `${start} is not a project folder. These folders below it are projects: ${listed(below)}. ${remedy} with one of them.`);
102
+ throw new Refusal("invalid-input", `${start} is not a project folder. These folders below it are projects: ${listed(below)}. ${remedy} with one of them.`);
113
103
  }
114
104
  return start;
115
105
  }
@@ -121,15 +111,15 @@ export function findProject(cwd: string = process.cwd(), options: ProjectRemedy
121
111
  * home folder.
122
112
  */
123
113
  export function namedProject(folder: string, cwd: string = process.cwd()): string {
124
- if (folder.length === 0) throw new ProjectError("invalid-input", "The project folder must not be empty.");
114
+ if (folder.length === 0) throw new Refusal("invalid-input", "The project folder must not be empty.");
125
115
  const path = resolve(cwd, folder);
126
116
  const stat = statSync(path, { throwIfNoEntry: false });
127
- if (stat === undefined) throw new ProjectError("not-found", `There is no folder ${folder}.`);
128
- if (!stat.isDirectory()) throw new ProjectError("invalid-input", `${folder} is not a folder.`);
129
- if (path === homedir()) throw new ProjectError("invalid-input", "The home folder is not a project folder.");
117
+ if (stat === undefined) throw new Refusal("not-found", `There is no folder ${folder}.`);
118
+ if (!stat.isDirectory()) throw new Refusal("invalid-input", `${folder} is not a folder.`);
119
+ if (path === homedir()) throw new Refusal("invalid-input", "The home folder is not a project folder.");
130
120
  const outer = foldersUpFrom(path).slice(1).find(isProject);
131
121
  if (outer !== undefined) {
132
- throw new ProjectError("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || "."}. Give that project folder.`);
122
+ throw new Refusal("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || "."}. Give that project folder.`);
133
123
  }
134
124
  return path;
135
125
  }
@@ -159,7 +149,7 @@ export function resolveProject(folder: string | undefined, cwd: string = process
159
149
  return root;
160
150
  }
161
151
 
162
- /** Sets the project of this process from --project. Throws ProjectError for a folder that the rules refuse. */
152
+ /** Sets the project of this process from --project. Refuses a folder that the rules refuse. */
163
153
  export function useProject(folder: string): void {
164
154
  chosen = namedProject(folder);
165
155
  }
@@ -179,18 +169,18 @@ export function projectRoot(): string {
179
169
 
180
170
  export function resolveProjectPath(path: string, root = projectRoot()): string {
181
171
  if (path.length === 0) {
182
- throw new TypeError("Project path must not be empty");
172
+ throw new Refusal("invalid-input", "A project path must not be empty.");
183
173
  }
184
174
  if (isAbsolute(path)) {
185
- throw new TypeError("Project path must be relative");
175
+ throw new Refusal("invalid-input", `${path} is not relative: give a path relative to the project folder.`);
186
176
  }
187
177
 
188
178
  const candidate = resolve(root, path);
189
179
  if (isInside(root, candidate)) return candidate;
190
- throw new TypeError("Project path escapes the project root");
180
+ throw new Refusal("invalid-input", `${path} leads out of the project folder: give a path that stays inside it.`);
191
181
  }
192
182
 
193
- /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Throws TypeError as resolveProjectPath does. */
183
+ /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Refuses a path as resolveProjectPath does. */
194
184
  export function projectRelative(path: string, root = projectRoot()): string {
195
185
  return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
196
186
  }
@@ -204,13 +194,13 @@ export function isInside(root: string, candidate: string): boolean {
204
194
  /**
205
195
  * Resolves a project-relative path to an existing file system entry whose real
206
196
  * location is inside the project too, so a symbolic link cannot reach outside
207
- * it. Throws TypeError for a refused path; lets ENOENT through.
197
+ * it. Refuses a path that leads out; lets ENOENT through.
208
198
  */
209
199
  export function resolveProjectFile(path: string, root = projectRoot()): string {
210
200
  const candidate = resolveProjectPath(path, root);
211
201
  const real = realpathSync(candidate);
212
202
  if (!isInside(realpathSync(root), real)) {
213
- throw new TypeError("Project path escapes the project root through a symbolic link");
203
+ throw new Refusal("invalid-input", `${path} leads out of the project folder through a symbolic link.`);
214
204
  }
215
205
  return real;
216
206
  }
@@ -1,6 +1,7 @@
1
1
  // The message layer of the Host Runtime wire: protocol identity, error codes, the message shapes and
2
2
  // their parsers, and the field checks that every validator uses.
3
3
 
4
+ import { ERROR_CODES, Refusal, type ErrorCode } from "../errors.ts";
4
5
  import type { ToolOperation, ToolOperations } from "./tools.ts";
5
6
  import type { ViceOperation, ViceOperations } from "./vice.ts";
6
7
 
@@ -18,20 +19,6 @@ export const DEFAULT_HOST_PORT = 6464;
18
19
  // ---------------------------------------------------------------------------
19
20
  // Shared vocabulary
20
21
 
21
- export const ERROR_CODES = [
22
- "invalid-input",
23
- "machine-running",
24
- "machine-unavailable",
25
- "machine-state-lost",
26
- "not-found",
27
- "media-error",
28
- "limit-exceeded",
29
- "unsupported-in-space",
30
- "installation-incomplete",
31
- "operation-failed",
32
- ] as const;
33
- export type ErrorCode = (typeof ERROR_CODES)[number];
34
-
35
22
  /** An actionable failure carried on the wire and shown to the LLM unchanged. */
36
23
  export interface WireError {
37
24
  code: ErrorCode;
@@ -122,14 +109,9 @@ export function shownMessage(error: { code: string; message: string }): string {
122
109
  return error.code === "installation-incomplete" ? withStopAndAsk(error.message) : error.message;
123
110
  }
124
111
 
125
- export class WireFailure extends Error implements WireError {
112
+ /** A refusal that crosses the Host Runtime wire. */
113
+ export class WireFailure extends Refusal implements WireError {
126
114
  override name = "WireFailure";
127
- readonly code: ErrorCode;
128
-
129
- constructor(code: ErrorCode, message: string) {
130
- super(message);
131
- this.code = code;
132
- }
133
115
 
134
116
  toWire(): WireError {
135
117
  return { code: this.code, message: this.message };
@@ -549,8 +549,8 @@ export interface ViceOperations {
549
549
  };
550
550
  screenBaselines: { params: Record<string, never>; result: { baselines: string[] } };
551
551
  snapshot: {
552
- params: { action: "save" | "restore" | "discard"; name: string } | { action: "list" };
553
- result: { saved: boolean; name: string } | { restored: boolean; state: RunState } | { snapshots: string[] } | { discarded: boolean };
552
+ params: { action: "save" | "restore" | "remove"; name: string } | { action: "list" };
553
+ result: { saved: boolean; name: string } | { restored: boolean; state: RunState } | { snapshots: string[] } | { removed: boolean };
554
554
  };
555
555
  /**
556
556
  * Snapshot files: export sends the bytes of a named snapshot back as
@@ -560,7 +560,7 @@ export interface ViceOperations {
560
560
  params: { action: "export"; name: string } | { action: "restore" };
561
561
  result: { exported: boolean; bytes: number } | { restored: boolean; state: RunState };
562
562
  };
563
- screenDiscard: { params: { baseline: string }; result: { discarded: boolean } };
563
+ screenDiscard: { params: { baseline: string }; result: { removed: boolean } };
564
564
  warp: { params: { enabled: boolean }; result: { enabled: boolean } };
565
565
  window: { params: { action: (typeof WINDOW_ACTIONS)[number] }; result: WindowResult };
566
566
  }
@@ -633,8 +633,8 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
633
633
  case "snapshot": {
634
634
  onlyFields(params, params.action === "list" ? ["action"] : ["action", "name"]);
635
635
  if (params.action === "list") return { action: "list" } as ViceOperations[O]["params"];
636
- if (params.action !== "save" && params.action !== "restore" && params.action !== "discard") {
637
- invalid("action must be save, restore, list or discard");
636
+ if (params.action !== "save" && params.action !== "restore" && params.action !== "remove") {
637
+ invalid("action must be save, restore, list or remove");
638
638
  }
639
639
  return { action: params.action, name: validName(params.name, "name") } as ViceOperations[O]["params"];
640
640
  }
@@ -1231,7 +1231,7 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1231
1231
  break;
1232
1232
  }
1233
1233
  case "screenDiscard": {
1234
- if (value.discarded !== true) throw new ProtocolError("screenDiscard result is malformed");
1234
+ if (value.removed !== true) throw new ProtocolError("screenDiscard result is malformed");
1235
1235
  break;
1236
1236
  }
1237
1237
  case "snapshot": {
@@ -1239,7 +1239,7 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1239
1239
  (value.saved === true && typeof value.name === "string") ||
1240
1240
  (value.restored === true && isOneOf(RUN_STATES, value.state)) ||
1241
1241
  (Array.isArray(value.snapshots) && value.snapshots.every((name) => typeof name === "string")) ||
1242
- value.discarded === true;
1242
+ value.removed === true;
1243
1243
  if (!ok) throw new ProtocolError("snapshot result is malformed");
1244
1244
  break;
1245
1245
  }