@kyleroche/relay 0.1.0 → 0.2.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/LICENSE CHANGED
@@ -187,7 +187,7 @@
187
187
  same "printed page" as the copyright notice for easier
188
188
  identification within third-party archives.
189
189
 
190
- Copyright [yyyy] [name of copyright owner]
190
+ Copyright 2026 Kyle Roche
191
191
 
192
192
  Licensed under the Apache License, Version 2.0 (the "License");
193
193
  you may not use this file except in compliance with the License.
@@ -0,0 +1,66 @@
1
+ # relay worker image (docs/WORKERS.md, "Isolation"): everything `relay work` needs, in one Linux VM.
2
+ #
3
+ # `relay work --isolation container --install` builds this with Apple's `container` CLI and creates the container
4
+ # `relay-work` from it, with a named volume at /home/relay. The vendor CLIs' logins, the site token, the clones and
5
+ # the model's shell all live in that volume; the Mac's home directory is never mounted. Everything the image
6
+ # installs goes under /usr/local, because the volume hides whatever the image put in /home/relay.
7
+ #
8
+ # Build arguments:
9
+ # RELAY_SPEC what `npm install -g` gets for relay when the build context holds no tarball
10
+ # (`@kyleroche/relay@<version>`); a checkout drops its `npm pack` tarball into the context instead
11
+ # FX_VERSION fx release (`vX.Y.Z`); `latest` reads https://releases.fx.sh/latest.txt
12
+ # CLAUDE_SPEC the Claude Code npm package spec
13
+ FROM docker.io/library/node:24-bookworm-slim
14
+
15
+ ARG RELAY_SPEC=@kyleroche/relay
16
+ ARG FX_VERSION=latest
17
+ ARG CLAUDE_SPEC=@anthropic-ai/claude-code
18
+ ARG DEBIAN_FRONTEND=noninteractive
19
+
20
+ # git, curl and CA certificates for the clone and the installers; bubblewrap, socat and ripgrep for Claude Code's own
21
+ # sandbox around the lane's Bash (nested inside the VM: the container is created with the runtime masks off so a
22
+ # user namespace can mount /proc); util-linux for `setpriv`; gh from GitHub's own apt repository.
23
+ RUN apt-get update \
24
+ && apt-get install -y --no-install-recommends ca-certificates curl git gnupg procps util-linux bubblewrap socat ripgrep less \
25
+ && install -d -m 0755 /usr/share/keyrings \
26
+ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
27
+ && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
28
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
29
+ && apt-get update \
30
+ && apt-get install -y --no-install-recommends gh \
31
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
32
+
33
+ # fx: the release tarball for this architecture, verified against the .sha256 the CDN publishes beside it.
34
+ RUN set -eu; \
35
+ arch="$(uname -m)"; case "$arch" in aarch64|arm64) arch=aarch64;; x86_64|amd64) arch=x86_64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
36
+ v="$FX_VERSION"; if [ "$v" = latest ]; then v="$(curl -fsSL https://releases.fx.sh/latest.txt | tr -d '[:space:]')"; fi; \
37
+ cd /tmp; curl -fsSL "https://releases.fx.sh/$v/fx-linux-$arch.tar.gz" -o fx.tar.gz; \
38
+ curl -fsSL "https://releases.fx.sh/$v/fx-linux-$arch.tar.gz.sha256" -o fx.sha256; \
39
+ want="$(tr -d '[:space:]' < fx.sha256 | cut -c1-64)"; have="$(sha256sum fx.tar.gz | cut -c1-64)"; \
40
+ [ "$want" = "$have" ] || { echo "fx checksum mismatch: $want != $have" >&2; exit 1; }; \
41
+ tar -xzf fx.tar.gz; install -m 0755 fx /usr/local/bin/fx; rm -f fx fx.tar.gz fx.sha256; \
42
+ fx --version
43
+
44
+ # pnpm through corepack (the Node image ships it), Claude Code (its postinstall puts the native binary in place, so
45
+ # it is allowed by name), and relay: a tarball from the build context when a checkout built this image, the npm
46
+ # package otherwise.
47
+ COPY . /tmp/ctx/
48
+ RUN set -eu; \
49
+ corepack enable && corepack prepare pnpm@latest --activate; \
50
+ npm install -g --no-fund --no-audit --allow-scripts=@anthropic-ai/claude-code "$CLAUDE_SPEC"; \
51
+ tgz="$(ls /tmp/ctx/*.tgz 2>/dev/null | head -n1 || true)"; \
52
+ if [ -n "$tgz" ]; then npm install -g --no-fund --no-audit "$tgz"; else npm install -g --no-fund --no-audit "$RELAY_SPEC"; fi; \
53
+ npm cache clean --force; rm -rf /tmp/ctx; \
54
+ claude --version; relay --version
55
+
56
+ # The worker's user. The volume mounted at /home/relay arrives owned by root; relay-entry hands it over on first boot.
57
+ RUN userdel -r node 2>/dev/null || true; \
58
+ groupadd -g 1000 relay && useradd -m -u 1000 -g 1000 -s /bin/bash relay
59
+ COPY --chmod=0755 relay-entry /usr/local/bin/relay-entry
60
+
61
+ ENV RELAY_HOME=/home/relay/.harness \
62
+ RELAY_ISOLATION=container \
63
+ HOME=/home/relay
64
+ WORKDIR /home/relay
65
+ ENTRYPOINT ["/usr/local/bin/relay-entry"]
66
+ CMD ["relay", "work"]
@@ -0,0 +1,31 @@
1
+ #!/bin/sh
2
+ # Entry point of the relay worker image. The container starts as root only to hand the volume at /home/relay to the
3
+ # `relay` user (a fresh volume is an empty ext4 owned by root); then it drops to that user for whatever it was asked
4
+ # to run: `relay work` by default, `bash` for `relay work container shell`, `relay setup --site` at install.
5
+ #
6
+ # Login callback forwarders: `container create --publish 127.0.0.1:<port>:<port>` delivers the Mac browser's redirect
7
+ # to the container's eth0 address, but the login servers the CLIs start (`fx login codex` hands off to Codex's own
8
+ # listener on 1455; fx's own callback is 3000) bind the container's loopback only, so the redirect would be dropped.
9
+ # One socat per port listens on eth0 and forwards to 127.0.0.1, as the relay user, in the background; while no login
10
+ # runs the forwarder just waits (a connection then fails, as it would have anyway). RELAY_LOGIN_PORTS lists the ports
11
+ # (default: packages/core/src/work/container.ts LOGIN_CALLBACK_PORTS); empty turns the forwarders off.
12
+ set -eu
13
+ home=/home/relay
14
+ uid="$(id -u relay)"
15
+ gid="$(id -g relay)"
16
+ if [ "$(stat -c %u "$home")" != "$uid" ]; then chown "$uid:$gid" "$home"; fi
17
+ [ -d "$home/.harness" ] || install -d -o "$uid" -g "$gid" "$home/.harness"
18
+
19
+ ports="${RELAY_LOGIN_PORTS-3000 1455}"
20
+ if [ -n "$ports" ] && command -v socat >/dev/null 2>&1; then
21
+ # the image has no `ip`; hostname -I prints every address, the first is eth0's
22
+ addr="$(hostname -I 2>/dev/null | cut -d" " -f1)"
23
+ if [ -n "$addr" ]; then
24
+ for port in $ports; do
25
+ setpriv --reuid="$uid" --regid="$gid" --init-groups \
26
+ socat "TCP-LISTEN:$port,bind=$addr,reuseaddr,fork" "TCP:127.0.0.1:$port" >/dev/null 2>&1 &
27
+ done
28
+ fi
29
+ fi
30
+
31
+ exec setpriv --reuid="$uid" --regid="$gid" --init-groups env HOME="$home" USER=relay LOGNAME=relay "$@"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code status line (settings.json `statusLine.command`, installed by `relay setup`). Claude Code passes a JSON
3
+ // document on stdin on every refresh; for claude.ai Pro/Max logins it carries `rate_limits.five_hour` and
4
+ // `rate_limits.seven_day` as { used_percentage, resets_at } (unix seconds), the only documented source of the plan
5
+ // meters the web app's header shows. This script:
6
+ // 1. saves them to <RELAY_HOME>/meters/claude.json (atomic write, 0600; the directory 0700) when present;
7
+ // 2. prints the status line: the command that was there before `relay setup` (recorded in
8
+ // <RELAY_HOME>/statusline.prev.json) is run with the same stdin and its output passed through, so a user's own
9
+ // status line keeps working; otherwise a compact default: `<model> · <cwd> · 5h 42% · 7d 44%`.
10
+ // Nothing here can break a session: on any error it prints nothing and exits 0. Stays dependency-free and reads no
11
+ // other file than those two, because it runs on every refresh.
12
+ import { spawnSync } from "node:child_process";
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { basename, join } from "node:path";
16
+
17
+ const RELAY_HOME = process.env.RELAY_HOME ?? join(homedir(), ".harness");
18
+ const METERS_DIR = join(RELAY_HOME, "meters");
19
+ const PREV_PATH = join(RELAY_HOME, "statusline.prev.json");
20
+
21
+ function main() {
22
+ let raw = "";
23
+ try {
24
+ raw = readFileSync(0, "utf8");
25
+ } catch {
26
+ return;
27
+ }
28
+ let payload;
29
+ try {
30
+ payload = JSON.parse(raw);
31
+ } catch {
32
+ return;
33
+ }
34
+ if (!payload || typeof payload !== "object") return;
35
+
36
+ const limits = payload.rate_limits;
37
+ let meters = null;
38
+ if (limits && typeof limits === "object") {
39
+ meters = { subscription: "max", five_hour: windowOf(limits.five_hour), seven_day: windowOf(limits.seven_day), captured_at: new Date().toISOString() };
40
+ try {
41
+ writeMeters(meters);
42
+ } catch {
43
+ /* a read-only home: the status line still prints */
44
+ }
45
+ }
46
+
47
+ const prev = previousCommand();
48
+ if (prev) {
49
+ const cwd = typeof payload.cwd === "string" && existsSync(payload.cwd) ? payload.cwd : undefined;
50
+ const r = spawnSync(prev, { shell: true, input: raw, encoding: "utf8", timeout: 5000, env: process.env, ...(cwd ? { cwd } : {}), stdio: ["pipe", "pipe", "ignore"] });
51
+ if (!r.error && typeof r.stdout === "string") process.stdout.write(r.stdout);
52
+ return;
53
+ }
54
+ const parts = [];
55
+ const model = payload.model && typeof payload.model.display_name === "string" ? payload.model.display_name : "";
56
+ if (model) parts.push(model);
57
+ const dir = (payload.workspace && typeof payload.workspace.current_dir === "string" && payload.workspace.current_dir) || (typeof payload.cwd === "string" ? payload.cwd : "");
58
+ if (dir) parts.push(basename(dir));
59
+ if (meters?.five_hour) parts.push(`5h ${meters.five_hour.used_percentage}%`);
60
+ if (meters?.seven_day) parts.push(`7d ${meters.seven_day.used_percentage}%`);
61
+ if (parts.length) process.stdout.write(parts.join(" · ") + "\n");
62
+ }
63
+
64
+ /** { used_percentage, resets_at } with the percentage clamped to 0..100, or null when the window is absent. */
65
+ function windowOf(w) {
66
+ if (!w || typeof w !== "object" || typeof w.used_percentage !== "number" || !Number.isFinite(w.used_percentage)) return null;
67
+ const resets = typeof w.resets_at === "number" && Number.isFinite(w.resets_at) ? w.resets_at : null;
68
+ return { used_percentage: Math.max(0, Math.min(100, Math.round(w.used_percentage))), resets_at: resets };
69
+ }
70
+
71
+ /** Write <METERS_DIR>/claude.json: temp file beside it, 0600, then rename, so a reader never sees half a file. */
72
+ function writeMeters(meters) {
73
+ mkdirSync(METERS_DIR, { recursive: true, mode: 0o700 });
74
+ const target = join(METERS_DIR, "claude.json");
75
+ const tmp = join(METERS_DIR, `.claude.${process.pid}.tmp`);
76
+ writeFileSync(tmp, JSON.stringify(meters) + "\n", { mode: 0o600 });
77
+ chmodSync(tmp, 0o600);
78
+ renameSync(tmp, target);
79
+ }
80
+
81
+ /** The status-line command `relay setup` replaced, if it recorded one. */
82
+ function previousCommand() {
83
+ if (!existsSync(PREV_PATH)) return undefined;
84
+ try {
85
+ const j = JSON.parse(readFileSync(PREV_PATH, "utf8"));
86
+ return j && typeof j.command === "string" && j.command.trim() ? j.command : undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ try {
93
+ main();
94
+ } catch {
95
+ /* never break the session */
96
+ }
97
+ process.exitCode = 0;
@@ -0,0 +1,13 @@
1
+ -- The doorbell (docs/WORKERS.md, "The loop"): one row whose version moves every time the queue may have changed
2
+ -- (a task opened, a lease released or expired). An idle worker reads it instead of running a claim, and claims only
3
+ -- when the version moved or something is queued, so an empty queue costs the site one cheap query per tick.
4
+ CREATE TABLE IF NOT EXISTS queue_state (
5
+ one boolean PRIMARY KEY DEFAULT true CHECK (one), -- exactly one row
6
+ version bigint NOT NULL DEFAULT 0,
7
+ updated_at timestamptz NOT NULL DEFAULT now()
8
+ );
9
+ INSERT INTO queue_state (one, version) VALUES (true, 0) ON CONFLICT DO NOTHING;
10
+
11
+ -- The poll interval a worker last reported with its heartbeat (ms), so the workers page and `relay work status`
12
+ -- can show how often an idle worker checks in. Null until a worker on this version heartbeats.
13
+ ALTER TABLE workers ADD COLUMN IF NOT EXISTS poll_ms integer;
@@ -0,0 +1,5 @@
1
+ -- Plan meters a worker reports with its heartbeat (packages/core/src/meters.ts): the 5-hour and 7-day used
2
+ -- percentages Claude Code hands its status line, saved by relay's status-line script under ~/.harness/meters and sent
3
+ -- along as [{subscription, window, used_percentage, resets_at, captured_at, source}]. Null until a worker on this
4
+ -- version heartbeats; the site shows the freshest reading per window across online and idle workers.
5
+ ALTER TABLE workers ADD COLUMN IF NOT EXISTS meters jsonb;
@@ -0,0 +1,20 @@
1
+ -- Materialized task state (docs/ARCHITECTURE.md, "A task's life"). The feed stays the source of truth: these columns
2
+ -- are a cache of the fold in packages/core/src/db/tasks.ts (deriveTaskStatus) and tasks/station.ts (deriveStation),
3
+ -- written by refreshTaskState (db/task-state.ts) after every write that can change a task (an event with the task's
4
+ -- repo and issue, a claim taken, released or expired, a task created). The flow map and the tasks list read them in
5
+ -- one query instead of folding every task's history on every poll. NULL state means "not computed yet": readers fold
6
+ -- the row and fill it in (backfillTaskState runs once when this migration lands).
7
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS title text; -- the issue title, from the creator or the feed
8
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS state text; -- queued | claimed | running | review | done | failed
9
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS station text; -- Queue | Claimed | Running | PR | Preview | Review | Merged | Failed
10
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS since timestamptz; -- when the task entered `station`
11
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pr integer;
12
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pr_url text;
13
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS preview_state text; -- ready | building | failed | none
14
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS preview_url text;
15
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS runner text; -- runner:<name> of the worker on it
16
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_error text; -- the latest run.failed (or failing run.finished) line
17
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS cost double precision; -- folded cost_equiv of the task's step.usage rows
18
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS state_updated_at timestamptz; -- when the cache was last written
19
+ CREATE INDEX IF NOT EXISTS tasks_state_idx ON tasks(state);
20
+ CREATE INDEX IF NOT EXISTS tasks_station_idx ON tasks(station);