@henols/c64-re-tools 1.1.0 → 2.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -37
  3. package/distribution/package.ts +36 -0
  4. package/distribution/plugin.ts +120 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +73 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +103 -0
  8. package/skills/c64-basic/SKILL.md +67 -0
  9. package/skills/c64-basic/scripts/basic.ts +46 -0
  10. package/skills/c64-disk/SKILL.md +79 -0
  11. package/skills/c64-disk/scripts/disk.ts +65 -0
  12. package/skills/c64-emulator/SKILL.md +124 -0
  13. package/skills/c64-knowledge/SKILL.md +102 -0
  14. package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
  15. package/skills/c64-memory-map/SKILL.md +76 -0
  16. package/skills/c64-memory-map/scripts/memmap.ts +81 -0
  17. package/skills/c64-memory-map/scripts/platform.ts +424 -0
  18. package/skills/c64-provenance/SKILL.md +76 -0
  19. package/skills/c64-provenance/scripts/compare.ts +92 -0
  20. package/skills/c64-provenance/scripts/provenance.ts +83 -0
  21. package/skills/c64-reverse-engineering/SKILL.md +90 -0
  22. package/skills/c64-static-analysis/SKILL.md +96 -0
  23. package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
  24. package/skills/c64-static-analysis/scripts/findings.ts +73 -0
  25. package/skills/c64-testing/SKILL.md +79 -0
  26. package/skills/c64-testing/references/scenario-format.md +76 -0
  27. package/skills/c64-testing/scripts/checklist.ts +51 -0
  28. package/skills/c64-testing/scripts/scenario.ts +473 -0
  29. package/skills/c64-testing/scripts/test.ts +55 -0
  30. package/skills/c64-unpacker/SKILL.md +82 -0
  31. package/skills/c64-unpacker/scripts/evidence.ts +135 -0
  32. package/skills/c64-unpacker/scripts/unpack.ts +216 -0
  33. package/src/c64.ts +281 -0
  34. package/src/cli/ap-sdk.ts +44 -0
  35. package/src/cli/cleanup.ts +194 -0
  36. package/src/cli/main.ts +122 -0
  37. package/src/cli/status.ts +34 -0
  38. package/src/host/main.ts +106 -0
  39. package/src/host/server.ts +376 -0
  40. package/src/host/tools/c1541.ts +255 -0
  41. package/src/host/tools/index.ts +31 -0
  42. package/src/host/tools/petcat.ts +70 -0
  43. package/src/host/tools/status.ts +30 -0
  44. package/src/host/tools/version.ts +41 -0
  45. package/src/host/vice/adapter.ts +761 -0
  46. package/src/host/vice/binary-monitor.ts +389 -0
  47. package/src/host/vice/chips.ts +108 -0
  48. package/src/host/vice/connect.ts +34 -0
  49. package/src/host/vice/process.ts +402 -0
  50. package/src/host/vice/screen.ts +183 -0
  51. package/src/host/vice/session.ts +1316 -0
  52. package/src/host/vice/text-monitor.ts +223 -0
  53. package/src/host-client/connect.ts +238 -0
  54. package/src/host-client/tools.ts +89 -0
  55. package/src/host-client/transfer.ts +100 -0
  56. package/src/host-client/vice-session.ts +254 -0
  57. package/src/knowledge/database.ts +132 -0
  58. package/src/knowledge/history.ts +128 -0
  59. package/src/knowledge/import.ts +344 -0
  60. package/src/knowledge/read.ts +191 -0
  61. package/src/knowledge/schema.ts +140 -0
  62. package/src/knowledge/write.ts +348 -0
  63. package/src/mcp/main.ts +106 -0
  64. package/src/mcp/server.ts +307 -0
  65. package/src/mcp/tools/debug.ts +237 -0
  66. package/src/mcp/tools/execution.ts +116 -0
  67. package/src/mcp/tools/input.ts +69 -0
  68. package/src/mcp/tools/machine.ts +97 -0
  69. package/src/mcp/tools/media.ts +106 -0
  70. package/src/mcp/tools/memory.ts +203 -0
  71. package/src/mcp/tools/video.ts +260 -0
  72. package/src/native/acme.ts +116 -0
  73. package/src/native/discover.ts +141 -0
  74. package/src/native/dxa.ts +166 -0
  75. package/src/native/ghidra/analyze.ts +86 -0
  76. package/src/native/ghidra/index.ts +157 -0
  77. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  78. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  79. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  80. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  81. package/src/native/ghidra/scripts/C64Export.java +242 -0
  82. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  83. package/src/native/local.ts +34 -0
  84. package/src/native/processes.ts +350 -0
  85. package/src/native/run.ts +172 -0
  86. package/src/native/staging.ts +83 -0
  87. package/src/native/status.ts +101 -0
  88. package/src/native/types.ts +228 -0
  89. package/src/native/watchdog.ts +78 -0
  90. package/src/project.ts +39 -0
  91. package/src/protocol/framing.ts +132 -0
  92. package/src/protocol/messages.ts +243 -0
  93. package/src/protocol/tools.ts +247 -0
  94. package/src/protocol/vice.ts +1235 -0
  95. package/src/script.ts +59 -0
  96. package/src/time.ts +15 -0
  97. package/src/trace.ts +162 -0
  98. package/THIRD-PARTY-NOTICES.md +0 -26
  99. package/bin/cli.mjs +0 -283
  100. package/skills/acme-build/SKILL.md +0 -260
  101. package/skills/acme-build/scripts/acme.mjs +0 -341
  102. package/skills/acme-build/template.a +0 -53
  103. package/skills/c64-disk-access/SKILL.md +0 -159
  104. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  105. package/skills/c64-memory-mapping/SKILL.md +0 -631
  106. package/skills/c64-memory-mapping/memmap.json +0 -8800
  107. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  108. package/skills/c64-petcat/SKILL.md +0 -87
  109. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  110. package/skills/c64-program-recon/SKILL.md +0 -706
  111. package/skills/c64-program-recon/references/control-flow.md +0 -180
  112. package/skills/c64-program-recon/references/graphics.md +0 -79
  113. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  114. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  115. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  116. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  117. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  118. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  119. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  120. package/skills/c64-provenance-diff/SKILL.md +0 -292
  121. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  122. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  123. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  124. package/skills/c64-ram-capture/SKILL.md +0 -474
  125. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  126. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  127. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  128. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  129. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  130. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  131. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  132. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  133. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  134. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  135. package/skills/c64-ram-capture/transients/README.md +0 -137
  136. package/skills/routine-queue-walker/SKILL.md +0 -394
  137. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  138. package/skills/vice-wedge-triage/SKILL.md +0 -269
