@rethinkingstudio/agent-runner 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rethinking Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @rethinkingstudio/agent-runner
2
+
3
+ Starts your coding agent when a job lands on your [Doable](https://doable.rethinking.art)
4
+ Agent Desk, so work you hand off from your phone gets picked up in seconds
5
+ instead of waiting until you sit down and ask.
6
+
7
+ MIT. It runs on your machine with access to your code — nobody should run a
8
+ closed binary for that.
9
+
10
+ ## Why anything has to run locally
11
+
12
+ MCP is pull-only, and `claude mcp add` registers a server without starting a
13
+ process. But the deeper reason is simpler: **coding agents have no inbox.**
14
+ Claude Code, Cursor, Codex — none of them can be told "start working" while
15
+ idle. They are invoked, not addressed.
16
+
17
+ So something local has to be able to *start* a session. This is that something,
18
+ and it is deliberately dumb: it notices queued work and shells out. It holds no
19
+ state and makes no decisions about the work.
20
+
21
+ ## Setting it up
22
+
23
+ Ask your agent, in the directory the jobs are about:
24
+
25
+ > Set up the Doable runner on this machine.
26
+
27
+ It calls the `setup_runner` MCP tool, gets a command back, and runs it. You never
28
+ touch a terminal. Or do it yourself:
29
+
30
+ ```
31
+ npx -y @rethinkingstudio/agent-runner install --token dbl_… --cwd ~/code/app --name mac-studio
32
+ ```
33
+
34
+ | | |
35
+ |---|---|
36
+ | `install` | Write settings, register a launchd (macOS) or systemd (Linux) service, start it |
37
+ | `watch` | Run in the foreground instead |
38
+ | `status` | What it is configured to do, and whether it is running |
39
+ | `uninstall` | Stop and remove the service |
40
+
41
+ Settings live in `~/.doable/runner.json`, mode 600 — outside any repo, because
42
+ the token is a credential and `git add -A` should not be able to reach it.
43
+
44
+ ## The default tool list is narrow on purpose
45
+
46
+ ```
47
+ mcp__doable__*,Read,Glob,Grep,WebFetch,WebSearch
48
+ ```
49
+
50
+ No Write, no Edit, no Bash. These sessions run with nobody watching, so the
51
+ default is what a research or writing job needs, and anything that changes files
52
+ is something you opt into:
53
+
54
+ ```
55
+ npx @rethinkingstudio/agent-runner install --tools "mcp__doable__*,Read,Edit,Write,Bash" …
56
+ ```
57
+
58
+ Widen it once you are comfortable with what gets started here, not before.
59
+
60
+ ## Any agent, not just Claude Code
61
+
62
+ The desk speaks MCP, so anything that speaks MCP can work it. `--exec` is a
63
+ plain shell command, and the prompt arrives **on stdin and in `$DOABLE_PROMPT`**,
64
+ because CLIs disagree about which they want and neither needs quoting.
65
+
66
+ ```
67
+ npx @rethinkingstudio/agent-runner install --exec 'codex exec "$DOABLE_PROMPT"' …
68
+ npx @rethinkingstudio/agent-runner install --exec 'my-agent --stdin' …
69
+ ```
70
+
71
+ If your agent has no headless mode — a GUI-only assistant — this is not for you,
72
+ and nothing is lost: connect it over MCP and work the desk by asking, which is
73
+ the default mode anyway.
74
+
75
+ ## How it watches
76
+
77
+ Long polling. `GET /api/agent/events` is held open for ~25s and answers the
78
+ moment the desk moves, so latency is about a second while idle costs one parked
79
+ HTTP request. Against a cron recipe that wakes a session every 30 minutes to
80
+ find nothing, this is both faster and cheaper.
81
+
82
+ The polling has not disappeared — it moved to the server, where a database read
83
+ is cheap.
84
+
85
+ ## What it will not do
86
+
87
+ - **Run two sessions at once.** The desk allows one job per machine; a second
88
+ would only be refused.
89
+ - **Decide anything about the work.** It passes a fixed prompt and gets out of
90
+ the way. The guardrails — one job, report progress, obey an abort, never mark
91
+ anything done — live in that prompt and in the server's rules.
92
+ - **Keep going on a revoked token.** A 401 exits rather than looping. Revoke a
93
+ machine from Account & agents in the app and it stops.
94
+
95
+ ## Turning it off
96
+
97
+ ```
98
+ npx @rethinkingstudio/agent-runner uninstall
99
+ rm ~/.doable/runner.json
100
+ ```
101
+
102
+ Then revoke the machine in the app, which invalidates the token server-side.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@rethinkingstudio/agent-runner",
3
+ "version": "0.1.0",
4
+ "description": "Starts your coding agent when a job lands on your Doable Agent Desk.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "doable-agent-runner": "src/cli.mjs"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Rethinking-studio/doable-agent-runner.git"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "agent",
25
+ "claude",
26
+ "doable"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "homepage": "https://doable.rethinking.art",
32
+ "bugs": {
33
+ "url": "https://github.com/Rethinking-studio/doable-agent-runner/issues"
34
+ }
35
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // doable-agent-runner install --token dbl_… --cwd ~/code/app --name mac-studio
4
+ // doable-agent-runner watch run in the foreground
5
+ // doable-agent-runner status
6
+ // doable-agent-runner uninstall
7
+ //
8
+ // `install` is what the agent runs after calling setup_runner. Everything else
9
+ // is for a human who wants to see or undo what it did.
10
+
11
+ import { spawnSync } from "node:child_process";
12
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, cpSync, rmSync } from "node:fs";
13
+ import { homedir, hostname, platform } from "node:os";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import * as config from "./config.mjs";
17
+
18
+ const LABEL = "art.rethinking.doable.runner";
19
+ const HERE = fileURLToPath(new URL(".", import.meta.url));
20
+
21
+ function arg(name, fallback) {
22
+ const i = process.argv.indexOf(`--${name}`);
23
+ return i > -1 ? process.argv[i + 1] : fallback;
24
+ }
25
+
26
+ const command = process.argv[2] || "watch";
27
+
28
+ switch (command) {
29
+ case "install":
30
+ install();
31
+ break;
32
+ case "watch":
33
+ await import("./watch.mjs");
34
+ break;
35
+ case "status":
36
+ status();
37
+ break;
38
+ case "uninstall":
39
+ uninstall();
40
+ break;
41
+ default:
42
+ console.log("Usage: doable-agent-runner <install|watch|status|uninstall>");
43
+ process.exit(1);
44
+ }
45
+
46
+ function install() {
47
+ const token = arg("token") || config.load().token;
48
+ if (!token) {
49
+ console.error("A token is required. Ask your agent to call setup_runner.");
50
+ process.exit(1);
51
+ }
52
+
53
+ const settings = {
54
+ token,
55
+ base: arg("base", config.DEFAULT_BASE),
56
+ cwd: arg("cwd", process.cwd()),
57
+ name: arg("name", hostname().replace(/\.local$/, "")),
58
+ // Deliberately narrow. These sessions run with nobody watching, so the
59
+ // default is what a research or writing job needs, and anything that
60
+ // changes files is opted into rather than assumed.
61
+ tools: arg("tools", "mcp__doable__*,Read,Glob,Grep,WebFetch,WebSearch"),
62
+ exec: arg("exec", ""),
63
+ };
64
+
65
+ const path = config.save(settings);
66
+ const runtime = installRuntime();
67
+ console.log(`Settings written to ${path}`);
68
+ console.log(`Machine name: ${settings.name}`);
69
+ console.log(`Working directory: ${settings.cwd}`);
70
+ console.log(`Tools the unattended session may use: ${settings.tools}`);
71
+ console.log(
72
+ "\nThat tool list is narrow on purpose — no Write, no Edit, no Bash. Re-run with\n" +
73
+ "--tools to widen it once you are comfortable with what gets started here."
74
+ );
75
+
76
+ platform() === "darwin" ? installLaunchd(runtime) : installSystemd(runtime);
77
+ }
78
+
79
+ /// Copies the runner somewhere stable and returns the entry point to point the
80
+ /// service at. Without this the service would reference wherever the installer
81
+ /// happened to run from — under npx, a cache directory with no guarantees.
82
+ function installRuntime() {
83
+ if (HERE.startsWith(config.RUNTIME)) return join(config.RUNTIME, "cli.mjs");
84
+ rmSync(config.RUNTIME, { recursive: true, force: true });
85
+ mkdirSync(config.RUNTIME, { recursive: true });
86
+ cpSync(HERE, config.RUNTIME, { recursive: true });
87
+ return join(config.RUNTIME, "cli.mjs");
88
+ }
89
+
90
+ function installLaunchd(entry) {
91
+ const dir = join(homedir(), "Library", "LaunchAgents");
92
+ mkdirSync(dir, { recursive: true });
93
+ const plist = join(dir, `${LABEL}.plist`);
94
+ writeFileSync(
95
+ plist,
96
+ `<?xml version="1.0" encoding="UTF-8"?>
97
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
98
+ <plist version="1.0">
99
+ <dict>
100
+ <key>Label</key><string>${LABEL}</string>
101
+ <key>ProgramArguments</key>
102
+ <array>
103
+ <string>${process.execPath}</string>
104
+ <string>${entry}</string>
105
+ <string>watch</string>
106
+ </array>
107
+ <key>RunAtLoad</key><true/>
108
+ <!-- The watch dies when the lid closes; launchd brings it back on wake. -->
109
+ <key>KeepAlive</key><true/>
110
+ <key>StandardOutPath</key><string>/tmp/doable-runner.log</string>
111
+ <key>StandardErrorPath</key><string>/tmp/doable-runner.log</string>
112
+ </dict>
113
+ </plist>
114
+ `
115
+ );
116
+ // launchd tears a job down asynchronously, so bootstrapping straight after a
117
+ // bootout races it and fails. Retry rather than reporting a failure the user
118
+ // would only have to work around by hand.
119
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid()}/${LABEL}`], { stdio: "ignore" });
120
+ let r;
121
+ for (let attempt = 0; attempt < 5; attempt++) {
122
+ r = spawnSync("launchctl", ["bootstrap", `gui/${process.getuid()}`, plist], {
123
+ stdio: attempt === 4 ? "inherit" : "ignore",
124
+ });
125
+ if (r.status === 0) break;
126
+ spawnSync("sleep", ["1"]);
127
+ }
128
+ console.log(
129
+ r.status === 0
130
+ ? `\nRunning. Logs: /tmp/doable-runner.log`
131
+ : `\nWrote ${plist} but could not load it. Start it by hand:\n launchctl bootstrap gui/$(id -u) ${plist}`
132
+ );
133
+ }
134
+
135
+ function installSystemd(entry) {
136
+ const dir = join(homedir(), ".config", "systemd", "user");
137
+ mkdirSync(dir, { recursive: true });
138
+ const unit = join(dir, "doable-runner.service");
139
+ writeFileSync(
140
+ unit,
141
+ `[Unit]
142
+ Description=Doable Agent Desk runner
143
+
144
+ [Service]
145
+ ExecStart=${process.execPath} ${entry} watch
146
+ Restart=always
147
+ RestartSec=10
148
+
149
+ [Install]
150
+ WantedBy=default.target
151
+ `
152
+ );
153
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
154
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", "doable-runner"], {
155
+ stdio: "inherit",
156
+ });
157
+ console.log(
158
+ r.status === 0
159
+ ? "\nRunning. Logs: journalctl --user -u doable-runner -f"
160
+ : `\nWrote ${unit} but could not start it:\n systemctl --user enable --now doable-runner`
161
+ );
162
+ }
163
+
164
+ function status() {
165
+ const settings = config.load();
166
+ if (!settings.token) return console.log("Not set up. Ask your agent to call setup_runner.");
167
+ console.log(`Machine name : ${settings.name}`);
168
+ console.log(`Directory : ${settings.cwd}`);
169
+ console.log(`Tools : ${settings.tools}`);
170
+ console.log(`Starts : ${settings.exec || "claude (default)"}`);
171
+ if (platform() === "darwin") {
172
+ spawnSync("launchctl", ["print", `gui/${process.getuid()}/${LABEL}`], { stdio: "inherit" });
173
+ } else {
174
+ spawnSync("systemctl", ["--user", "status", "doable-runner"], { stdio: "inherit" });
175
+ }
176
+ }
177
+
178
+ function uninstall() {
179
+ if (platform() === "darwin") {
180
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid()}/${LABEL}`], { stdio: "ignore" });
181
+ const plist = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
182
+ if (existsSync(plist)) unlinkSync(plist);
183
+ } else {
184
+ spawnSync("systemctl", ["--user", "disable", "--now", "doable-runner"], { stdio: "ignore" });
185
+ }
186
+ rmSync(config.RUNTIME, { recursive: true, force: true });
187
+ console.log(
188
+ "Stopped and removed.\n" +
189
+ `Settings are still at ${config.FILE} — delete it to remove the token, and revoke\n` +
190
+ "this machine from Account & agents in the app."
191
+ );
192
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // Where the runner keeps its token and settings. Deliberately outside any repo:
2
+ // this is a credential, and it should not be somewhere a `git add -A` can reach.
3
+
4
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ export const DIR = join(homedir(), ".doable");
9
+ export const FILE = join(DIR, "runner.json");
10
+ /// Where `install` copies the runner to. The service must point at a path that
11
+ /// outlives the installer: run through npx and the code sits in a cache
12
+ /// directory that can be cleared at any time, which would leave a launchd job
13
+ /// aimed at a file that no longer exists — failing silently, which is the worst
14
+ /// way for a background process to fail.
15
+ export const RUNTIME = join(DIR, "runner");
16
+
17
+ export const DEFAULT_BASE = "https://doable.rethinking.art/api/agent";
18
+
19
+ export function load() {
20
+ try {
21
+ return JSON.parse(readFileSync(FILE, "utf8"));
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ export function save(config) {
28
+ mkdirSync(DIR, { recursive: true });
29
+ writeFileSync(FILE, JSON.stringify(config, null, 2));
30
+ // The token lives in here.
31
+ chmodSync(FILE, 0o600);
32
+ return FILE;
33
+ }
package/src/watch.mjs ADDED
@@ -0,0 +1,137 @@
1
+ //
2
+ // Watches the Agent Desk and starts a Claude Code session when work shows up.
3
+ //
4
+ // This is the piece that makes "hand it off from your phone and the machine
5
+ // starts working" true. MCP is pull-only — the desk cannot reach into a laptop
6
+ // and start anything — so something local has to be listening. This is that
7
+ // something, and it is deliberately dumb: its whole job is to notice a queued
8
+ // job and shell out. It holds no state and makes no decisions about the work.
9
+ //
10
+ // Idle cost is one parked HTTP request. Unlike a cron recipe it does not wake a
11
+ // Claude session to discover there is nothing to do.
12
+ //
13
+ // The agent it starts is yours to choose — the desk speaks MCP, so anything
14
+ // that speaks MCP can work it. `--exec` is a plain shell command; the prompt
15
+ // arrives on stdin and in $DOABLE_PROMPT, so you can shape the invocation the
16
+ // way your tool wants it.
17
+ //
18
+ // Settings come from ~/.doable/runner.json, written by `install`.
19
+
20
+ import { spawn } from "node:child_process";
21
+ import * as config from "./config.mjs";
22
+
23
+ const settings = config.load();
24
+ const BASE = settings.base || config.DEFAULT_BASE;
25
+ const token = settings.token || process.env.DOABLE_TOKEN;
26
+ const cwd = settings.cwd || process.cwd();
27
+ const tools = settings.tools || "mcp__doable__*,Read,Glob,Grep,WebFetch,WebSearch";
28
+ /// Defaults to Claude Code because that is what most people have, not because
29
+ /// the desk needs it. Anything that speaks MCP can work this queue.
30
+ const exec =
31
+ settings.exec ||
32
+ process.env.DOABLE_EXEC ||
33
+ `claude -p "$DOABLE_PROMPT" --allowedTools ${JSON.stringify(tools)}`;
34
+ const PROMPT = `Check my Doable agent desk.
35
+
36
+ Call list_jobs. If there is a queued job you can finish on this machine, claim
37
+ exactly one, do the work, and submit the result.
38
+
39
+ - Call report_progress every few minutes while working. If it ever returns
40
+ "aborted", stop immediately and submit nothing.
41
+ - Read the workstream brief before starting. It is the context the job title
42
+ leaves out.
43
+ - If a job was sent back, its feedback says what to fix. Read it.
44
+ - If nothing is queued, or nothing here is something you can do on this machine,
45
+ say so and exit without claiming anything.`;
46
+
47
+ if (!token) {
48
+ console.error("Not set up. Ask your agent to call setup_runner, then run `install`.");
49
+ process.exit(1);
50
+ }
51
+
52
+ const log = (...a) => console.log(new Date().toISOString(), ...a);
53
+
54
+ /// True while a session is running. The desk allows one job per machine anyway,
55
+ /// so starting a second session would only produce an agent that gets refused.
56
+ let busy = false;
57
+
58
+ function runSession() {
59
+ if (busy) return;
60
+ busy = true;
61
+ log("work on the desk — starting a session");
62
+ // Through a shell so `--exec` can be whatever the user's agent needs. The
63
+ // prompt is handed over twice — on stdin and in the environment — because
64
+ // CLIs disagree about which they prefer, and neither way needs quoting.
65
+ const child = spawn("/bin/sh", ["-c", exec], {
66
+ cwd,
67
+ env: { ...process.env, DOABLE_PROMPT: PROMPT },
68
+ stdio: ["pipe", "inherit", "inherit"],
69
+ });
70
+ child.stdin?.end(PROMPT);
71
+ child.on("exit", (code) => {
72
+ busy = false;
73
+ log(`session exited (${code})`);
74
+ });
75
+ child.on("error", (e) => {
76
+ busy = false;
77
+ log("could not start the agent:", e.message);
78
+ });
79
+ }
80
+
81
+ /// The OAuth handshake only tells the desk which software connected, so every
82
+ /// machine running the same tool shows up under the same name. Saying which
83
+ /// machine this is restores the point of the heartbeat.
84
+ async function announce() {
85
+ try {
86
+ const res = await fetch(`${BASE}/agent/label`, {
87
+ method: "POST",
88
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
89
+ body: JSON.stringify({ label: settings.name }),
90
+ });
91
+ if (res.ok) log(`named this machine ${(await res.json()).label}`);
92
+ } catch {
93
+ // Cosmetic. Never worth failing to start over.
94
+ }
95
+ }
96
+
97
+ let version = "";
98
+
99
+ async function watch() {
100
+ for (;;) {
101
+ try {
102
+ const res = await fetch(`${BASE}/events?v=${encodeURIComponent(version)}`, {
103
+ headers: { Authorization: `Bearer ${token}` },
104
+ });
105
+
106
+ if (res.status === 401) {
107
+ log("token rejected — reconnect the agent with `claude mcp add`");
108
+ process.exit(1);
109
+ }
110
+ if (!res.ok) {
111
+ log(`desk answered ${res.status}, retrying in 30s`);
112
+ await sleep(30_000);
113
+ continue;
114
+ }
115
+
116
+ const event = await res.json();
117
+ version = event.version;
118
+
119
+ // A timeout is the normal case: nothing moved, ask again.
120
+ if (!event.changed) continue;
121
+
122
+ const queued = event.desk?.queued ?? [];
123
+ if (queued.length) runSession();
124
+ else log("desk changed but nothing is queued");
125
+ } catch (e) {
126
+ // Sleep, laptop lid, flaky wifi — none of these deserve a crash.
127
+ log("watch failed:", e.message, "— retrying in 15s");
128
+ await sleep(15_000);
129
+ }
130
+ }
131
+ }
132
+
133
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
134
+
135
+ log(`watching ${BASE} — will run \`${exec}\` in ${cwd}`);
136
+ await announce();
137
+ watch();