@henols/c64-re-tools 2.0.0-rc.2 → 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.
- package/README.md +12 -64
- package/package.json +1 -1
- package/src/host/main.ts +10 -25
- package/src/host/server.ts +2 -35
- package/src/host-client/connect.ts +0 -3
- package/src/mcp/main.ts +1 -4
- package/src/protocol/messages.ts +0 -9
package/README.md
CHANGED
|
@@ -17,73 +17,27 @@ run ACME, DXA and Ghidra themselves, and keep project knowledge local in
|
|
|
17
17
|
The design is in [`docs/redesign/`](docs/redesign/README.md), and
|
|
18
18
|
[`docs/plan.md`](docs/plan.md) tracks the implementation.
|
|
19
19
|
|
|
20
|
-
## Install
|
|
20
|
+
## Install
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
You need Node.js 24 or newer, and VICE 3.9 or later (`x64sc`, `c1541`, `petcat`) with its ROM files. ACME, DXA and Ghidra are needed only for the skills that use them.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
- On the machine with VICE: VICE 3.9 or later (`x64sc`, `c1541`, `petcat`) with its ROM files.
|
|
26
|
-
- Where the agent runs: ACME, DXA and Ghidra, for the skills that use them.
|
|
27
|
-
|
|
28
|
-
Everything runs through npx. Nothing is installed globally.
|
|
29
|
-
|
|
30
|
-
### Release or prerelease
|
|
31
|
-
|
|
32
|
-
| Channel | Version |
|
|
33
|
-
| --- | --- |
|
|
34
|
-
| `latest` | The current release. |
|
|
35
|
-
| `next` | The current prerelease, for example `2.0.0-rc.1`. |
|
|
36
|
-
|
|
37
|
-
Until 2.0.0 is released, `latest` is the old 1.1.0. Use `next` until then. In the commands below, replace `<channel>` with `latest` or `next`. Use the same channel on both machines.
|
|
38
|
-
|
|
39
|
-
### On one machine
|
|
40
|
-
|
|
41
|
-
Start the Host Runtime in its own terminal. Ctrl+C stops it and every emulator:
|
|
24
|
+
Start the Host Runtime and leave it running:
|
|
42
25
|
|
|
43
26
|
```
|
|
44
|
-
npx -y --package=@henols/c64-re-tools
|
|
27
|
+
npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host
|
|
45
28
|
```
|
|
46
29
|
|
|
47
|
-
Install into
|
|
30
|
+
Install into your project and start the agent:
|
|
48
31
|
|
|
49
32
|
```
|
|
50
33
|
cd my-c64-project
|
|
51
|
-
npx -y @henols/c64-re-tools
|
|
52
|
-
npx -y @henols/c64-re-tools@<channel> status
|
|
34
|
+
npx -y @henols/c64-re-tools@latest install --target claude
|
|
53
35
|
claude
|
|
54
36
|
```
|
|
55
37
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
On the host, start the Host Runtime on the container bridge with a shared secret:
|
|
38
|
+
`npx -y @henols/c64-re-tools@latest status` shows what works.
|
|
59
39
|
|
|
60
|
-
|
|
61
|
-
export C64RT_HOST_TOKEN=$(openssl rand -hex 16)
|
|
62
|
-
npx -y --package=@henols/c64-re-tools@<channel> c64-re-tools-host --listen 172.17.0.1
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
In the container, set the same `C64RT_HOST_TOKEN`, then:
|
|
66
|
-
|
|
67
|
-
```
|
|
68
|
-
npx -y @henols/c64-re-tools@<channel> install --target claude
|
|
69
|
-
npx -y @henols/c64-re-tools@<channel> status
|
|
70
|
-
claude
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
The container reaches the host as `host.docker.internal`. `status` must show the Host Runtime as reachable.
|
|
74
|
-
|
|
75
|
-
### Update and remove
|
|
76
|
-
|
|
77
|
-
```
|
|
78
|
-
npx -y @henols/c64-re-tools@<channel> update # the newest skills into the project
|
|
79
|
-
npx -y @henols/c64-re-tools@<channel> uninstall
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Restart the Host Runtime after a new release. `--global` installs into your home directory.
|
|
83
|
-
|
|
84
|
-
### Debugging
|
|
85
|
-
|
|
86
|
-
See [docs/debugging.md](docs/debugging.md) for the trace and the field test.
|
|
40
|
+
Until 2.0.0 is released, `@latest` is the old 1.1.0. Use the prerelease instead. For the prerelease, a dev container and debugging, see [docs/debug-install.md](docs/debug-install.md).
|
|
87
41
|
|
|
88
42
|
### Settings
|
|
89
43
|
|
|
@@ -94,9 +48,8 @@ See [docs/debugging.md](docs/debugging.md) for the trace and the field test.
|
|
|
94
48
|
| `C64RT_ACME`, `C64RT_DXA` | skills, CLI | Full path of the tool when it is not on `PATH`. |
|
|
95
49
|
| `C64RT_GHIDRA` | skills, CLI | The Ghidra installation directory, when `analyzeHeadless` is not on `PATH`. |
|
|
96
50
|
| `C64RT_HOST` | MCP, skills, CLI | `host:port` of the Host Runtime when it is not on 127.0.0.1, `host.docker.internal` or `host.containers.internal` at 6464. |
|
|
97
|
-
| `C64RT_HOST_TOKEN` | host, MCP, skills, CLI | The shared secret for a Host Runtime that listens beyond loopback. |
|
|
98
51
|
| `C64RT_VIDEO` | MCP | `pal` (default) or `ntsc`, fixed for the life of the MCP process. |
|
|
99
|
-
| `C64RT_TRACE` | host, MCP, skills, CLI | A directory for a trace of what the programs do, for a person who debugs a run. See [docs/
|
|
52
|
+
| `C64RT_TRACE` | host, MCP, skills, CLI | A directory for a trace of what the programs do, for a person who debugs a run. See [docs/debug-install.md](docs/debug-install.md). Nothing of the trace reaches the agent. |
|
|
100
53
|
|
|
101
54
|
Ghidra runs with its own settings directory for each request. The toolkit
|
|
102
55
|
brings its own NMOS 6510 language (all 256 opcodes); your Ghidra installation
|
|
@@ -104,6 +57,8 @@ and settings stay unchanged.
|
|
|
104
57
|
|
|
105
58
|
## Use it with an agent
|
|
106
59
|
|
|
60
|
+
## Use it with an agent
|
|
61
|
+
|
|
107
62
|
1. Install for your harness: `--target claude`, `codex`, `pi`, `opencode`, `gemini`, `copilot`, `cursor` or `windsurf`. Without `--target`, `install` installs for all of them.
|
|
108
63
|
2. Start the Host Runtime and leave it running.
|
|
109
64
|
3. Start the harness in the project, for example `claude` or `codex`, or open the project in Cursor or Windsurf. The harness starts the MCP server and finds the skills by itself.
|
|
@@ -190,14 +145,7 @@ C64RT_LIVE_VICE=/usr/bin/x64sc C64RT_GHIDRA=/opt/ghidra pnpm test
|
|
|
190
145
|
|
|
191
146
|
ACME, DXA, c1541 and petcat tests run when the tools are on `PATH`.
|
|
192
147
|
|
|
193
|
-
To release, push a tag
|
|
194
|
-
|
|
195
|
-
```
|
|
196
|
-
git tag v2.0.0-rc.2 && git push origin v2.0.0-rc.2 # a prerelease, to npm next
|
|
197
|
-
git tag v2.0.0 && git push origin v2.0.0 # a release, to npm latest
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
CI checks and tests the tagged commit, publishes it to npm and makes a GitHub Release. The repository keeps the version `0.0.0-development`.
|
|
148
|
+
To release, push a tag. See [docs/releasing.md](docs/releasing.md).
|
|
201
149
|
|
|
202
150
|
## Layout
|
|
203
151
|
|
package/package.json
CHANGED
package/src/host/main.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
|
|
|
5
5
|
import { DEFAULT_HOST_PORT } from "../protocol/messages.ts";
|
|
6
6
|
import { ProcessSupervisor } from "../native/processes.ts";
|
|
7
7
|
import { startTrace, trace } from "../trace.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { ListenError, parsePort, startHostServer, stopSignal } from "./server.ts";
|
|
9
9
|
import { createToolDispatcher } from "./tools/index.ts";
|
|
10
10
|
import { checkViceStarts, findVice } from "./vice/process.ts";
|
|
11
11
|
import { viceSessionFactory } from "./vice/session.ts";
|
|
@@ -13,22 +13,19 @@ import { viceSessionFactory } from "./vice/session.ts";
|
|
|
13
13
|
const HELP = `c64-re-tools-host
|
|
14
14
|
|
|
15
15
|
Usage:
|
|
16
|
-
npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host [--port <port>]
|
|
16
|
+
npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host [--port <port>]
|
|
17
17
|
c64-re-tools-host --help
|
|
18
18
|
|
|
19
19
|
Runs the c64-re-tools Host Runtime in the foreground. It first starts VICE
|
|
20
20
|
once (x64sc on PATH, or C64RT_VICE) and does not start if VICE cannot run.
|
|
21
|
-
It listens on
|
|
21
|
+
It listens on port ${DEFAULT_HOST_PORT} on every address, so a dev container
|
|
22
|
+
reaches it as host.docker.internal. It starts one VICE emulator for each
|
|
22
23
|
connected c64-re-tools-mcp process, and runs the tools that come with VICE
|
|
23
24
|
(c1541, petcat) for skill scripts. Stop it with Ctrl+C; that stops every
|
|
24
25
|
emulator and tool it started.
|
|
25
26
|
|
|
26
27
|
Options:
|
|
27
28
|
--port <port> Listen on this port instead (0 picks a free port).
|
|
28
|
-
--listen <address> Also listen on this address, on the same port, for
|
|
29
|
-
example the container bridge (172.17.0.1). An address
|
|
30
|
-
that is not loopback needs C64RT_HOST_TOKEN: set it to
|
|
31
|
-
the same secret for this program and for the clients.
|
|
32
29
|
--help Show this help.`;
|
|
33
30
|
|
|
34
31
|
function log(line: string): void {
|
|
@@ -37,9 +34,9 @@ function log(line: string): void {
|
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
async function main(): Promise<number> {
|
|
40
|
-
let values: { help?: boolean; port?: string
|
|
37
|
+
let values: { help?: boolean; port?: string };
|
|
41
38
|
try {
|
|
42
|
-
({ values } = parseArgs({ options: { help: { type: "boolean" }, port: { type: "string" }
|
|
39
|
+
({ values } = parseArgs({ options: { help: { type: "boolean" }, port: { type: "string" } }, strict: true }));
|
|
43
40
|
} catch (error) {
|
|
44
41
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}\n`);
|
|
45
42
|
return 2;
|
|
@@ -56,13 +53,6 @@ async function main(): Promise<number> {
|
|
|
56
53
|
return 2;
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
const extraHosts = values.listen ?? [];
|
|
60
|
-
const token = process.env.C64RT_HOST_TOKEN;
|
|
61
|
-
if (extraHosts.some((address) => !isLoopback(address)) && (token === undefined || token.length < 16)) {
|
|
62
|
-
process.stderr.write("--listen with an address that is not loopback needs C64RT_HOST_TOKEN with at least 16 characters, so that only your clients can connect.\n");
|
|
63
|
-
return 2;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
56
|
// Without an emulator the runtime is useless: refuse to start, by name and with the remedy.
|
|
67
57
|
try {
|
|
68
58
|
findVice(process.env);
|
|
@@ -90,9 +80,8 @@ async function main(): Promise<number> {
|
|
|
90
80
|
let server;
|
|
91
81
|
try {
|
|
92
82
|
server = await startHostServer({
|
|
83
|
+
host: "0.0.0.0",
|
|
93
84
|
port,
|
|
94
|
-
extraHosts,
|
|
95
|
-
...(token === undefined || token === "" ? {} : { token }),
|
|
96
85
|
log,
|
|
97
86
|
createViceSession: viceSessionFactory({ supervisor, log }),
|
|
98
87
|
tools: createToolDispatcher({ supervisor, log }),
|
|
@@ -102,15 +91,11 @@ async function main(): Promise<number> {
|
|
|
102
91
|
process.stderr.write(`Port ${error.port} on ${error.address} is in use. Is another c64-re-tools-host running?\n`);
|
|
103
92
|
return 1;
|
|
104
93
|
}
|
|
105
|
-
if (error instanceof ListenError && error.code === "EADDRNOTAVAIL") {
|
|
106
|
-
process.stderr.write(`The address ${error.address} in --listen does not belong to this machine.\n`);
|
|
107
|
-
return 1;
|
|
108
|
-
}
|
|
109
94
|
throw error;
|
|
110
95
|
}
|
|
111
|
-
const
|
|
112
|
-
process.stdout.write(`c64-re-tools-host listening on ${
|
|
113
|
-
trace().event("host.listening", { addresses });
|
|
96
|
+
const address = `${server.host}:${server.port}`;
|
|
97
|
+
process.stdout.write(`c64-re-tools-host listening on ${address}\n`);
|
|
98
|
+
trace().event("host.listening", { addresses: [address] });
|
|
114
99
|
|
|
115
100
|
trace().event("host.stop", { signal: await stopSignal(log) });
|
|
116
101
|
await server.close();
|
package/src/host/server.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
1
|
import { createServer, type Server, type Socket } from "node:net";
|
|
3
2
|
|
|
4
3
|
import { encodeFrame, MessageReader } from "../protocol/framing.ts";
|
|
@@ -37,16 +36,8 @@ export interface HostServerOptions {
|
|
|
37
36
|
createViceSession: ViceSessionFactory;
|
|
38
37
|
/** Native-tool requests on "tool" connections; without it they are refused. */
|
|
39
38
|
tools?: ToolDispatcher;
|
|
40
|
-
/** Defaults to 127.0.0.1.
|
|
39
|
+
/** Defaults to 127.0.0.1; 0.0.0.0 listens on every address, so a container reaches it too. */
|
|
41
40
|
host?: string;
|
|
42
|
-
/** Extra addresses to listen on, on the same port as `host`. */
|
|
43
|
-
extraHosts?: string[];
|
|
44
|
-
/**
|
|
45
|
-
* The shared secret. A connection that arrives on an address that is
|
|
46
|
-
* not loopback must send it in its hello; without a token such a
|
|
47
|
-
* connection is refused.
|
|
48
|
-
*/
|
|
49
|
-
token?: string;
|
|
50
41
|
/** 0 picks a free port. */
|
|
51
42
|
port: number;
|
|
52
43
|
/** How long a new connection may stay silent before it must send hello. */
|
|
@@ -67,10 +58,6 @@ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
|
67
58
|
/** How long a refused client may keep its side of the connection open after the host has ended its side. */
|
|
68
59
|
const REFUSED_CLOSE_MS = 1_000;
|
|
69
60
|
|
|
70
|
-
function digest(text: string): Buffer {
|
|
71
|
-
return createHash("sha256").update(text, "utf8").digest();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
61
|
/** A caught value for the log, with its stack when it has one. */
|
|
75
62
|
function describe(error: unknown): string {
|
|
76
63
|
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
@@ -95,18 +82,6 @@ class Connection {
|
|
|
95
82
|
#isTool = false;
|
|
96
83
|
readonly #reader = new MessageReader();
|
|
97
84
|
|
|
98
|
-
/** A connection on a loopback address needs no token; any other needs the host's token. */
|
|
99
|
-
#checkToken(token: string | undefined): WireError | undefined {
|
|
100
|
-
if (isLoopback(this.#socket.localAddress ?? "")) return undefined;
|
|
101
|
-
const expected = this.#options.token;
|
|
102
|
-
// Equal-length digests: the comparison takes the same time for any token, also one with multibyte characters.
|
|
103
|
-
const matches = expected !== undefined && token !== undefined && timingSafeEqual(digest(token), digest(expected));
|
|
104
|
-
if (matches) return undefined;
|
|
105
|
-
return {
|
|
106
|
-
code: "installation-incomplete",
|
|
107
|
-
message: "The c64-re-tools host runtime refused this client: a connection from outside the host needs C64RT_HOST_TOKEN with the same value on both sides.",
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
85
|
readonly #socket: Socket;
|
|
111
86
|
readonly #options: HostServerOptions;
|
|
112
87
|
readonly #log: (line: string) => void;
|
|
@@ -191,7 +166,7 @@ class Connection {
|
|
|
191
166
|
if (this.#state === "handshake") {
|
|
192
167
|
if (message.type !== "hello") throw new ProtocolError("first message is not hello");
|
|
193
168
|
clearTimeout(this.#handshakeTimer);
|
|
194
|
-
const mismatch = checkHello(message)
|
|
169
|
+
const mismatch = checkHello(message);
|
|
195
170
|
if (mismatch !== undefined) {
|
|
196
171
|
this.#log(`refusing a client: ${mismatch.message}`);
|
|
197
172
|
trace().event("connection.refused", { connection: this.#id, role: message.role, ...mismatch });
|
|
@@ -308,12 +283,6 @@ class Connection {
|
|
|
308
283
|
}
|
|
309
284
|
}
|
|
310
285
|
|
|
311
|
-
/** True for 127.0.0.0/8 and ::1, also in their IPv4-mapped IPv6 form. */
|
|
312
|
-
export function isLoopback(address: string): boolean {
|
|
313
|
-
const plain = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
314
|
-
return plain === "::1" || /^127\.\d+\.\d+\.\d+$/.test(plain);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
286
|
/** A port as the user gives it: decimal digits only, 0 to 65535. Anything else gives undefined. */
|
|
318
287
|
export function parsePort(text: string): number | undefined {
|
|
319
288
|
if (!/^\d{1,5}$/.test(text)) return undefined;
|
|
@@ -386,8 +355,6 @@ export async function startHostServer(options: HostServerOptions): Promise<HostS
|
|
|
386
355
|
let port: number;
|
|
387
356
|
try {
|
|
388
357
|
port = await listen(host, options.port);
|
|
389
|
-
// The extra addresses share the port, so one endpoint setting reaches every address.
|
|
390
|
-
for (const extra of options.extraHosts ?? []) await listen(extra, port);
|
|
391
358
|
} catch (error) {
|
|
392
359
|
await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
393
360
|
throw error;
|
|
@@ -150,9 +150,6 @@ export class HostConnection {
|
|
|
150
150
|
const connection = new HostConnection(socket);
|
|
151
151
|
const hello: Hello = { type: "hello", protocol: HOST_PROTOCOL_ID, version: HOST_PROTOCOL_VERSION, role: options.role };
|
|
152
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
153
|
|
|
157
154
|
const stop = () => void connection.close();
|
|
158
155
|
signal?.addEventListener("abort", stop, { once: true });
|
package/src/mcp/main.ts
CHANGED
|
@@ -28,10 +28,7 @@ the agent, through npx with the latest version.
|
|
|
28
28
|
Environment:
|
|
29
29
|
C64RT_VIDEO pal (default) or ntsc; fixed for the life of the process.
|
|
30
30
|
C64RT_HOST host:port of the Host Runtime when it is not on 127.0.0.1:6464,
|
|
31
|
-
host.docker.internal:6464 or host.containers.internal:6464
|
|
32
|
-
C64RT_HOST_TOKEN
|
|
33
|
-
the shared secret of a Host Runtime that listens on an address
|
|
34
|
-
that is not loopback. Use the same value as the Host Runtime.`;
|
|
31
|
+
host.docker.internal:6464 or host.containers.internal:6464.`;
|
|
35
32
|
|
|
36
33
|
function log(line: string): void {
|
|
37
34
|
process.stderr.write(`c64-re-tools-mcp: ${line}\n`);
|
package/src/protocol/messages.ts
CHANGED
|
@@ -54,13 +54,8 @@ export interface Hello {
|
|
|
54
54
|
role: Role;
|
|
55
55
|
/** vice-session only; fixed for the session. */
|
|
56
56
|
videoStandard?: VideoStandard;
|
|
57
|
-
/** The shared secret (C64RT_HOST_TOKEN) for a host that listens beyond loopback. */
|
|
58
|
-
token?: string;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
/** Longest accepted shared token. */
|
|
62
|
-
const MAX_TOKEN_LENGTH = 256;
|
|
63
|
-
|
|
64
59
|
export interface Ready {
|
|
65
60
|
type: "ready";
|
|
66
61
|
}
|
|
@@ -166,12 +161,8 @@ export function parseClientMessage(value: unknown): ReceivedClientMessage {
|
|
|
166
161
|
if (value.videoStandard !== undefined && !isOneOf(VIDEO_STANDARDS, value.videoStandard)) {
|
|
167
162
|
throw new ProtocolError("hello has an unknown video standard");
|
|
168
163
|
}
|
|
169
|
-
if (value.token !== undefined && (typeof value.token !== "string" || value.token.length > MAX_TOKEN_LENGTH)) {
|
|
170
|
-
throw new ProtocolError("hello has a malformed token");
|
|
171
|
-
}
|
|
172
164
|
const hello: Hello = { type: "hello", protocol: value.protocol, version: value.version, role: value.role };
|
|
173
165
|
if (value.videoStandard !== undefined) hello.videoStandard = value.videoStandard;
|
|
174
|
-
if (value.token !== undefined) hello.token = value.token;
|
|
175
166
|
return hello;
|
|
176
167
|
}
|
|
177
168
|
if (value.type === "request") {
|