package/src/project.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+
4
+ export function projectRoot(): string {
5
+ return process.cwd();
6
+ }
7
+
8
+ export function resolveProjectPath(path: string, root = projectRoot()): string {
9
+ if (path.length === 0) {
10
+ throw new TypeError("Project path must not be empty");
11
+ }
12
+ if (isAbsolute(path)) {
13
+ throw new TypeError("Project path must be relative");
14
+ }
15
+
16
+ const candidate = resolve(root, path);
17
+ if (isInside(root, candidate)) return candidate;
18
+ throw new TypeError("Project path escapes the project root");
19
+ }
20
+
21
+ /** True when `candidate` is `root` or below it. Both are absolute paths. */
22
+ export function isInside(root: string, candidate: string): boolean {
23
+ const fromRoot = relative(root, candidate);
24
+ return fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot));
25
+ }
26
+
27
+ /**
28
+ * Resolves a project-relative path to an existing file system entry whose real
29
+ * location is inside the project too, so a symbolic link cannot reach outside
30
+ * it. Throws TypeError for a refused path; lets ENOENT through.
31
+ */
32
+ export function resolveProjectFile(path: string, root = projectRoot()): string {
33
+ const candidate = resolveProjectPath(path, root);
34
+ const real = realpathSync(candidate);
35
+ if (!isInside(realpathSync(root), real)) {
36
+ throw new TypeError("Project path escapes the project root through a symbolic link");
37
+ }
38
+ return real;
39
+ }
@@ -0,0 +1,132 @@
1
+ // The frame codec of the Host Runtime wire: the frame limits, encodeFrame and MessageReader.
2
+
3
+ import { type ClientMessage, type HostMessage, isInteger, isObject, ProtocolError } from "./messages.ts";
4
+
5
+ /** Largest frame body either side accepts. */
6
+ export const MAX_FRAME_BYTES = 1024 * 1024;
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Framing: 4-byte big-endian body length, then the body. A message body
10
+ // is UTF-8 JSON. A message may announce binary attachments with an
11
+ // `attachments` array of byte sizes; each attachment then follows as raw
12
+ // frames of at most MAX_FRAME_BYTES each, in order.
13
+
14
+ const HEADER_BYTES = 4;
15
+ /** Total attachment bytes one message may carry. */
16
+ export const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
17
+
18
+ function frame(body: Uint8Array): Buffer {
19
+ const header = Buffer.allocUnsafe(HEADER_BYTES);
20
+ header.writeUInt32BE(body.length, 0);
21
+ return Buffer.concat([header, body]);
22
+ }
23
+
24
+ /** Encodes one message and its attachments as one buffer, so writes never interleave. */
25
+ export function encodeFrame(message: ClientMessage | HostMessage, attachments: readonly Uint8Array[] = []): Buffer {
26
+ const total = attachments.reduce((sum, attachment) => sum + attachment.length, 0);
27
+ if (total > MAX_ATTACHMENT_BYTES) throw new ProtocolError(`attachments of ${total} bytes exceed ${MAX_ATTACHMENT_BYTES}`);
28
+ const announced = attachments.length === 0 ? message : { ...message, attachments: attachments.map((attachment) => attachment.length) };
29
+ const body = Buffer.from(JSON.stringify(announced), "utf8");
30
+ if (body.length > MAX_FRAME_BYTES) throw new ProtocolError(`frame of ${body.length} bytes exceeds ${MAX_FRAME_BYTES}`);
31
+ const frames = [frame(body)];
32
+ for (const attachment of attachments) {
33
+ for (let offset = 0; offset < attachment.length; offset += MAX_FRAME_BYTES) {
34
+ frames.push(frame(attachment.subarray(offset, offset + MAX_FRAME_BYTES)));
35
+ }
36
+ }
37
+ return Buffer.concat(frames);
38
+ }
39
+
40
+ /** Splits a stream into frame bodies. After a ProtocolError the decoder is unusable. */
41
+ class RawFrameDecoder {
42
+ #pending: Buffer = Buffer.alloc(0);
43
+
44
+ push(chunk: Buffer): Buffer[] {
45
+ this.#pending = this.#pending.length === 0 ? chunk : Buffer.concat([this.#pending, chunk]);
46
+ const bodies: Buffer[] = [];
47
+ while (this.#pending.length >= HEADER_BYTES) {
48
+ const length = this.#pending.readUInt32BE(0);
49
+ if (length > MAX_FRAME_BYTES) throw new ProtocolError(`frame of ${length} bytes exceeds ${MAX_FRAME_BYTES}`);
50
+ if (this.#pending.length < HEADER_BYTES + length) break;
51
+ bodies.push(Buffer.from(this.#pending.subarray(HEADER_BYTES, HEADER_BYTES + length)));
52
+ this.#pending = this.#pending.subarray(HEADER_BYTES + length);
53
+ }
54
+ return bodies;
55
+ }
56
+ }
57
+
58
+ export interface ReceivedMessage {
59
+ /** The parsed JSON body, with any `attachments` announcement removed. */
60
+ message: unknown;
61
+ attachments: Buffer[];
62
+ }
63
+
64
+ /** Reassembles messages and their attachments from stream chunks. Throws ProtocolError. */
65
+ export class MessageReader {
66
+ readonly #frames = new RawFrameDecoder();
67
+ readonly #textDecoder = new TextDecoder("utf-8", { fatal: true });
68
+ #current: { message: unknown; sizes: number[]; done: Buffer[]; parts: Buffer[]; received: number } | undefined;
69
+
70
+ push(chunk: Buffer): ReceivedMessage[] {
71
+ const complete: ReceivedMessage[] = [];
72
+ for (const body of this.#frames.push(chunk)) {
73
+ if (this.#current === undefined) this.#start(body);
74
+ else this.#collect(body);
75
+ const finished = this.#finish();
76
+ if (finished !== undefined) complete.push(finished);
77
+ }
78
+ return complete;
79
+ }
80
+
81
+ #start(body: Buffer): void {
82
+ let text: string;
83
+ try {
84
+ text = this.#textDecoder.decode(body);
85
+ } catch {
86
+ throw new ProtocolError("frame body is not valid UTF-8");
87
+ }
88
+ let message: unknown;
89
+ try {
90
+ message = JSON.parse(text);
91
+ } catch {
92
+ throw new ProtocolError("frame body is not valid JSON");
93
+ }
94
+ let sizes: number[] = [];
95
+ if (isObject(message) && "attachments" in message) {
96
+ const announced = message.attachments;
97
+ if (!Array.isArray(announced) || !announced.every((size) => isInteger(size, 0, MAX_ATTACHMENT_BYTES))) {
98
+ throw new ProtocolError("attachments must be a list of byte sizes");
99
+ }
100
+ if (announced.reduce((sum: number, size: number) => sum + size, 0) > MAX_ATTACHMENT_BYTES) {
101
+ throw new ProtocolError(`attachments exceed ${MAX_ATTACHMENT_BYTES} bytes`);
102
+ }
103
+ sizes = announced as number[];
104
+ const { attachments: _announced, ...rest } = message;
105
+ message = rest;
106
+ }
107
+ this.#current = { message, sizes, done: [], parts: [], received: 0 };
108
+ }
109
+
110
+ #collect(body: Buffer): void {
111
+ const current = this.#current!;
112
+ const expected = current.sizes[current.done.length]!;
113
+ // An attachment of size 0 is complete without a frame, so the pending one needs bytes; an empty frame would only add an empty part.
114
+ if (body.length === 0) throw new ProtocolError("attachment frame is empty");
115
+ if (current.received + body.length > expected) throw new ProtocolError("attachment frame runs past its announced size");
116
+ current.parts.push(body);
117
+ current.received += body.length;
118
+ }
119
+
120
+ /** Completes attachments whose bytes are all here; returns the message once nothing is missing. */
121
+ #finish(): ReceivedMessage | undefined {
122
+ const current = this.#current!;
123
+ while (current.done.length < current.sizes.length && current.received === current.sizes[current.done.length]) {
124
+ current.done.push(Buffer.concat(current.parts));
125
+ current.parts = [];
126
+ current.received = 0;
127
+ }
128
+ if (current.done.length < current.sizes.length) return undefined;
129
+ this.#current = undefined;
130
+ return { message: current.message, attachments: current.done };
131
+ }
132
+ }
@@ -0,0 +1,243 @@
1
+ // The message layer of the Host Runtime wire: protocol identity, error codes, the message shapes and
2
+ // their parsers, and the field checks that every validator uses.
3
+
4
+ import type { ToolOperation, ToolOperations } from "./tools.ts";
5
+ import type { ViceOperation, ViceOperations } from "./vice.ts";
6
+
7
+ /** Private Host Runtime protocol identifier. */
8
+ export const HOST_PROTOCOL_ID = "c64-re-tools-host" as const;
9
+ /** Bumped on any incompatible change; a mismatch fails the handshake. */
10
+ export const HOST_PROTOCOL_VERSION = 2;
11
+ /** After ready, the client pings this often, on every connection. */
12
+ export const HEARTBEAT_INTERVAL_MS = 10_000;
13
+ /** Either side closes a connection that has sent nothing for this long. */
14
+ export const HEARTBEAT_TIMEOUT_MS = 30_000;
15
+ /** The Host Runtime listens on 127.0.0.1 at this port by default. */
16
+ export const DEFAULT_HOST_PORT = 6464;
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Shared vocabulary
20
+
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
+ /** An actionable failure carried on the wire and shown to the LLM unchanged. */
36
+ export interface WireError {
37
+ code: ErrorCode;
38
+ message: string;
39
+ }
40
+
41
+ export const ROLES = ["vice-session", "tool"] as const;
42
+ export type Role = (typeof ROLES)[number];
43
+
44
+ export const VIDEO_STANDARDS = ["pal", "ntsc"] as const;
45
+ export type VideoStandard = (typeof VIDEO_STANDARDS)[number];
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Messages
49
+
50
+ export interface Hello {
51
+ type: "hello";
52
+ protocol: string;
53
+ version: number;
54
+ role: Role;
55
+ /** vice-session only; fixed for the session. */
56
+ videoStandard?: VideoStandard;
57
+ }
58
+
59
+ export interface Ready {
60
+ type: "ready";
61
+ }
62
+
63
+ /** Sent instead of `ready` when the handshake fails; the host then closes. */
64
+ export interface HandshakeError {
65
+ type: "error";
66
+ error: WireError;
67
+ }
68
+
69
+ export interface Request<O extends ViceOperation | ToolOperation = ViceOperation | ToolOperation> {
70
+ type: "request";
71
+ id: number;
72
+ op: O;
73
+ params: O extends ViceOperation ? ViceOperations[O]["params"] : O extends ToolOperation ? ToolOperations[O]["params"] : never;
74
+ }
75
+
76
+ export type Reply =
77
+ | { type: "reply"; id: number; result: unknown }
78
+ | { type: "reply"; id: number; error: WireError };
79
+
80
+ /** Heartbeat: the client pings after ready, the host answers pong. Neither touches the session. */
81
+ export interface Ping {
82
+ type: "ping";
83
+ }
84
+ export interface Pong {
85
+ type: "pong";
86
+ }
87
+
88
+ export type ClientMessage = Hello | Request | Ping;
89
+
90
+ /** A request as the host receives it: the operation is not checked yet, so the host answers an unknown one with invalid-input. */
91
+ export interface ReceivedRequest {
92
+ type: "request";
93
+ id: number;
94
+ op: string;
95
+ params: Fields;
96
+ }
97
+ export type ReceivedClientMessage = Hello | ReceivedRequest | Ping;
98
+ export type HostMessage = Ready | HandshakeError | Reply | Pong;
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Validation. A ProtocolError means the peer broke the contract; the receiver
102
+ // closes the connection. Bad operation parameters are a WireError instead.
103
+
104
+ export class ProtocolError extends Error {
105
+ override name = "ProtocolError";
106
+ }
107
+
108
+ /** Thrown by parameter/result validators; carries the wire code to answer with. */
109
+ export class WireFailure extends Error implements WireError {
110
+ override name = "WireFailure";
111
+ readonly code: ErrorCode;
112
+
113
+ constructor(code: ErrorCode, message: string) {
114
+ super(message);
115
+ this.code = code;
116
+ }
117
+
118
+ toWire(): WireError {
119
+ return { code: this.code, message: this.message };
120
+ }
121
+ }
122
+
123
+ /** The message of a caught value, which may not be an Error. */
124
+ export function describeError(error: unknown): string {
125
+ return error instanceof Error ? error.message : String(error);
126
+ }
127
+
128
+ export type Fields = Record<string, unknown>;
129
+
130
+ export function isObject(value: unknown): value is Fields {
131
+ return typeof value === "object" && value !== null && !Array.isArray(value);
132
+ }
133
+
134
+ /** True when `value` is one of `values`; narrows a string to the union. */
135
+ export function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
136
+ return typeof value === "string" && (values as readonly string[]).includes(value);
137
+ }
138
+
139
+ export function isInteger(value: unknown, min: number, max: number): value is number {
140
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
141
+ }
142
+
143
+ function isRequestId(value: unknown): value is number {
144
+ return isInteger(value, 0, 0xffff_ffff);
145
+ }
146
+
147
+ function isWireError(value: unknown): value is WireError {
148
+ return isObject(value) && isOneOf(ERROR_CODES, value.code) && typeof value.message === "string";
149
+ }
150
+
151
+ /** Validates a message the host received. Throws ProtocolError on any contract break. */
152
+ export function parseClientMessage(value: unknown): ReceivedClientMessage {
153
+ if (!isObject(value)) throw new ProtocolError("message is not an object");
154
+ if (value.type === "hello") {
155
+ // protocol/version are checked by the handshake, which answers a mismatch
156
+ // with installation-incomplete; here they only need their JSON types.
157
+ if (typeof value.protocol !== "string" || typeof value.version !== "number") {
158
+ throw new ProtocolError("hello lacks protocol or version");
159
+ }
160
+ if (!isOneOf(ROLES, value.role)) throw new ProtocolError("hello has an unknown role");
161
+ if (value.videoStandard !== undefined && !isOneOf(VIDEO_STANDARDS, value.videoStandard)) {
162
+ throw new ProtocolError("hello has an unknown video standard");
163
+ }
164
+ const hello: Hello = { type: "hello", protocol: value.protocol, version: value.version, role: value.role };
165
+ if (value.videoStandard !== undefined) hello.videoStandard = value.videoStandard;
166
+ return hello;
167
+ }
168
+ if (value.type === "request") {
169
+ if (!isRequestId(value.id)) throw new ProtocolError("request id is not a 32-bit unsigned integer");
170
+ if (typeof value.op !== "string") throw new ProtocolError("request op is not a string");
171
+ if (!isObject(value.params)) throw new ProtocolError("request params is not an object");
172
+ return { type: "request", id: value.id, op: value.op, params: value.params };
173
+ }
174
+ if (value.type === "ping") return { type: "ping" };
175
+ throw new ProtocolError("unknown message type");
176
+ }
177
+
178
+ /** Validates a message the client received. Throws ProtocolError on any contract break. */
179
+ export function parseHostMessage(value: unknown): HostMessage {
180
+ if (!isObject(value)) throw new ProtocolError("message is not an object");
181
+ if (value.type === "ready") return { type: "ready" };
182
+ if (value.type === "pong") return { type: "pong" };
183
+ if (value.type === "error") {
184
+ if (!isWireError(value.error)) throw new ProtocolError("handshake error is malformed");
185
+ return { type: "error", error: { code: value.error.code, message: value.error.message } };
186
+ }
187
+ if (value.type === "reply") {
188
+ if (!isRequestId(value.id)) throw new ProtocolError("reply id is not a 32-bit unsigned integer");
189
+ if ("error" in value) {
190
+ if (!isWireError(value.error)) throw new ProtocolError("reply error is malformed");
191
+ return { type: "reply", id: value.id, error: { code: value.error.code, message: value.error.message } };
192
+ }
193
+ if (!("result" in value)) throw new ProtocolError("reply has neither result nor error");
194
+ return { type: "reply", id: value.id, result: value.result };
195
+ }
196
+ throw new ProtocolError("unknown message type");
197
+ }
198
+
199
+ /** Checks a hello against this build's protocol. Returns the failure to send, or undefined. */
200
+ /**
201
+ * Refuses a client of another protocol version, and names the older side and
202
+ * how to update it. A release that changes the wire increments the protocol
203
+ * version, so the first client of a new release refuses an older host.
204
+ */
205
+ export function checkHello(hello: Hello): WireError | undefined {
206
+ if (hello.protocol !== HOST_PROTOCOL_ID) {
207
+ return {
208
+ code: "installation-incomplete",
209
+ message:
210
+ "The c64-re-tools host runtime and this client come from different installations. " +
211
+ "Install the same c64-re-tools release for both and restart the host runtime.",
212
+ };
213
+ }
214
+ if (hello.version > HOST_PROTOCOL_VERSION) {
215
+ return {
216
+ code: "installation-incomplete",
217
+ message:
218
+ "The c64-re-tools host runtime is from an older release than this client. " +
219
+ "Stop the host runtime and start it again with the same npx command. npx then starts the newest release.",
220
+ };
221
+ }
222
+ if (hello.version < HOST_PROTOCOL_VERSION) {
223
+ return {
224
+ code: "installation-incomplete",
225
+ message:
226
+ "This client is from an older release of c64-re-tools than the host runtime. " +
227
+ "Restart the agent session, so that npx starts the newest MCP server. " +
228
+ "For the skill scripts, run c64-re-tools update in the project.",
229
+ };
230
+ }
231
+ return undefined;
232
+ }
233
+
234
+ export function invalid(message: string): never {
235
+ throw new WireFailure("invalid-input", message);
236
+ }
237
+
238
+ /** Refuses a field that is not in `allowed`, the same way for every operation. */
239
+ export function onlyFields(value: Fields, allowed: readonly string[], where?: string): void {
240
+ for (const key of Object.keys(value)) {
241
+ if (!allowed.includes(key)) invalid(where === undefined ? `unknown field: ${key}` : `unknown field in ${where}: ${key}`);
242
+ }
243
+ }
@@ -0,0 +1,247 @@
1
+ // The native-tool operations of the Host Runtime wire ("tool" connections): their shapes and validators.
2
+
3
+ import { textToPetsciiName, type BasicHandoff } from "../c64.ts";
4
+ import { type Fields, invalid, isInteger, isObject, isOneOf, onlyFields, ProtocolError } from "./messages.ts";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Native-tool operations. Short-lived requests on "tool" connections; they
8
+ // never touch an emulator or knowledge.db.
9
+
10
+ /** A transferred source tree: relative POSIX paths, one attachment per file in this order. */
11
+ export type SourceTree = Array<{ path: string; size: number }>;
12
+ export const MAX_TREE_FILES = 4096;
13
+
14
+ export const DISK_IMAGE_TYPES = ["d64", "d71", "d81", "g64"] as const;
15
+ export type DiskImageType = (typeof DISK_IMAGE_TYPES)[number];
16
+ export const DISK_ACTIONS = ["directory", "bam", "entry", "chain", "read"] as const;
17
+ export type DiskAction = (typeof DISK_ACTIONS)[number];
18
+ export const CBM_FILE_TYPES = ["del", "seq", "prg", "usr", "rel", "unknown"] as const;
19
+ export type CbmFileType = (typeof CBM_FILE_TYPES)[number];
20
+
21
+ /** c1541.inspect. The image bytes are the request's one attachment. */
22
+ export interface C1541Params {
23
+ action: DiskAction;
24
+ imageType: DiskImageType;
25
+ /** entry, chain and read only: the file name as petsciiNameToText shows it. */
26
+ name?: string;
27
+ }
28
+
29
+ export interface DiskFile {
30
+ name: string;
31
+ type: CbmFileType;
32
+ blocks: number;
33
+ closed: boolean;
34
+ locked: boolean;
35
+ }
36
+
37
+ export interface DiskSector {
38
+ track: number;
39
+ sector: number;
40
+ }
41
+
42
+ export type C1541Result =
43
+ | { action: "directory"; diskName: string; diskId: string; dosType: string; freeBlocks: number; entries: DiskFile[] }
44
+ | { action: "bam"; tracks: Array<{ track: number; freeSectors: number[]; usedSectors: number[] }> }
45
+ | { action: "entry"; found: false }
46
+ | { action: "entry"; found: true; entry: DiskFile & { startTrack: number; startSector: number } }
47
+ | { action: "chain"; found: false }
48
+ | { action: "chain"; found: true; sectors: DiskSector[] }
49
+ | { action: "read"; found: false }
50
+ /** The file's bytes are the reply's attachment. */
51
+ | { action: "read"; found: true; name: string; bytes: number };
52
+
53
+ /** petcat.decode takes no fields: the program bytes are the request's one attachment. */
54
+ export type PetcatParams = Record<string, never>;
55
+
56
+ /** A machine-code handoff: SYS with a constant address, or SYS/USR whose target is computed at run time. */
57
+ export type { BasicHandoff };
58
+
59
+ export type PetcatResult =
60
+ | { decoded: false; reason: string }
61
+ | {
62
+ decoded: true;
63
+ loadAddress: number;
64
+ /** The address after the BASIC end marker; any bytes from here on are no BASIC. */
65
+ basicEnd: number;
66
+ listing: string;
67
+ lines: Array<{ number: number; text: string }>;
68
+ handoffs: BasicHandoff[];
69
+ };
70
+
71
+ /** One native tool on the host, as host.status found and ran it. */
72
+ export interface ToolStatus {
73
+ name: string;
74
+ found: boolean;
75
+ /** Where it is, when found. */
76
+ path?: string;
77
+ /** Its version text, when it ran. */
78
+ version?: string;
79
+ runs: boolean;
80
+ /** What is wrong and what to do, when it is missing or does not run. */
81
+ problem?: string;
82
+ }
83
+
84
+ export interface ToolOperations {
85
+ "host.status": { params: Record<string, never>; result: { tools: ToolStatus[] } };
86
+ "c1541.inspect": { params: C1541Params; result: C1541Result };
87
+ "petcat.decode": { params: PetcatParams; result: PetcatResult };
88
+ }
89
+ export type ToolOperation = keyof ToolOperations;
90
+ export const TOOL_OPERATIONS = ["host.status", "c1541.inspect", "petcat.decode"] as const satisfies readonly ToolOperation[];
91
+
92
+ const RELATIVE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))[^\0\\]+$/;
93
+
94
+ /** A relative POSIX path with no empty, "." or ".." segment and no backslash. */
95
+ export function isRelativePath(value: unknown): value is string {
96
+ return typeof value === "string" && value.length <= 1024 && RELATIVE_PATH.test(value) && !value.includes("//") && !value.endsWith("/");
97
+ }
98
+
99
+ /** Validates a transferred source tree against its attachments. Throws WireFailure(invalid-input). */
100
+ export function validateSourceTree(value: unknown, attachments: readonly Uint8Array[]): SourceTree {
101
+ if (!Array.isArray(value) || value.length > MAX_TREE_FILES) invalid(`files must list at most ${MAX_TREE_FILES} files`);
102
+ if (value.length !== attachments.length) invalid("each listed file needs exactly one attachment");
103
+ const seen = new Set<string>();
104
+ return value.map((entry, index) => {
105
+ if (!isObject(entry) || !isRelativePath(entry.path)) invalid("each file needs a relative path inside the source root");
106
+ if (seen.has(entry.path)) invalid(`the file ${entry.path} is listed twice`);
107
+ seen.add(entry.path);
108
+ if (entry.size !== attachments[index]!.length) invalid(`the size of ${entry.path} does not match its bytes`);
109
+ return { path: entry.path, size: entry.size as number };
110
+ });
111
+ }
112
+
113
+ /** Refuses anything but exactly one attachment with at least one byte, named by what it must hold. */
114
+ function oneFile(attachments: readonly Uint8Array[], what: string): void {
115
+ if (attachments.length !== 1 || attachments[0]!.length === 0) invalid(`${what} must be the one attachment, and it must not be empty`);
116
+ }
117
+
118
+ /** Validates tool request parameters on the host. Throws WireFailure(invalid-input). */
119
+ export function validateToolParams<O extends ToolOperation>(op: O, params: unknown, attachments: readonly Uint8Array[]): ToolOperations[O]["params"] {
120
+ if (!isObject(params)) invalid("parameters must be an object");
121
+ switch (op) {
122
+ case "c1541.inspect": {
123
+ onlyFields(params, ["action", "imageType", "name"]);
124
+ if (!isOneOf(DISK_ACTIONS, params.action)) invalid(`action must be one of ${DISK_ACTIONS.join(", ")}`);
125
+ if (!isOneOf(DISK_IMAGE_TYPES, params.imageType)) invalid(`imageType must be one of ${DISK_IMAGE_TYPES.join(", ")}`);
126
+ oneFile(attachments, "the disk image");
127
+ const result: C1541Params = { action: params.action, imageType: params.imageType };
128
+ const named = params.action === "entry" || params.action === "chain" || params.action === "read";
129
+ if (!named) {
130
+ if (params.name !== undefined) invalid(`action ${params.action} takes no name`);
131
+ } else {
132
+ if (typeof params.name !== "string" || params.name.length === 0) invalid(`action ${params.action} needs a file name`);
133
+ let bytes: Uint8Array;
134
+ try {
135
+ bytes = textToPetsciiName(params.name);
136
+ } catch (error) {
137
+ return invalid(`name: ${(error as Error).message}`);
138
+ }
139
+ if (bytes.length > 16) invalid("a CBM file name has at most 16 characters");
140
+ result.name = params.name;
141
+ }
142
+ return result as ToolOperations[O]["params"];
143
+ }
144
+ case "petcat.decode": {
145
+ onlyFields(params, []);
146
+ oneFile(attachments, "the program");
147
+ return {} as ToolOperations[O]["params"];
148
+ }
149
+ case "host.status":
150
+ onlyFields(params, []);
151
+ if (attachments.length !== 0) invalid("host.status takes no attachment");
152
+ return {} as ToolOperations[O]["params"];
153
+ }
154
+ return invalid(`unknown tool operation: ${String(op)}`);
155
+ }
156
+
157
+ /** Validates a tool result on the client. Throws ProtocolError. */
158
+ export function validateToolResult<O extends ToolOperation>(op: O, value: unknown, attachments: readonly Uint8Array[]): ToolOperations[O]["result"] {
159
+ if (!isObject(value)) throw new ProtocolError(`${op} result is not an object`);
160
+ switch (op) {
161
+ case "c1541.inspect": {
162
+ if (!isC1541Result(value, attachments)) throw new ProtocolError("c1541.inspect result is malformed");
163
+ return value as unknown as ToolOperations[O]["result"];
164
+ }
165
+ case "petcat.decode": {
166
+ if (!isPetcatResult(value) || attachments.length !== 0) throw new ProtocolError("petcat.decode result is malformed");
167
+ return value as unknown as ToolOperations[O]["result"];
168
+ }
169
+ case "host.status": {
170
+ const tools = value.tools;
171
+ const isStatus = (tool: unknown) =>
172
+ isObject(tool) &&
173
+ typeof tool.name === "string" &&
174
+ typeof tool.found === "boolean" &&
175
+ typeof tool.runs === "boolean" &&
176
+ [tool.path, tool.version, tool.problem].every((field) => field === undefined || typeof field === "string");
177
+ if (!Array.isArray(tools) || !tools.every(isStatus) || attachments.length !== 0) throw new ProtocolError("host.status result is malformed");
178
+ return value as unknown as ToolOperations[O]["result"];
179
+ }
180
+ }
181
+ throw new ProtocolError(`unknown tool operation: ${String(op)}`);
182
+ }
183
+
184
+ const isTrackSector = (value: unknown): boolean => isObject(value) && isInteger(value.track, 0, 255) && isInteger(value.sector, 0, 255);
185
+ const isSectorList = (value: unknown): boolean => Array.isArray(value) && value.every((item) => isInteger(item, 0, 255));
186
+
187
+ function isDiskFile(value: unknown): value is DiskFile {
188
+ return (
189
+ isObject(value) &&
190
+ typeof value.name === "string" &&
191
+ isOneOf(CBM_FILE_TYPES, value.type) &&
192
+ isInteger(value.blocks, 0, 0xffff) &&
193
+ typeof value.closed === "boolean" &&
194
+ typeof value.locked === "boolean"
195
+ );
196
+ }
197
+
198
+ function isC1541Result(value: Fields, attachments: readonly Uint8Array[]): boolean {
199
+ const carries = value.action === "read" && value.found === true ? 1 : 0;
200
+ if (attachments.length !== carries) return false;
201
+ switch (value.action) {
202
+ case "directory":
203
+ return (
204
+ typeof value.diskName === "string" &&
205
+ typeof value.diskId === "string" &&
206
+ typeof value.dosType === "string" &&
207
+ isInteger(value.freeBlocks, 0, 0xffff) &&
208
+ Array.isArray(value.entries) &&
209
+ value.entries.every(isDiskFile)
210
+ );
211
+ case "bam":
212
+ return (
213
+ Array.isArray(value.tracks) &&
214
+ value.tracks.every((track) => isObject(track) && isInteger(track.track, 1, 255) && isSectorList(track.freeSectors) && isSectorList(track.usedSectors))
215
+ );
216
+ case "entry":
217
+ return (
218
+ value.found === false ||
219
+ (value.found === true && isObject(value.entry) && isDiskFile(value.entry) && isTrackSector({ track: value.entry.startTrack, sector: value.entry.startSector }))
220
+ );
221
+ case "chain":
222
+ return value.found === false || (value.found === true && Array.isArray(value.sectors) && value.sectors.every(isTrackSector));
223
+ case "read":
224
+ return value.found === false || (value.found === true && typeof value.name === "string" && value.bytes === attachments[0]!.length);
225
+ }
226
+ return false;
227
+ }
228
+
229
+ function isPetcatResult(value: Fields): boolean {
230
+ if (value.decoded === false) return typeof value.reason === "string";
231
+ const isLine = (line: unknown) => isObject(line) && isInteger(line.number, 0, 0xffff) && typeof line.text === "string";
232
+ const isHandoff = (handoff: unknown) =>
233
+ isObject(handoff) &&
234
+ isInteger(handoff.line, 0, 0xffff) &&
235
+ ((handoff.kind === "sys" && isInteger(handoff.address, 0, 0xffff) && handoff.computed === undefined) ||
236
+ ((handoff.kind === "sys" || handoff.kind === "usr") && handoff.computed === true && handoff.address === undefined));
237
+ return (
238
+ value.decoded === true &&
239
+ isInteger(value.loadAddress, 0, 0xffff) &&
240
+ isInteger(value.basicEnd, 0, 0x10000) &&
241
+ typeof value.listing === "string" &&
242
+ Array.isArray(value.lines) &&
243
+ value.lines.every(isLine) &&
244
+ Array.isArray(value.handoffs) &&
245
+ value.handoffs.every(isHandoff)
246
+ );
247
+ }