@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 +21 -0
- package/README.md +102 -0
- package/package.json +35 -0
- package/src/cli.mjs +192 -0
- package/src/config.mjs +33 -0
- package/src/watch.mjs +137 -0
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();
|