@henols/c64-re-tools 1.0.0 → 2.0.0-rc.2

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 +190 -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 +121 -0
  39. package/src/host/server.ts +409 -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 +241 -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 +109 -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 +252 -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
@@ -0,0 +1,223 @@
1
+ // VICE remote text monitor client, used only for operations the binary monitor
2
+ // cannot do (warp, file load/attach, checkpoint ignore counts, device switch).
3
+ // Raw monitor syntax never leaves src/host/vice.
4
+ //
5
+ // Framing: every command ends with a prompt
6
+ // such as "(C:$e5cf) ", but a command that makes VICE enter the monitor gets
7
+ // an extra prompt first. So prompts cannot delimit answers. Each command is
8
+ // followed by a sentinel `~ $nnnn` whose distinctive four-line answer marks
9
+ // where the command's own output ends. The sentinel is sent only after a
10
+ // prompt shows the command was taken: VICE drops the rest of an input chunk
11
+ // when the command in it makes VICE enter the monitor. Only idempotent
12
+ // commands go through here (see resendAfterMs).
13
+ //
14
+ // VICE sometimes reads a line but runs it only when more input comes. So a
15
+ // late sentinel is sent again, and stray sentinel answers are removed from
16
+ // output. The second answer of a resent sentinel can arrive in parts, while
17
+ // the next command starts: that command waits until the answer is whole
18
+ // before it drops what is left over, so no part of it is read as output. VICE also prints its step message here when the binary monitor
19
+ // steps, sometimes after the next text command was sent; no command of this
20
+ // client steps, so that message is removed from output too.
21
+
22
+ import type { Socket } from "node:net";
23
+
24
+ import { connectWithTimeout } from "./connect.ts";
25
+
26
+ export class TextMonitorError extends Error {
27
+ override name = "TextMonitorError";
28
+ }
29
+
30
+ /** A command got no answer in time, but the connection is open; it may still run later. */
31
+ export class TextMonitorTimeoutError extends TextMonitorError {
32
+ override name = "TextMonitorTimeoutError";
33
+ }
34
+
35
+ const PROMPT = /\((?:C|\d+):\$[0-9a-f]{4}\) /g;
36
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
37
+ /** The default of TextMonitor.resendAfterMs. */
38
+ const RESEND_AFTER_MS = 3_000;
39
+ /** The default of TextMonitor.overdueTimeoutMs. */
40
+ const OVERDUE_COMMAND_TIMEOUT_MS = 1_000;
41
+ /** The default of TextMonitor.sentinelResendMs. */
42
+ const SENTINEL_RESEND_MS = 1_000;
43
+ /** The longest delay a Node timer takes. */
44
+ const MAX_TIMER_MS = 2_147_483_647;
45
+ const ANY_SENTINEL_ANSWER = /\+\d+\n\$[0-9a-f]{4}\n[0-7]+\n%[01 ]+\n/g;
46
+ const STEP_MESSAGE = /^Stepping through the next \d+ instruction\(s\)\.\n/gm;
47
+
48
+ function sentinelPattern(nonce: number): RegExp {
49
+ const hex = nonce.toString(16).padStart(4, "0");
50
+ return new RegExp(`\\+${nonce}\\n\\$${hex}\\n[0-7]+\\n%[01 ]+\\n\\((?:C|\\d+):\\$[0-9a-f]{4}\\) `);
51
+ }
52
+
53
+ /** Strips prompts, stray sentinel answers, step messages and surrounding blank space from a command's raw output. */
54
+ export function cleanOutput(raw: string): string {
55
+ return raw.replace(/\r/g, "").replace(ANY_SENTINEL_ANSWER, "").replace(STEP_MESSAGE, "").replace(PROMPT, "").trim();
56
+ }
57
+
58
+ export class TextMonitor {
59
+ readonly closed: Promise<Error | undefined>;
60
+ readonly #socket: Socket;
61
+ #buffer = "";
62
+ #wake: (() => void) | undefined;
63
+ #queue: Promise<unknown> = Promise.resolve();
64
+ #nonce = 0x1000 + Math.floor(Math.random() * 0x1000);
65
+ #closedByUs = false;
66
+ #failure: Error | undefined;
67
+ /** The time limit of a command that names none. */
68
+ defaultTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS;
69
+ /**
70
+ * How long to wait for any answer before sending the command once more.
71
+ * VICE sometimes holds a command line until more input comes; every
72
+ * command this client sends is idempotent, so a repeat is harmless. A
73
+ * command that has printed output but no prompt yet still runs, and is not
74
+ * sent again.
75
+ */
76
+ resendAfterMs = RESEND_AFTER_MS;
77
+ /** How long to wait for a sentinel's answer before sending the sentinel again. */
78
+ sentinelResendMs = SENTINEL_RESEND_MS;
79
+ /** While an earlier command timed out, later ones are waited for only this long (see BinaryMonitor). */
80
+ overdueTimeoutMs = OVERDUE_COMMAND_TIMEOUT_MS;
81
+ /** Set when a command timed out; cleared by the next command that is answered. */
82
+ #overdue = false;
83
+ /** The answers that a resent sentinel of the last command can still give. */
84
+ #strays: { nonce: number; count: number } | undefined;
85
+
86
+ private constructor(socket: Socket) {
87
+ this.#socket = socket;
88
+ socket.setNoDelay(true);
89
+ socket.on("data", (chunk) => {
90
+ this.#buffer += chunk.toString("latin1");
91
+ this.#wake?.();
92
+ });
93
+ socket.on("error", (error) => (this.#failure ??= new TextMonitorError(`text monitor connection failed: ${error.message}`)));
94
+ this.closed = new Promise((resolve) => {
95
+ socket.on("close", () => {
96
+ this.#wake?.();
97
+ resolve(this.#closedByUs ? undefined : (this.#failure ?? new TextMonitorError("VICE closed the text monitor connection")));
98
+ });
99
+ });
100
+ }
101
+
102
+ static connect(port: number, host = "127.0.0.1", timeoutMs = 2_000): Promise<TextMonitor> {
103
+ return connectWithTimeout({
104
+ port,
105
+ host,
106
+ timeoutMs,
107
+ wrap: (socket) => new TextMonitor(socket),
108
+ failure: (message) => new TextMonitorError(`text monitor ${message}`),
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Runs one idempotent command and returns its output without prompts.
114
+ * Commands run one at a time. A command makes VICE stop the machine if it
115
+ * was running. `timeoutMs` Infinity waits with no limit, also after a
116
+ * command that timed out.
117
+ */
118
+ command(line: string, timeoutMs = this.defaultTimeoutMs): Promise<string> {
119
+ if (/[\r\n]/.test(line)) return Promise.reject(new TypeError("a monitor command must be one line"));
120
+ const run = this.#queue.then(async () => {
121
+ try {
122
+ const limit = this.#overdue && timeoutMs !== Infinity ? Math.min(timeoutMs, this.overdueTimeoutMs) : timeoutMs;
123
+ const output = await this.#exchange(line, limit);
124
+ this.#overdue = false;
125
+ return output;
126
+ } catch (error) {
127
+ if (error instanceof TextMonitorTimeoutError) this.#overdue = true;
128
+ throw error;
129
+ }
130
+ });
131
+ this.#queue = run.catch(() => {});
132
+ return run;
133
+ }
134
+
135
+ close(): Promise<void> {
136
+ this.#closedByUs = true;
137
+ this.#socket.destroy();
138
+ return this.closed.then(() => {});
139
+ }
140
+
141
+ async #exchange(line: string, timeoutMs: number): Promise<string> {
142
+ if (this.#socket.destroyed) throw new TextMonitorError("text monitor connection is closed");
143
+ this.#nonce = this.#nonce >= 0xfffe ? 0x1000 : this.#nonce + 1;
144
+ const sentinel = sentinelPattern(this.#nonce);
145
+ const deadline = Date.now() + timeoutMs;
146
+ await this.#settleStrays(deadline, timeoutMs);
147
+ // Anything left over belongs to no command; drop it so it cannot be misread.
148
+ this.#buffer = "";
149
+ this.#socket.write(`${line}\n`);
150
+ const prompted = () => new RegExp(PROMPT.source).exec(this.#buffer);
151
+ const resendAt = Math.min(deadline, Date.now() + this.resendAfterMs);
152
+ try {
153
+ await this.#waitFor(prompted, resendAt, timeoutMs);
154
+ } catch (error) {
155
+ if (this.#socket.destroyed || Date.now() >= deadline) throw error;
156
+ // Output without a prompt: VICE runs the command and still prints. A repeat would run it twice.
157
+ if (cleanOutput(this.#buffer) === "") this.#socket.write(`${line}\n`);
158
+ await this.#waitFor(prompted, deadline, timeoutMs);
159
+ }
160
+ const sentinelLine = `~ $${this.#nonce.toString(16)}\n`;
161
+ let sends = 0;
162
+ for (;;) {
163
+ this.#socket.write(sentinelLine);
164
+ sends++;
165
+ let found: RegExpExecArray;
166
+ try {
167
+ found = await this.#waitFor(() => sentinel.exec(this.#buffer), Math.min(deadline, Date.now() + this.sentinelResendMs), timeoutMs);
168
+ } catch (error) {
169
+ if (this.#socket.destroyed || Date.now() >= deadline) throw error;
170
+ continue;
171
+ }
172
+ const output = this.#buffer.slice(0, found.index);
173
+ this.#buffer = this.#buffer.slice(found.index + found[0].length);
174
+ this.#strays = sends > 1 ? { nonce: this.#nonce, count: sends - 1 } : undefined;
175
+ return cleanOutput(output);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Waits a short time for the answers that a resent sentinel of the last
181
+ * command can still give, and until an answer that has begun is whole. An
182
+ * answer that VICE holds until more input comes arrives whole inside the
183
+ * next command's output, where cleanOutput removes it.
184
+ */
185
+ async #settleStrays(deadline: number, timeoutMs: number): Promise<void> {
186
+ const strays = this.#strays;
187
+ this.#strays = undefined;
188
+ if (strays === undefined) return;
189
+ const whole = new RegExp(sentinelPattern(strays.nonce).source, "g");
190
+ const begun = new RegExp(`\\+${strays.nonce}\\b|\\+\\d*$`);
191
+ const complete = () => ((this.#buffer.match(whole)?.length ?? 0) >= strays.count ? true : null);
192
+ const partial = () => begun.test(this.#buffer.replace(whole, ""));
193
+ try {
194
+ await this.#waitFor(complete, Math.min(deadline, Date.now() + this.sentinelResendMs), timeoutMs);
195
+ } catch (error) {
196
+ if (!(error instanceof TextMonitorTimeoutError)) throw error;
197
+ if (partial()) await this.#waitFor(() => (partial() ? null : true), deadline, timeoutMs);
198
+ }
199
+ }
200
+
201
+ /** Waits until `probe` finds something in the received text, and returns what it found. */
202
+ async #waitFor<T>(probe: () => T | null, deadline: number, timeoutMs: number): Promise<T> {
203
+ for (;;) {
204
+ const found = probe();
205
+ if (found !== null) return found;
206
+ if (this.#socket.destroyed) throw new TextMonitorError("text monitor connection closed during a command");
207
+ const remaining = deadline - Date.now();
208
+ if (remaining <= 0) {
209
+ // The raw tail goes to the host log only; it is never shown to the LLM.
210
+ throw new TextMonitorTimeoutError(`text monitor command got no answer in ${timeoutMs} ms (received: ${JSON.stringify(this.#buffer.slice(-200))})`);
211
+ }
212
+ await new Promise<void>((resolve) => {
213
+ // No timer for a wait with no limit; a timer longer than Node allows would fire at once.
214
+ const timer = remaining === Infinity ? undefined : setTimeout(resolve, Math.min(remaining, MAX_TIMER_MS));
215
+ this.#wake = () => {
216
+ clearTimeout(timer);
217
+ resolve();
218
+ };
219
+ });
220
+ this.#wake = undefined;
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,241 @@
1
+ // Deterministic connection to the Host Runtime and the version handshake.
2
+
3
+ import { connect as connectTcp, type Socket } from "node:net";
4
+
5
+ import { encodeFrame, MessageReader } from "../protocol/framing.ts";
6
+ import { elapsedMs, trace } from "../trace.ts";
7
+ import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure, type ErrorCode, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
8
+
9
+ export interface Endpoint {
10
+ host: string;
11
+ port: number;
12
+ }
13
+
14
+ /** How long one endpoint may take to accept a TCP connection. */
15
+ const CONNECT_TIMEOUT_MS = 1_500;
16
+ /**
17
+ * How long the host may take to answer hello. A VICE session starts its
18
+ * emulator first, and the host tries the start up to three times.
19
+ */
20
+ export const DEFAULT_READY_TIMEOUT_MS = 120_000;
21
+
22
+ /**
23
+ * The endpoints to try, in order: C64RT_HOST=host:port alone when set,
24
+ * else loopback, then the Docker and Podman host bridges.
25
+ */
26
+ export function hostEndpoints(env: NodeJS.ProcessEnv = process.env): Endpoint[] {
27
+ const override = env.C64RT_HOST;
28
+ if (override !== undefined && override !== "") {
29
+ const match = /^(?:\[([^\]]+)\]|([^:]+)):(\d{1,5})$/.exec(override);
30
+ const port = match === null ? NaN : Number(match[3]);
31
+ if (match === null || !(port > 0 && port <= 0xffff)) {
32
+ throw new WireFailure("installation-incomplete", "C64RT_HOST must have the form host:port, for example 127.0.0.1:6464.");
33
+ }
34
+ return [{ host: match[1] ?? match[2]!, port }];
35
+ }
36
+ return ["127.0.0.1", "host.docker.internal", "host.containers.internal"].map((host) => ({ host, port: DEFAULT_HOST_PORT }));
37
+ }
38
+
39
+ function unreachable(role: Role): WireFailure {
40
+ const code: ErrorCode = role === "vice-session" ? "machine-unavailable" : "operation-failed";
41
+ return new WireFailure(
42
+ code,
43
+ "The c64-re-tools host runtime is not running or cannot be reached. Start it on the host (npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host) and try again.",
44
+ );
45
+ }
46
+
47
+ function stopped(role: Role): WireFailure {
48
+ return new WireFailure(
49
+ role === "vice-session" ? "machine-unavailable" : "operation-failed",
50
+ "The connection to the c64-re-tools host runtime stopped before the host runtime was ready.",
51
+ );
52
+ }
53
+
54
+ /** Resolves undefined when the endpoint refuses, does not answer in time, or `signal` stops the attempt. */
55
+ function tcpConnect(endpoint: Endpoint, signal: AbortSignal | undefined): Promise<Socket | undefined> {
56
+ return new Promise((resolve) => {
57
+ const socket = connectTcp({ host: endpoint.host, port: endpoint.port });
58
+ const fail = () => finish(undefined);
59
+ const finish = (result: Socket | undefined) => {
60
+ clearTimeout(timer);
61
+ signal?.removeEventListener("abort", fail);
62
+ if (result === undefined) socket.destroy();
63
+ resolve(result);
64
+ };
65
+ const timer = setTimeout(fail, CONNECT_TIMEOUT_MS);
66
+ signal?.addEventListener("abort", fail, { once: true });
67
+ socket.once("connect", () => {
68
+ socket.removeAllListeners("error");
69
+ finish(socket);
70
+ });
71
+ socket.once("error", fail);
72
+ });
73
+ }
74
+
75
+ /** A handshaken, framed connection to the Host Runtime. */
76
+ export class HostConnection {
77
+ /** Settles when the connection is gone; carries the reason unless we closed it. */
78
+ readonly closed: Promise<Error | undefined>;
79
+ readonly #socket: Socket;
80
+ readonly #reader = new MessageReader();
81
+ #listener: ((message: HostMessage, attachments: Buffer[]) => void) | undefined;
82
+ #failure: Error | undefined;
83
+ #closedByUs = false;
84
+ #pingTimer: NodeJS.Timeout | undefined;
85
+ #silenceTimer: NodeJS.Timeout | undefined;
86
+ #heartbeat = false;
87
+
88
+ private constructor(socket: Socket) {
89
+ this.#socket = socket;
90
+ socket.setNoDelay(true);
91
+ socket.setKeepAlive(true, 10_000);
92
+ this.closed = new Promise((resolve) => {
93
+ socket.on("close", () => {
94
+ const reason = this.#closedByUs ? undefined : (this.#failure ?? new Error("the host runtime closed the connection"));
95
+ trace().event("host.closed", { byUs: this.#closedByUs, reason });
96
+ resolve(reason);
97
+ });
98
+ });
99
+ socket.on("error", (error) => (this.#failure ??= error));
100
+ socket.on("close", () => {
101
+ clearInterval(this.#pingTimer);
102
+ clearTimeout(this.#silenceTimer);
103
+ });
104
+ socket.on("data", (chunk) => {
105
+ this.#restartSilence();
106
+ try {
107
+ for (const { message, attachments } of this.#reader.push(chunk)) {
108
+ const parsed = parseHostMessage(message);
109
+ if (parsed.type !== "pong") this.#listener?.(parsed, attachments);
110
+ }
111
+ } catch (error) {
112
+ // A broken frame, or a listener that throws, ends the connection with a
113
+ // reason; nothing escapes the socket handler.
114
+ this.#failure =
115
+ error instanceof ProtocolError
116
+ ? error
117
+ : new Error(`could not handle a message from the host runtime: ${error instanceof Error ? error.message : String(error)}`);
118
+ socket.destroy();
119
+ }
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Connects to the first endpoint that accepts, then handshakes. Once an
125
+ * endpoint accepts, its answer is final: no later endpoint is tried.
126
+ */
127
+ static async open(options: {
128
+ role: Role;
129
+ videoStandard?: VideoStandard;
130
+ env?: NodeJS.ProcessEnv;
131
+ /** Stops the connect and the handshake at once; the open then fails and its socket is closed. */
132
+ signal?: AbortSignal;
133
+ }): Promise<HostConnection> {
134
+ const signal = options.signal;
135
+ const started = performance.now();
136
+ let socket: Socket | undefined;
137
+ for (const endpoint of hostEndpoints(options.env)) {
138
+ if (signal?.aborted) break;
139
+ const attempt = performance.now();
140
+ socket = await tcpConnect(endpoint, signal);
141
+ trace().event("host.connect", { ...endpoint, role: options.role, accepted: socket !== undefined, ms: elapsedMs(attempt) });
142
+ if (socket !== undefined) break;
143
+ }
144
+ if (signal?.aborted) {
145
+ socket?.destroy();
146
+ throw stopped(options.role);
147
+ }
148
+ if (socket === undefined) throw unreachable(options.role);
149
+
150
+ const connection = new HostConnection(socket);
151
+ const hello: Hello = { type: "hello", protocol: HOST_PROTOCOL_ID, version: HOST_PROTOCOL_VERSION, role: options.role };
152
+ if (options.videoStandard !== undefined) hello.videoStandard = options.videoStandard;
153
+ // A host that listens beyond loopback checks this shared secret.
154
+ const token = (options.env ?? process.env).C64RT_HOST_TOKEN;
155
+ if (token !== undefined && token !== "") hello.token = token;
156
+
157
+ const stop = () => void connection.close();
158
+ signal?.addEventListener("abort", stop, { once: true });
159
+ const answer = await new Promise<HostMessage | Error>((resolve) => {
160
+ const timer = setTimeout(() => resolve(new Error("no answer to hello in time")), DEFAULT_READY_TIMEOUT_MS);
161
+ connection.#listener = (message) => {
162
+ clearTimeout(timer);
163
+ resolve(message);
164
+ };
165
+ void connection.closed.then((reason) => {
166
+ clearTimeout(timer);
167
+ resolve(reason ?? new Error("closed"));
168
+ });
169
+ connection.#send(hello);
170
+ });
171
+ connection.#listener = undefined;
172
+ signal?.removeEventListener("abort", stop);
173
+ if (signal?.aborted) {
174
+ await connection.close();
175
+ throw stopped(options.role);
176
+ }
177
+
178
+ if (!(answer instanceof Error) && answer.type === "ready") {
179
+ trace().event("host.ready", { role: options.role, ms: elapsedMs(started) });
180
+ connection.#startHeartbeat();
181
+ return connection;
182
+ }
183
+ trace().event("host.handshake-failed", { role: options.role, ms: elapsedMs(started), ...(answer instanceof Error ? { error: answer } : answer) });
184
+ await connection.close();
185
+ if (answer instanceof Error) {
186
+ if (answer instanceof ProtocolError) {
187
+ throw new WireFailure(
188
+ "installation-incomplete",
189
+ "The c64-re-tools host runtime is from another release. Stop the host runtime and start it again with npx, then restart the agent session. npx then starts the newest release on both sides.",
190
+ );
191
+ }
192
+ throw new WireFailure(
193
+ options.role === "vice-session" ? "machine-unavailable" : "operation-failed",
194
+ "The c64-re-tools host runtime did not finish the handshake. Check that it is running and try again.",
195
+ );
196
+ }
197
+ if (answer.type === "error") throw new WireFailure(answer.error.code, answer.error.message);
198
+ throw new WireFailure("installation-incomplete", "The c64-re-tools host runtime answered out of turn. Install the same c64-re-tools release on both sides.");
199
+ }
200
+
201
+ /** Receives every message after the handshake, with any attachments. */
202
+ onMessage(listener: (message: HostMessage, attachments: Buffer[]) => void): void {
203
+ this.#listener = listener;
204
+ }
205
+
206
+ send(request: Request, attachments: readonly Uint8Array[] = []): void {
207
+ if (!this.#socket.destroyed) this.#socket.write(encodeFrame(request, attachments));
208
+ }
209
+
210
+ close(): Promise<void> {
211
+ this.#closedByUs = true;
212
+ this.#socket.destroy();
213
+ return this.closed.then(() => {});
214
+ }
215
+
216
+ /**
217
+ * Pings the host so it knows this client is alive, and closes the connection
218
+ * when the host has sent nothing for the timeout: a host that vanished
219
+ * without a close then fails like a closed one. The timers never keep
220
+ * the process alive.
221
+ */
222
+ #startHeartbeat(): void {
223
+ this.#heartbeat = true;
224
+ const ping: Ping = { type: "ping" };
225
+ this.#pingTimer = setInterval(() => this.#send(ping), HEARTBEAT_INTERVAL_MS).unref();
226
+ this.#restartSilence();
227
+ }
228
+
229
+ #restartSilence(): void {
230
+ if (!this.#heartbeat || this.#socket.destroyed) return;
231
+ clearTimeout(this.#silenceTimer);
232
+ this.#silenceTimer = setTimeout(() => {
233
+ this.#failure ??= new Error(`the host runtime sent nothing for ${HEARTBEAT_TIMEOUT_MS} ms`);
234
+ this.#socket.destroy();
235
+ }, HEARTBEAT_TIMEOUT_MS).unref();
236
+ }
237
+
238
+ #send(message: Hello | Request | Ping): void {
239
+ if (!this.#socket.destroyed) this.#socket.write(encodeFrame(message));
240
+ }
241
+ }
@@ -0,0 +1,89 @@
1
+ // Short-lived typed requests to the Host Runtime for skill scripts:
2
+ // VICE's own tools (c1541, petcat) and the host tool status. Each request
3
+ // opens its own "tool" connection, sends bytes, and closes. Skill scripts run
4
+ // ACME, DXA and Ghidra themselves through src/native.
5
+
6
+ import { ProtocolError, WireFailure } from "../protocol/messages.ts";
7
+ import { DISK_IMAGE_TYPES, validateToolResult, type C1541Result, type DiskAction, type DiskImageType, type PetcatResult, type ToolOperation, type ToolOperations, type ToolStatus } from "../protocol/tools.ts";
8
+ import { HostConnection } from "./connect.ts";
9
+ import { readProjectFile } from "./transfer.ts";
10
+
11
+ export interface ToolCallOptions {
12
+ env?: NodeJS.ProcessEnv;
13
+ }
14
+
15
+ /** Sends one tool request and returns its validated result and attachments. */
16
+ export async function callTool<O extends ToolOperation>(
17
+ op: O,
18
+ params: ToolOperations[O]["params"],
19
+ attachments: readonly Uint8Array[],
20
+ options: ToolCallOptions = {},
21
+ ): Promise<{ result: ToolOperations[O]["result"]; attachments: Buffer[] }> {
22
+ const connection = await HostConnection.open({ role: "tool", ...(options.env === undefined ? {} : { env: options.env }) });
23
+ try {
24
+ return await new Promise((resolve, reject) => {
25
+ connection.onMessage((message, received) => {
26
+ if (message.type !== "reply" || message.id !== 1) {
27
+ reject(new WireFailure("installation-incomplete", "The c64-re-tools host runtime answered out of turn. Install the same c64-re-tools release on both sides."));
28
+ return;
29
+ }
30
+ if ("error" in message) {
31
+ reject(new WireFailure(message.error.code, message.error.message));
32
+ return;
33
+ }
34
+ try {
35
+ resolve({ result: validateToolResult(op, message.result, received), attachments: received });
36
+ } catch (error) {
37
+ reject(
38
+ new WireFailure(
39
+ "operation-failed",
40
+ error instanceof ProtocolError ? "The c64-re-tools host runtime sent an invalid reply." : "The c64-re-tools host-client could not read the reply of the host runtime.",
41
+ ),
42
+ );
43
+ }
44
+ });
45
+ void connection.closed.then(() =>
46
+ reject(new WireFailure("operation-failed", "The connection to the c64-re-tools host runtime was lost during the request. Check that the Host Runtime (c64-re-tools-host) is running.")),
47
+ );
48
+ connection.send({ type: "request", id: 1, op, params }, attachments);
49
+ });
50
+ } finally {
51
+ await connection.close();
52
+ }
53
+ }
54
+
55
+ export interface InspectDiskRequest {
56
+ /** Project-relative disk image; its extension gives the image type. */
57
+ image: string;
58
+ action: DiskAction;
59
+ /** entry, chain and read: the file name as the directory shows it. */
60
+ name?: string;
61
+ }
62
+
63
+ /** c1541.inspect: a found read also returns the file's bytes. */
64
+ export async function inspectDisk(request: InspectDiskRequest, options: ToolCallOptions = {}): Promise<C1541Result & { data?: Buffer }> {
65
+ const file = readProjectFile(request.image);
66
+ if (!(DISK_IMAGE_TYPES as readonly string[]).includes(file.type)) {
67
+ throw new WireFailure("invalid-input", `${request.image} is not a disk image that this tool reads. Use a .d64, .d71, .d81 or .g64 file.`);
68
+ }
69
+ const params: ToolOperations["c1541.inspect"]["params"] = { action: request.action, imageType: file.type as DiskImageType };
70
+ if (request.name !== undefined) params.name = request.name;
71
+ const { result, attachments } = await callTool("c1541.inspect", params, [file.bytes], options);
72
+ return result.action === "read" && result.found ? { ...result, data: attachments[0]! } : result;
73
+ }
74
+
75
+ /** petcat.decode: the C64 BASIC V2 listing of a project program and its machine-code handoffs. */
76
+ export async function decodeBasic(request: { program: string }, options: ToolCallOptions = {}): Promise<PetcatResult> {
77
+ const file = readProjectFile(request.program);
78
+ return (await callTool("petcat.decode", {}, [file.bytes], options)).result;
79
+ }
80
+
81
+ /** host.status: each native tool on the host, found and run once. */
82
+ export async function hostTools(options: ToolCallOptions = {}): Promise<ToolStatus[]> {
83
+ return (await callTool("host.status", {}, [], options)).result.tools;
84
+ }
85
+
86
+ /** Skill scripts see failures and wire limits through the host-client, never the private protocol module. */
87
+ export { WireFailure } from "../protocol/messages.ts";
88
+ export { DISK_ACTIONS } from "../protocol/tools.ts";
89
+ export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES } from "../protocol/vice.ts";
@@ -0,0 +1,100 @@
1
+ // Reads project files on the client side for transfer to the Host Runtime.
2
+ // The host never sees a client path: only bytes and the file's type.
3
+
4
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
+ import { extname, join, relative, sep } from "node:path";
6
+
7
+ import { isInside, projectRoot, resolveProjectFile } from "../project.ts";
8
+ import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
9
+ import { WireFailure } from "../protocol/messages.ts";
10
+ import { MAX_TREE_FILES, type SourceTree } from "../protocol/tools.ts";
11
+
12
+ export interface ProjectFile {
13
+ bytes: Buffer;
14
+ /** Lowercase extension without the dot; empty when the file has none. */
15
+ type: string;
16
+ }
17
+
18
+ /**
19
+ * Reads one project file. Throws WireFailure: invalid-input for a path the
20
+ * project rules refuse, not-found when nothing is there, limit-exceeded when
21
+ * the file is too large to transfer.
22
+ */
23
+ export function readProjectFile(path: string, options: { root?: string } = {}): ProjectFile {
24
+ const root = options.root ?? projectRoot();
25
+ let real: string;
26
+ try {
27
+ real = resolveProjectFile(path, root);
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
30
+ throw new WireFailure("not-found", `There is no file at ${path} in the project directory.`);
31
+ }
32
+ if (error instanceof TypeError) {
33
+ throw new WireFailure("invalid-input", `${path}: give a path relative to the project directory that stays inside it.`);
34
+ }
35
+ throw error;
36
+ }
37
+ const stat = statSync(real);
38
+ if (!stat.isFile()) throw new WireFailure("invalid-input", `${path} is not a file.`);
39
+ if (stat.size > MAX_ATTACHMENT_BYTES) {
40
+ throw new WireFailure("limit-exceeded", `${path} has ${stat.size} bytes. The limit is ${MAX_ATTACHMENT_BYTES} bytes.`);
41
+ }
42
+ return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
43
+ }
44
+
45
+ /** Directories never staged: version control and the toolkit's own project state. */
46
+ const SKIPPED_DIRECTORIES = new Set([".git", ".hg", ".svn", ".c64-re-tools"]);
47
+
48
+ /**
49
+ * Reads a project directory as a source tree for staging: ordinary
50
+ * files with relative POSIX paths. A symbolic link is followed only when its
51
+ * target stays inside the source root; otherwise the tree is refused.
52
+ */
53
+ export function readProjectTree(path: string, options: { root?: string } = {}): { files: SourceTree; contents: Buffer[] } {
54
+ const projectRootPath = options.root ?? projectRoot();
55
+ let sourceRoot: string;
56
+ try {
57
+ sourceRoot = resolveProjectFile(path, projectRootPath);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new WireFailure("not-found", `There is no directory ${path} in the project directory.`);
60
+ if (error instanceof TypeError) throw new WireFailure("invalid-input", `${path}: give a directory relative to the project directory that stays inside it.`);
61
+ throw error;
62
+ }
63
+ if (!statSync(sourceRoot).isDirectory()) throw new WireFailure("invalid-input", `${path} is not a directory.`);
64
+
65
+ const files: SourceTree = [];
66
+ const contents: Buffer[] = [];
67
+ let total = 0;
68
+ // `realDirectory` is where `directory` really is; `ancestors` holds the real
69
+ // location of every directory on the walk down to it, so a link to one of
70
+ // them is a loop.
71
+ const walk = (directory: string, realDirectory: string, ancestors: Set<string>): void => {
72
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
73
+ const full = join(directory, entry.name);
74
+ let target = join(realDirectory, entry.name);
75
+ if (entry.isSymbolicLink()) {
76
+ target = realpathSync(full);
77
+ if (!isInside(sourceRoot, target)) {
78
+ throw new WireFailure("invalid-input", `${relative(projectRootPath, full)} links outside ${path}. Staging refuses a link that goes outside the source directory.`);
79
+ }
80
+ }
81
+ const stat = statSync(target);
82
+ if (stat.isDirectory()) {
83
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
84
+ if (ancestors.has(target)) {
85
+ throw new WireFailure("invalid-input", `${relative(projectRootPath, full)} points at a directory that contains it. Staging refuses a link loop.`);
86
+ }
87
+ walk(full, target, new Set(ancestors).add(target));
88
+ continue;
89
+ }
90
+ if (!stat.isFile()) continue;
91
+ if (files.length >= MAX_TREE_FILES) throw new WireFailure("limit-exceeded", `${path} has more than ${MAX_TREE_FILES} files.`);
92
+ total += stat.size;
93
+ if (total > MAX_ATTACHMENT_BYTES) throw new WireFailure("limit-exceeded", `${path} holds more than ${MAX_ATTACHMENT_BYTES} bytes.`);
94
+ files.push({ path: relative(sourceRoot, full).split(sep).join("/"), size: stat.size });
95
+ contents.push(readFileSync(target));
96
+ }
97
+ };
98
+ walk(sourceRoot, sourceRoot, new Set([sourceRoot]));
99
+ return { files, contents };
100
+ }