relayrun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { createRepo } from "./repo.js";
6
+ import { startRunner } from "./runner.js";
7
+ import { apiKeyHelperPath, keyHint, looksLikeApiKey, SESSION_KEY_ENV, verifyApiKey, } from "./sessionKey.js";
8
+ const run = promisify(execFile);
9
+ /**
10
+ * Where a published build points when given no `--server`/`--web`.
11
+ *
12
+ * These are the two values to change after deploying, and the only ones — the
13
+ * CLI is installed on other people's machines, so "it works if you also run the
14
+ * server locally" is not a default anyone else can use. Env vars override them
15
+ * so a contributor can point at a scratch deployment without editing source.
16
+ */
17
+ const DEFAULT_SERVER = process.env.RELAY_SERVER ?? "https://relay-production-c9bd.up.railway.app";
18
+ const DEFAULT_WEB = process.env.RELAY_WEB ?? "https://relay-web-green.vercel.app";
19
+ const USAGE = `relayrun — run a Relay session against a repository on this machine
20
+
21
+ relayrun [options]
22
+
23
+ --repo <path> Repository to work in (default: current directory)
24
+ --server <url> Relay coordination server (default: ${DEFAULT_SERVER})
25
+ --web <url> Web app, for the printed link (default: ${DEFAULT_WEB})
26
+ --mock Run the scripted offline agent instead of a real one.
27
+ Costs nothing, but ignores what you type and replays
28
+ a fixed script. For UI work, not for real answers.
29
+ --api-key <key> Bill runs to this key instead of your Claude Code login
30
+ --session <id> Reattach to an existing session (requires --token)
31
+ --token <token> Runner token for --session
32
+ --github-repo <o/n> Open a PR here on publish
33
+ --github-token <tok> Token for --github-repo
34
+ -h, --help Show this message
35
+ `;
36
+ function parseArgs(argv) {
37
+ const args = {};
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const arg = argv[i];
40
+ if (!arg.startsWith("--")) {
41
+ if (arg === "-h")
42
+ args.help = true;
43
+ continue;
44
+ }
45
+ const key = arg.slice(2);
46
+ const next = argv[i + 1];
47
+ // A flag is boolean unless the next token is a value rather than a flag.
48
+ if (next && !next.startsWith("--")) {
49
+ args[key] = next;
50
+ i++;
51
+ }
52
+ else {
53
+ args[key] = true;
54
+ }
55
+ }
56
+ return args;
57
+ }
58
+ function str(args, key) {
59
+ const v = args[key];
60
+ return typeof v === "string" ? v : undefined;
61
+ }
62
+ function wsUrlFor(serverUrl, sessionId) {
63
+ const url = new URL(serverUrl);
64
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
65
+ url.searchParams.set("session", sessionId);
66
+ return url.toString();
67
+ }
68
+ /**
69
+ * The root of the repository containing `dir`.
70
+ *
71
+ * Resolved rather than used as given: `git rev-parse` succeeds from anywhere
72
+ * inside a repo, but `git clone` needs the root. Running the CLI from a
73
+ * subdirectory therefore passed the check and then failed at clone time — and
74
+ * a subdirectory is the normal case, since people run this from wherever they
75
+ * happen to be. Taking the root also means the session gets the whole
76
+ * repository, which is what someone naming their project means.
77
+ */
78
+ async function repoRoot(dir) {
79
+ try {
80
+ const { stdout } = await run("git", ["rev-parse", "--show-toplevel"], { cwd: dir });
81
+ return stdout.trim();
82
+ }
83
+ catch {
84
+ throw new Error(`not a git repository: ${dir}`);
85
+ }
86
+ }
87
+ /**
88
+ * Mints a session and the token that proves this process owns it.
89
+ *
90
+ * Retried: `pnpm dev` starts every package at once, so the server is routinely
91
+ * still binding its port when this runs.
92
+ */
93
+ async function createSession(serverUrl) {
94
+ const attempts = 10;
95
+ for (let i = 0; i < attempts; i++) {
96
+ try {
97
+ const res = await fetch(new URL("/sessions", serverUrl), { method: "POST" });
98
+ if (!res.ok)
99
+ throw new Error(`server returned ${res.status}`);
100
+ return (await res.json());
101
+ }
102
+ catch (err) {
103
+ if (i === attempts - 1) {
104
+ throw new Error(`could not reach the Relay server at ${serverUrl} — is it running? (${err instanceof Error ? err.message : err})`);
105
+ }
106
+ await new Promise((r) => setTimeout(r, 1_000));
107
+ }
108
+ }
109
+ throw new Error("unreachable");
110
+ }
111
+ /**
112
+ * Resolves what the run will be billed to, before connecting — a key that turns
113
+ * out to be bad should fail here, not three minutes into someone's session.
114
+ */
115
+ async function resolveCredentials(args) {
116
+ // Real is the default. The scripted agent ignores whatever you type and
117
+ // replays a fixed script, so getting it by accident means watching a
118
+ // convincing answer to a question you never asked. That failure is worse
119
+ // than the pennies an unwanted real run costs — ask for the mock by name.
120
+ if (args.mock)
121
+ return { mode: "mock", keySource: "mock", keyHint: null };
122
+ const key = str(args, "api-key") ?? process.env.RELAY_API_KEY;
123
+ if (!key) {
124
+ // No explicit key: the SDK uses whatever this machine is already logged in
125
+ // with, exactly as Claude Code does.
126
+ return { mode: "real", keySource: "oauth", keyHint: null };
127
+ }
128
+ if (!looksLikeApiKey(key)) {
129
+ throw new Error("that doesn't look like an Anthropic API key (expected sk-ant-…)");
130
+ }
131
+ const check = await verifyApiKey(key);
132
+ if (!check.ok)
133
+ throw new Error(check.error);
134
+ const helper = apiKeyHelperPath();
135
+ if (!helper) {
136
+ // Falling back to the operator's own login would bill the wrong account
137
+ // behind a UI saying otherwise — refuse instead.
138
+ throw new Error("could not create the API key helper, so --api-key can't be honoured");
139
+ }
140
+ process.env[SESSION_KEY_ENV] = key;
141
+ return { mode: "real", keySource: "api-key", keyHint: keyHint(key), apiKeyHelper: helper };
142
+ }
143
+ async function main() {
144
+ const args = parseArgs(process.argv.slice(2));
145
+ if (args.help) {
146
+ console.log(USAGE);
147
+ return;
148
+ }
149
+ const serverUrl = str(args, "server") ?? DEFAULT_SERVER;
150
+ const webUrl = str(args, "web") ?? DEFAULT_WEB;
151
+ const sourcePath = await repoRoot(path.resolve(str(args, "repo") ?? process.cwd()));
152
+ const credentials = await resolveCredentials(args);
153
+ const existing = str(args, "session");
154
+ const existingToken = str(args, "token");
155
+ if (existing && !existingToken) {
156
+ throw new Error("--session also needs --token (printed when the session was created)");
157
+ }
158
+ const { id, runnerToken } = existing && existingToken
159
+ ? { id: existing, runnerToken: existingToken }
160
+ : await createSession(serverUrl);
161
+ const repo = createRepo({
162
+ sourcePath,
163
+ githubRepo: str(args, "github-repo") ?? process.env.RELAY_GITHUB_REPO ?? null,
164
+ githubToken: str(args, "github-token") ?? process.env.RELAY_GITHUB_TOKEN ?? null,
165
+ });
166
+ const billing = credentials.mode === "mock"
167
+ ? "SCRIPTED MOCK — ignores your instructions, answers are fake"
168
+ : credentials.keySource === "api-key"
169
+ ? `real agent — billed to the key ending ${credentials.keyHint}`
170
+ : "real agent — billed to this machine's Claude Code login";
171
+ console.log(`\n repo ${path.basename(sourcePath)} (${sourcePath})`);
172
+ console.log(` agent ${billing}`);
173
+ console.log(` session ${id}`);
174
+ console.log(`\n Share this link:\n ${new URL(`/session/${id}`, webUrl)}\n`);
175
+ if (!existing) {
176
+ console.log(` To reattach after a restart:\n relayrun --session ${id} --token ${runnerToken}\n`);
177
+ }
178
+ startRunner({
179
+ wsUrl: wsUrlFor(serverUrl, id),
180
+ sessionId: id,
181
+ token: runnerToken,
182
+ repo,
183
+ mode: credentials.mode,
184
+ keySource: credentials.keySource,
185
+ keyHint: credentials.keyHint,
186
+ apiKeyHelper: credentials.apiKeyHelper,
187
+ });
188
+ }
189
+ main().catch((err) => {
190
+ console.error(`\n ${err instanceof Error ? err.message : err}\n`);
191
+ process.exit(1);
192
+ });
package/dist/emit.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/redact.js ADDED
@@ -0,0 +1,12 @@
1
+ const KEY_LIKE = /sk-ant-[A-Za-z0-9_-]+/g;
2
+ /**
3
+ * Scrubs anything key-shaped out of text bound for the server.
4
+ *
5
+ * A failed auth often echoes the key in its message, and anything emitted here
6
+ * lands in the transcript, every browser in the room, and Postgres. The server
7
+ * scrubs incoming text as well — this is the same guard at the other end of
8
+ * the socket, where the text is actually produced.
9
+ */
10
+ export function redactKeys(text) {
11
+ return text.replace(KEY_LIKE, (m) => `sk-ant-…${m.slice(-4)}`);
12
+ }
package/dist/repo.js ADDED
@@ -0,0 +1,224 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ const run = promisify(execFile);
8
+ // Cap the patch shipped to browsers. A runaway agent that rewrites a lockfile
9
+ // shouldn't push a multi-MB string down every participant's socket.
10
+ const MAX_PATCH_BYTES = 400_000;
11
+ function redactToken(s) {
12
+ return s.replace(/x-access-token:[^@]+@/g, "x-access-token:***@");
13
+ }
14
+ export function createRepo(config) {
15
+ // Scratch space under the OS temp dir, not inside the operator's repo — the
16
+ // clones are disposable and must never show up in their working tree.
17
+ const root = path.join(tmpdir(), "relayrun");
18
+ const pristineDir = path.join(root, "pristine");
19
+ const sessionsDir = path.join(root, "sessions");
20
+ let pristineReady = null;
21
+ /**
22
+ * Serializes git operations per working directory.
23
+ *
24
+ * `sessionChanges` and `publishSession` both run a sequence of raw git
25
+ * commands and both are reachable from more than one caller at once: two
26
+ * participants joining in the same second each trigger a changes request, and
27
+ * a publish can land while one is still in flight. Without this, concurrent
28
+ * `git add -A` calls collide on `.git/index.lock` — caught live against a
29
+ * two-participant session, where it surfaced as the request failing outright.
30
+ *
31
+ * A promise chain per directory rather than a mutex library: the only
32
+ * property needed is "the next operation waits for the last one."
33
+ */
34
+ const repoLocks = new Map();
35
+ function withRepoLock(dir, fn) {
36
+ const prior = repoLocks.get(dir) ?? Promise.resolve();
37
+ // Chained even through a rejection: one failed operation must not wedge
38
+ // every later caller on this directory.
39
+ const next = prior.catch(() => { }).then(fn);
40
+ repoLocks.set(dir, next.catch(() => { }));
41
+ return next;
42
+ }
43
+ // `git clone` copies committed state only — no node_modules, no .DS_Store,
44
+ // and deliberately none of the operator's uncommitted work.
45
+ async function ensurePristine() {
46
+ if (existsSync(pristineDir))
47
+ return;
48
+ await mkdir(path.dirname(pristineDir), { recursive: true });
49
+ await run("git", ["clone", config.sourcePath, pristineDir]);
50
+ }
51
+ function ensurePristineOnce() {
52
+ pristineReady ??= ensurePristine();
53
+ return pristineReady;
54
+ }
55
+ /**
56
+ * Everything the agent touched this session, relative to the clone's starting
57
+ * commit. Staging first is what makes new files (the common case) show up at
58
+ * all; `git diff` alone ignores untracked paths. The working dir is
59
+ * disposable, so leaving things staged costs nothing.
60
+ *
61
+ * Unlocked: publishSession calls this from inside its own lock, and the lock
62
+ * isn't reentrant.
63
+ */
64
+ async function computeSessionChanges(dir) {
65
+ await run("git", ["add", "-A"], { cwd: dir });
66
+ const { stdout: numstat } = await run("git", ["diff", "--cached", "--numstat"], {
67
+ cwd: dir,
68
+ maxBuffer: 32 * 1024 * 1024,
69
+ });
70
+ const files = [];
71
+ for (const line of numstat.split("\n")) {
72
+ if (!line.trim())
73
+ continue;
74
+ const [ins, del, ...rest] = line.split("\t");
75
+ files.push({
76
+ path: rest.join("\t"),
77
+ // Binary files report "-" rather than a count.
78
+ insertions: ins === "-" ? 0 : Number(ins) || 0,
79
+ deletions: del === "-" ? 0 : Number(del) || 0,
80
+ });
81
+ }
82
+ const { stdout: rawPatch } = await run("git", ["diff", "--cached"], {
83
+ cwd: dir,
84
+ maxBuffer: 32 * 1024 * 1024,
85
+ });
86
+ const patch = Buffer.byteLength(rawPatch) > MAX_PATCH_BYTES
87
+ ? `${rawPatch.slice(0, MAX_PATCH_BYTES)}\n\n… patch truncated — ${files.length} files changed in total.`
88
+ : rawPatch;
89
+ return {
90
+ files,
91
+ insertions: files.reduce((n, f) => n + f.insertions, 0),
92
+ deletions: files.reduce((n, f) => n + f.deletions, 0),
93
+ patch,
94
+ };
95
+ }
96
+ async function defaultBranch(repo, token) {
97
+ try {
98
+ const res = await fetch(`https://api.github.com/repos/${repo}`, {
99
+ headers: {
100
+ authorization: `Bearer ${token}`,
101
+ accept: "application/vnd.github+json",
102
+ },
103
+ });
104
+ if (!res.ok)
105
+ return "main";
106
+ const json = (await res.json());
107
+ return json.default_branch ?? "main";
108
+ }
109
+ catch {
110
+ return "main";
111
+ }
112
+ }
113
+ async function doPublish(dir, sessionId, message) {
114
+ const branch = `relay/session-${sessionId}`;
115
+ try {
116
+ const changes = await computeSessionChanges(dir);
117
+ if (changes.files.length === 0) {
118
+ return { ok: false, error: "nothing to publish — no files changed" };
119
+ }
120
+ await run("git", ["checkout", "-B", branch], { cwd: dir });
121
+ // Identity is per-clone and disposable; without it `git commit` fails on
122
+ // machines with no global user.email configured.
123
+ await run("git", [
124
+ "-c",
125
+ "user.email=relay@localhost",
126
+ "-c",
127
+ "user.name=Relay",
128
+ "commit",
129
+ "-m",
130
+ message,
131
+ ], { cwd: dir });
132
+ // Land the branch in the operator's own repository, by path. The session
133
+ // clone is deleted when the room empties, so a commit that only exists
134
+ // there is gone within seconds of the run finishing. Pushing a branch
135
+ // whose name is unique per session never touches whatever they have
136
+ // checked out.
137
+ await run("git", ["push", "--force", config.sourcePath, `HEAD:${branch}`], {
138
+ cwd: dir,
139
+ });
140
+ const { githubRepo, githubToken } = config;
141
+ if (!githubRepo || !githubToken) {
142
+ return { ok: true, branch, pushed: false, prUrl: null };
143
+ }
144
+ // The token goes in the remote URL for one push and is never persisted to
145
+ // the clone's config, logged, or sent to a browser.
146
+ const remote = `https://x-access-token:${githubToken}@github.com/${githubRepo}.git`;
147
+ await run("git", ["push", "--force", remote, `HEAD:${branch}`], { cwd: dir });
148
+ const base = await defaultBranch(githubRepo, githubToken);
149
+ const res = await fetch(`https://api.github.com/repos/${githubRepo}/pulls`, {
150
+ method: "POST",
151
+ headers: {
152
+ authorization: `Bearer ${githubToken}`,
153
+ accept: "application/vnd.github+json",
154
+ "content-type": "application/json",
155
+ },
156
+ body: JSON.stringify({
157
+ title: message,
158
+ head: branch,
159
+ base,
160
+ body: `Opened from a Relay session (\`${sessionId}\`).\n\n${changes.files.length} files changed, +${changes.insertions} −${changes.deletions}.`,
161
+ }),
162
+ });
163
+ if (!res.ok) {
164
+ // The branch is pushed either way — say so rather than implying the
165
+ // whole thing failed.
166
+ const detail = await res.text();
167
+ return {
168
+ ok: true,
169
+ branch,
170
+ pushed: true,
171
+ prUrl: null,
172
+ note: `branch pushed; PR not created (${res.status}) ${redactToken(detail.slice(0, 140))}`,
173
+ };
174
+ }
175
+ const pr = (await res.json());
176
+ return { ok: true, branch, pushed: true, prUrl: pr.html_url ?? null };
177
+ }
178
+ catch (err) {
179
+ // Never echo the remote URL back — it carries the token.
180
+ const raw = err instanceof Error ? err.message : String(err);
181
+ return { ok: false, error: redactToken(raw) };
182
+ }
183
+ }
184
+ return {
185
+ /**
186
+ * Just the folder name, for the session header. A watcher needs to know
187
+ * which codebase they're watching; the operator's full directory layout is
188
+ * both noise and a needless disclosure.
189
+ */
190
+ repoName() {
191
+ return path.basename(config.sourcePath) || null;
192
+ },
193
+ /**
194
+ * A clone of the pristine copy, so a session can edit and commit freely
195
+ * without touching the pristine tree, let alone the operator's own.
196
+ */
197
+ async prepareWorkingDir(sessionId) {
198
+ await ensurePristineOnce();
199
+ const dir = path.join(sessionsDir, sessionId);
200
+ await rm(dir, { recursive: true, force: true });
201
+ await mkdir(sessionsDir, { recursive: true });
202
+ await run("git", ["clone", pristineDir, dir]);
203
+ return dir;
204
+ },
205
+ async disposeWorkingDir(sessionId) {
206
+ const dir = path.join(sessionsDir, sessionId);
207
+ await rm(dir, { recursive: true, force: true });
208
+ // Otherwise repoLocks accumulates one entry per session for the life of
209
+ // the process.
210
+ repoLocks.delete(dir);
211
+ },
212
+ sessionChanges(dir) {
213
+ return withRepoLock(dir, () => computeSessionChanges(dir));
214
+ },
215
+ /**
216
+ * Commit the session's work to a branch, push it to the operator's repo,
217
+ * and open a pull request if a GitHub repo and token are configured.
218
+ */
219
+ publishSession(dir, sessionId, message) {
220
+ // The whole sequence is one queued unit.
221
+ return withRepoLock(dir, () => doPublish(dir, sessionId, message));
222
+ },
223
+ };
224
+ }
package/dist/runner.js ADDED
@@ -0,0 +1,201 @@
1
+ import WebSocket from "ws";
2
+ import { runRealAgent } from "./agent/run.js";
3
+ import { runMockAgent } from "./agent/mock.js";
4
+ const RECONNECT_BASE_MS = 1_000;
5
+ const RECONNECT_MAX_MS = 15_000;
6
+ export function startRunner(opts) {
7
+ const { wsUrl, sessionId, token, repo, mode } = opts;
8
+ let socket = null;
9
+ let attempts = 0;
10
+ let workingDir = null;
11
+ let currentRun = null;
12
+ // Set when the server says the problem is permanent, so `close` stops
13
+ // rescheduling and the process can exit instead of spinning.
14
+ let giveUp = false;
15
+ function send(msg) {
16
+ if (socket?.readyState === WebSocket.OPEN)
17
+ socket.send(JSON.stringify(msg));
18
+ }
19
+ const emit = {
20
+ event: (kind, data) => send({ type: "runner_event", kind, data }),
21
+ status: (status) => send({ type: "runner_status", status }),
22
+ agentSession: (id) => send({ type: "runner_agent_session", agentSessionId: id }),
23
+ };
24
+ // Prepared on the first instruction rather than at startup: cloning for a
25
+ // session nobody ever drives is wasted work.
26
+ async function ensureWorkingDir() {
27
+ workingDir ??= await repo.prepareWorkingDir(sessionId);
28
+ return workingDir;
29
+ }
30
+ function fail(status, message) {
31
+ emit.event("agent_error", { message });
32
+ emit.status(status);
33
+ }
34
+ async function handleInstruction(text, resume) {
35
+ const abort = new AbortController();
36
+ currentRun = abort;
37
+ try {
38
+ const dir = await ensureWorkingDir();
39
+ if (mode === "mock") {
40
+ await runMockAgent({ emit, instruction: text, workingDir: dir, signal: abort.signal });
41
+ }
42
+ else {
43
+ await runRealAgent({
44
+ emit,
45
+ instruction: text,
46
+ workingDir: dir,
47
+ signal: abort.signal,
48
+ resume,
49
+ apiKeyHelper: opts.apiKeyHelper,
50
+ });
51
+ }
52
+ }
53
+ catch (err) {
54
+ fail("error", err instanceof Error ? err.message : String(err));
55
+ }
56
+ finally {
57
+ if (currentRun === abort)
58
+ currentRun = null;
59
+ }
60
+ }
61
+ async function handleChangesRequest(requestId) {
62
+ // Nothing has run yet, so there is nothing to diff — an empty answer is
63
+ // correct here, not an error.
64
+ if (!workingDir) {
65
+ send({
66
+ type: "runner_changes",
67
+ requestId,
68
+ files: [],
69
+ insertions: 0,
70
+ deletions: 0,
71
+ patch: "",
72
+ });
73
+ return;
74
+ }
75
+ try {
76
+ const changes = await repo.sessionChanges(workingDir);
77
+ send({ type: "runner_changes", requestId, ...changes });
78
+ }
79
+ catch (err) {
80
+ console.error("could not read session changes:", err);
81
+ send({
82
+ type: "runner_changes",
83
+ requestId,
84
+ files: [],
85
+ insertions: 0,
86
+ deletions: 0,
87
+ patch: "",
88
+ });
89
+ }
90
+ }
91
+ async function handlePublish(title) {
92
+ if (!workingDir) {
93
+ send({
94
+ type: "runner_publish_result",
95
+ ok: false,
96
+ error: "this session hasn't run anything yet",
97
+ });
98
+ return;
99
+ }
100
+ try {
101
+ const result = await repo.publishSession(workingDir, sessionId, title);
102
+ send(result.ok
103
+ ? {
104
+ type: "runner_publish_result",
105
+ ok: true,
106
+ branch: result.branch,
107
+ pushed: result.pushed,
108
+ prUrl: result.prUrl,
109
+ note: result.note,
110
+ }
111
+ : { type: "runner_publish_result", ok: false, error: result.error });
112
+ }
113
+ catch (err) {
114
+ console.error("publish failed:", err);
115
+ send({ type: "runner_publish_result", ok: false, error: "publish failed" });
116
+ }
117
+ }
118
+ function handleMessage(raw) {
119
+ let msg;
120
+ try {
121
+ msg = JSON.parse(raw);
122
+ }
123
+ catch {
124
+ return;
125
+ }
126
+ switch (msg.type) {
127
+ case "runner_ready":
128
+ // Only now is the socket genuinely attached. Resetting backoff on
129
+ // "open" instead would make a server that accepts and immediately
130
+ // rejects look like a healthy connection, and retry it every second
131
+ // forever.
132
+ attempts = 0;
133
+ console.log("connected — waiting for instructions");
134
+ break;
135
+ case "runner_rejected":
136
+ console.error(`\n ${msg.reason}\n`);
137
+ if (msg.fatal) {
138
+ // Retrying cannot change the answer, and a silent reconnect loop
139
+ // against a dead session is worse than stopping: the terminal keeps
140
+ // claiming to be connected while nothing works.
141
+ giveUp = true;
142
+ socket?.close();
143
+ process.exitCode = 1;
144
+ }
145
+ break;
146
+ case "run_instruction":
147
+ void handleInstruction(msg.text, msg.resume);
148
+ break;
149
+ case "stop_run":
150
+ currentRun?.abort();
151
+ break;
152
+ case "request_runner_changes":
153
+ void handleChangesRequest(msg.requestId);
154
+ break;
155
+ case "run_publish":
156
+ void handlePublish(msg.title);
157
+ break;
158
+ }
159
+ }
160
+ function connect() {
161
+ const ws = new WebSocket(wsUrl);
162
+ socket = ws;
163
+ ws.on("open", () => {
164
+ send({
165
+ type: "runner_hello",
166
+ sessionId,
167
+ token,
168
+ repoName: repo.repoName(),
169
+ mode,
170
+ keySource: opts.keySource,
171
+ keyHint: opts.keyHint,
172
+ });
173
+ });
174
+ ws.on("message", (data) => handleMessage(data.toString()));
175
+ ws.on("error", (err) => {
176
+ console.error("connection error:", err instanceof Error ? err.message : err);
177
+ });
178
+ ws.on("close", () => {
179
+ if (socket === ws)
180
+ socket = null;
181
+ // An in-flight run can no longer report anything, so stop it rather than
182
+ // letting it spend time (or credit) with nowhere to send the result.
183
+ currentRun?.abort();
184
+ if (giveUp) {
185
+ void repo.disposeWorkingDir(sessionId).finally(() => process.exit(1));
186
+ return;
187
+ }
188
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempts, RECONNECT_MAX_MS);
189
+ attempts++;
190
+ console.log(`disconnected — retrying in ${Math.round(delay / 1000)}s`);
191
+ setTimeout(connect, delay);
192
+ });
193
+ }
194
+ const cleanup = () => {
195
+ currentRun?.abort();
196
+ void repo.disposeWorkingDir(sessionId).finally(() => process.exit(0));
197
+ };
198
+ process.on("SIGINT", cleanup);
199
+ process.on("SIGTERM", cleanup);
200
+ connect();
201
+ }