directionallylocal 0.6.0 → 0.8.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/README.md CHANGED
@@ -1,59 +1,128 @@
1
1
  # Directionally Local installer
2
2
 
3
- Rent a server, paste one line, and send coding tasks to it from your laptop. Each task runs in its own [Hotcell](https://github.com/directionally-local/hotcell) sandbox, driven by [Autopilot](https://github.com/directionally-local/autopilot), and comes back as a pull request, even with your laptop closed.
3
+ Coding agents that run on your own computer or on your own server. Send a task; it runs in its own [Hotcell](https://github.com/directionally-local/hotcell) sandbox, driven by [Autopilot](https://github.com/directionally-local/autopilot), and comes back as a pull request, even with your laptop closed.
4
+
5
+ ## Start on your laptop
4
6
 
5
7
  ```sh
6
- curl -fsSL https://directionallylocal.run | sh
8
+ npm install -g directionallylocal
9
+ dl
7
10
  ```
8
11
 
9
- It asks three things:
12
+ `dl` asks where your agents should run:
13
+
14
+ - **On this computer** — start now, nothing to rent, pauses when this computer sleeps.
15
+ - **On my own server** — keeps going when your laptop is closed; needs SSH access.
16
+
17
+ For a server, `dl` lists the hosts in `~/.ssh/config` (or takes `user@host[:port]`), checks it over SSH, installs itself there, and pairs this laptop. Nothing is exposed on the server: `dl` reaches it through an SSH tunnel.
18
+
19
+ ### The keys it asks for
20
+
21
+ Two keys, kept on the server only. `dl` looks for them on your laptop first and offers each one it finds:
10
22
 
11
- 1. **Tailscale auth key.** The server joins your tailnet and serves Autopilot on its private `https://<name>.<tailnet>.ts.net` address, with no public ports. Turn on MagicDNS and HTTPS certificates for the tailnet first. Own-domain and instant-address options are coming.
12
- 2. **OpenRouter API key.** This is stored in Hotcell's keystore on the server. Sandboxes get spend-capped gateway tokens instead, and your laptop never sees the key.
13
- 3. **GitHub token,** so runs can clone, push, and open PRs. A fine-grained token with Contents and Pull requests read/write on the repositories you'll use is enough. It stays on the server; sandboxes push through Autopilot's repository-scoped proxy.
23
+ - **GitHub** — your GitHub CLI login (`gh auth token`), offered as *"Use your GitHub CLI login (you)? It stays on your server."* Otherwise it asks for a fine-grained token with Contents and Pull requests read/write.
24
+ - **OpenRouter** — the key in `OPENROUTER_API_KEY`, or the one Autopilot already has, offered the same way. Otherwise it asks for one.
14
25
 
15
- It ends with:
26
+ The OpenRouter key goes into Hotcell's keystore on the server; sandboxes get spend-capped gateway tokens instead, and your laptop never sees it. The GitHub token stays on the server too; sandboxes push through Autopilot's repository-scoped proxy.
27
+
28
+ ### The model
29
+
30
+ Setup asks once which model tasks and sessions use. The default is `deepseek/deepseek-v4.1-flash-1017`; if Autopilot already has a default model, that one is preselected instead. Change it any time with `dl model`, or per run with `-m`.
31
+
32
+ ### It ends with
16
33
 
17
34
  ```
18
- ✓ Ready. Fits ~6 agents at once (estimate).
19
- Open once to approve laptops in your browser: https://box.tail1234.ts.net/api/v1/auth/claim?token=…
20
- On your laptop (signed in to the same tailnet): autopilot remote add https://box.tail1234.ts.net
35
+ ✓ Ready · fits ~6 agents at once (estimate)
36
+ 1 Start a session: cd into a GitHub repository, then type
37
+ dl
38
+ 2 Or hand off a one-off task
39
+ dl run "fix the flaky checkout test"
40
+
41
+ dl status shows where agents run and what they're doing.
21
42
  ```
22
43
 
23
- Then, on your laptop:
44
+ Then, from inside a GitHub repository:
24
45
 
25
46
  ```sh
26
- npm install -g directionallylocal
27
- dl connect https://box.tail1234.ts.net # approve the code in the browser you opened the link in
28
- dl run "fix the flaky checkout test" # from inside a GitHub repository; local changes go along
29
- dl runs # tasks and their pull requests
30
- dl pull <id> # check one out here
47
+ dl # talk to the repository's agent; it keeps running when you leave
48
+ dl run "fix the flaky checkout test" # one-off task; your uncommitted changes go along
49
+ dl runs # tasks and their pull requests
50
+ dl pull <id> # check one out here
31
51
  ```
32
52
 
33
- Typing `dl` on its own explains what it is and what's going on, on the server and on the laptop.
53
+ `dl status` says when a server is behind npm, once a day: `! box is behind: Autopilot 2.7.0 → 2.7.2 dl update box`.
34
54
 
35
- The "fits ~N agents" figure is a conservative estimate from memory and CPUs (1 GB per sandbox). Measured numbers will replace it.
55
+ The "fits ~N agents" figure is a conservative estimate: memory left after the host's own share (at least 1.5 GB or 15%), divided by 1 GB per sandbox, capped at two per CPU. Measured numbers will replace it.
36
56
 
37
57
  ## Commands
38
58
 
39
- Run these as root on the server. `dl` is short for `directionallylocal`; both work.
59
+ `dl` is short for `directionallylocal`; both work.
60
+
61
+ ### On your laptop
62
+
63
+ | Command | What it does |
64
+ | --- | --- |
65
+ | `dl` | Set up the first time; afterwards, an overview. Inside a GitHub repository, that repository's agent session. |
66
+ | `dl setup` | Set up or repair where your agents run. Safe to re-run. `--identity FILE` names the SSH key to use. |
67
+ | `dl new` | In a GitHub repository: start a fresh conversation for it. |
68
+ | `dl status` | Where your agents run and what they're doing. Works anywhere. |
69
+ | `dl connect URL` | Connect to a server someone else set up; approve the code it shows. |
70
+ | `dl run "TASK"` | Run a task, from inside a GitHub repository. Ctrl-C stops watching, not the task. |
71
+ | `dl runs` | Your tasks and their pull requests. `--json` for scripts. |
72
+ | `dl watch ID` / `dl cancel ID` | Follow a task, or stop one. |
73
+ | `dl pull ID` | Check out a task's branch here. |
74
+ | `dl preview ID` | Deploy a task's Workers branch on the server with Serverless Objects and open it here, over the SSH tunnel. |
75
+ | `dl servers` | Where agents run. `dl servers rename OLD NEW`, `dl servers remove NAME`. |
76
+ | `dl use [NAME]` | Where tasks go without `--server`. With no name, shows it. |
77
+ | `dl keys [openrouter\|github]` | Which keys are set, or replace one. `--server NAME` picks the server. |
78
+ | `dl model [ID]` | The model for tasks and sessions. With no ID, picks from a list. |
79
+ | `dl update [NAME]` | Update a server's Autopilot, Hotcell and sandbox image over SSH. `--wait` waits for running tasks instead of asking; with no terminal it waits anyway. |
80
+ | `dl local status` | Agents running on this computer: running?, where, how many fit. |
81
+ | `dl local uninstall [--purge]` | Stop and remove them. `--purge` also deletes keys and data. |
82
+
83
+ Run options: `--server NAME`, `-m/--model MODEL`, `--repo OWNER/NAME`, `--detach`.
84
+
85
+ ### On the server
86
+
87
+ Run these as root. `dl` on its own shows what this is, whether it's healthy, the connected laptops, and the runs.
40
88
 
41
89
  | Command | What it does |
42
90
  | --- | --- |
43
91
  | `dl up` | Install or repair everything. Safe to re-run: it keeps your keys and generated secrets and skips work that's already done. |
44
92
  | `dl up --dry-run` | Print what `up` would change, without changing anything. |
93
+ | `dl connect-laptop` | How to connect a laptop, and a link to approve it in a browser. `dl connect-laptop CODE` approves the code a laptop shows. |
94
+ | `dl laptops` | Connected laptops. `dl laptops remove NAME` disconnects one. |
95
+ | `dl runs` | Tasks running or done, with their pull requests. |
96
+ | `dl logs [hotcell\|autopilot] [-f]` | The services' logs. |
97
+ | `dl preview ID [--remove]` | Deploy a task's branch as a preview, or stop it. Needs `DL_PREVIEWS_BUCKET`. |
98
+ | `dl previews` | Live previews: task, port, branch. |
45
99
  | `dl doctor` | Check every part, with a fix for each failure. |
100
+ | `dl keys [set openrouter\|github]` | Which keys are set, or replace one (hidden input). |
46
101
  | `dl status` | Show the installed versions and where to reach the server. |
47
102
  | `dl uninstall` | Remove the services and software but keep configuration and data. Add `--purge` to delete those too, plus the service accounts and the sandbox image. |
48
103
 
49
- For unattended installs, set the answers as environment variables: `DL_TAILSCALE_AUTH_KEY`, `DL_OPENROUTER_API_KEY` and `DL_GITHUB_TOKEN`. `DL_ACCESS=loopback` skips Tailscale; you then reach the server over an SSH tunnel.
104
+ ### Unattended installs
105
+
106
+ Set the answers as environment variables: `DL_TAILSCALE_AUTH_KEY`, `DL_OPENROUTER_API_KEY` and `DL_GITHUB_TOKEN`. `DL_ACCESS` picks how the server is reached: `tailscale` (the default), `loopback` (this machine only), or `ssh` (what the laptop's setup uses). `DL_SKIP_CREDENTIAL_CHECK=1` skips the online key checks for offline installs.
50
107
 
51
- ## What it sets up
108
+ The laptop's setup drives the server over SSH with `dl up --access ssh --answers-stdin --events json`, which takes the keys as one JSON object on stdin and reports progress as JSON lines. `dl up --needs` reports which keys it would still ask for, without changing anything.
109
+
110
+ ## The one-line server install
111
+
112
+ The curl path is still there for a server you set up by hand, as root on Ubuntu or Debian:
113
+
114
+ ```sh
115
+ curl -fsSL https://directionallylocal.run | sh
116
+ ```
117
+
118
+ It installs Node.js if needed, installs `directionallylocal`, and runs `dl up`. The laptop's setup uses the same script to bootstrap a server over SSH.
119
+
120
+ ## What it sets up on a server
52
121
 
53
122
  - **Node.js 24 LTS,** if Node is missing or lacks the fix for CVE-2026-111497 (22.23.4+, 24.22.1+ and 26.12.1+ are accepted). Node security releases from NodeSource are added to Ubuntu's automatic updates, which otherwise skip them. Delete `/etc/apt/apt.conf.d/52directionallylocal-nodejs` to opt out.
54
123
  - **Docker,** if it's missing, plus a rule that keeps sandboxes away from the cloud metadata endpoint. Docker isn't updated automatically, because its repo ships major upgrades and restarting the Docker daemon can stop running sandboxes.
55
124
  - **Hotcell and Autopilot from npm,** under `/opt/directionallylocal/releases/<versions>`, with `current` pointing at the active release.
56
- - **Two systemd services, each under its own unprivileged account:** `hotcell.service` (sandboxes and the credential gateway, on loopback) and `autopilot.service` (the API that laptops pair with, on `127.0.0.1:4096`). Only Tailscale Serve exposes it.
125
+ - **Three systemd units:** `hotcell.service` (sandboxes and the credential gateway, on loopback) and `autopilot.service` (the API that laptops pair with, on `127.0.0.1:4096`), each under its own unprivileged account, plus `directionallylocal-metadata-block.service`, the firewall rule for sandboxes. Only Tailscale Serve exposes the API, and only when you use Tailscale access.
57
126
  - **A sandbox image** with Node, git, Python, and the Autopilot runtime baked in.
58
127
  - **Configuration** in `/etc/hotcell`, `/etc/autopilot` and `/etc/directionallylocal`, readable only by root and the matching service account. Data lives in `/var/lib/hotcell` and `/var/lib/autopilot`.
59
128
 
@@ -63,6 +132,8 @@ Supported: Ubuntu 24.04 and 22.04 and Debian 12, on x86_64 or arm64, with at lea
63
132
 
64
133
  `site/` is the Cloudflare Worker for `https://directionallylocal.run`. It serves `install.sh` to `curl` at the bare domain (and `/install.sh`), sends browsers to directionallylocal.com, and serves nothing else. Subdomains of `directionallylocal.run` are users' instant addresses, and the domain is on the Public Suffix List, so the Worker never sets cookies. Deploy it with `npx wrangler deploy -c site/wrangler.toml`.
65
134
 
135
+ `site/wrangler.jsonc` is the portable config that celld and Serverless Objects read; `site/wrangler.toml` keeps the Cloudflare-only `routes` and `workers_dev`. The install script is checked in as `site/install-script.ts` so the Worker has no non-JavaScript imports; regenerate it with `npm run sync:site-script` after editing `install.sh`. `sobjects build --strict site` checks that the Worker stays portable. The design for the name service and approvals page is in [`docs/plans/portable-services.md`](docs/plans/portable-services.md).
136
+
66
137
  ## Development
67
138
 
68
139
  ```sh
@@ -70,6 +141,7 @@ npm install
70
141
  npm test # node:test through tsx; a simulated server exercises up, doctor, and uninstall
71
142
  npm run typecheck
72
143
  npm run build
144
+ npm run sync:site-script # after editing install.sh
73
145
  ```
74
146
 
75
147
  ## License
package/dist/cli.js CHANGED
@@ -9,7 +9,10 @@ import { loadRemotes } from "./laptop/config.js";
9
9
  import { keys } from "./laptop/keys.js";
10
10
  import { model } from "./laptop/settings.js";
11
11
  import { projectSession } from "./laptop/session.js";
12
+ import { preview as laptopPreview } from "./laptop/preview.js";
12
13
  import { setup } from "./laptop/setup.js";
14
+ import { realSshExec } from "./laptop/ssh.js";
15
+ import { update } from "./laptop/update.js";
13
16
  import { localCommand } from "./local/cli.js";
14
17
  import { setUpOnThisComputer } from "./local/setup.js";
15
18
  import { TtyPrompter } from "./prompt.js";
@@ -17,6 +20,7 @@ import { readState } from "./state.js";
17
20
  import { DryRunSystem, RealSystem } from "./system.js";
18
21
  import { uninstall } from "./uninstall.js";
19
22
  import { JsonUi, PlainUi, TerminalUi, palette } from "./ui.js";
23
+ import { previewCommand, previewsCommand } from "./preview.js";
20
24
  import { needs, parseAccessMode, parseProvidedAnswers, up } from "./up.js";
21
25
  const HELP = `Directionally Local: coding agents that run on this computer or on your own server.
22
26
  Send a task; it runs in its own sandbox and comes back as a pull request.
@@ -34,10 +38,13 @@ On your laptop:
34
38
  dl runs your tasks and their pull requests
35
39
  dl watch ID follow a task dl cancel ID stop one
36
40
  dl pull ID check out a task's branch here
41
+ dl preview ID deploy a task's Workers branch on the server and open it here
37
42
  dl servers where agents run (dl servers remove NAME · dl servers rename OLD NEW)
38
43
  dl use [NAME] where tasks go without --server (no NAME: show it)
39
44
  dl keys [openrouter|github] which keys are set, or replace one (--server NAME)
40
45
  dl model [ID] the model for tasks and sessions (no ID: pick from a list)
46
+ dl update [NAME] update a server's Autopilot, Hotcell and sandbox image
47
+ (--wait: wait for running tasks instead of asking)
41
48
  run options: --server NAME, -m/--model MODEL, --repo OWNER/NAME, --detach
42
49
  dl local status agents running on this computer: running?, where, how many fit
43
50
  dl local uninstall [--purge] stop and remove them (--purge also deletes keys and data)
@@ -49,6 +56,8 @@ On the server:
49
56
  dl connect-laptop CODE approve the code a laptop shows
50
57
  dl laptops connected laptops (dl laptops remove NAME to disconnect one)
51
58
  dl runs tasks running or done, with their pull requests
59
+ dl preview ID [--remove] deploy a task's branch with Serverless Objects, or stop it
60
+ dl previews live previews: task, port, branch
52
61
  dl logs [hotcell|autopilot] [-f] the services' logs
53
62
  dl doctor check every part, with a fix for each problem
54
63
  dl keys [set openrouter|github] which keys are set, or replace one (hidden input)
@@ -106,6 +115,7 @@ export async function main(argv, io = {
106
115
  needs: { type: "boolean" },
107
116
  identity: { type: "string" },
108
117
  stdin: { type: "boolean" },
118
+ wait: { type: "boolean" },
109
119
  },
110
120
  });
111
121
  }
@@ -220,7 +230,7 @@ export async function main(argv, io = {
220
230
  }
221
231
  // A laptop connected to servers gets its own home (`dl status` shows it from anywhere);
222
232
  // otherwise, what this is and how to start.
223
- if ((!command || command === "status") && await laptopHome(laptopIO))
233
+ if ((!command || command === "status") && await laptopHome(laptopIO, undefined, undefined, { ssh: realSshExec }))
224
234
  return 0;
225
235
  // A laptop with nothing set up yet: the wizard (it needs someone to answer it).
226
236
  if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
@@ -242,6 +252,15 @@ export async function main(argv, io = {
242
252
  }
243
253
  case "setup":
244
254
  return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input), ...(values.identity ? { identityFile: values.identity } : {}) });
255
+ case "update":
256
+ return await update(parsed.positionals.slice(1), {
257
+ ssh: realSshExec,
258
+ prompter: new TtyPrompter(),
259
+ newUi: () => terminal ? new TerminalUi(io.out, undefined, colorOn) : new PlainUi(io.out),
260
+ io: laptopIO,
261
+ interactive: terminal,
262
+ wait: Boolean(values.wait),
263
+ });
245
264
  case "connect": {
246
265
  const url = parsed.positionals[1];
247
266
  if (!url) {
@@ -291,6 +310,34 @@ export async function main(argv, io = {
291
310
  io.err(` dl new starts a fresh conversation from inside a GitHub repository (with something set up: dl setup).\n`);
292
311
  return 1;
293
312
  }
313
+ case "preview": {
314
+ const id = parsed.positionals[1];
315
+ if (!id) {
316
+ io.err(` Usage: dl preview ID ${colors.dim("(from dl runs)")}\n`);
317
+ return 2;
318
+ }
319
+ // On a server, deploy it; on a laptop, ask the server to and tunnel to the result.
320
+ if (await readState(system)) {
321
+ if (!isRoot()) {
322
+ io.err(` Run it as root: sudo dl preview ${id}\n`);
323
+ return 1;
324
+ }
325
+ return await previewCommand(system, parsed.positionals.slice(1), { json: Boolean(values.json) }, io.out, colors);
326
+ }
327
+ return await laptopPreview({ id, ...(values.server ? { server: values.server } : {}) }, {}, laptopIO);
328
+ }
329
+ case "previews": {
330
+ const state = await readState(system);
331
+ if (!state) {
332
+ io.out(NOT_A_SERVER(colors));
333
+ return 1;
334
+ }
335
+ if (!isRoot()) {
336
+ io.err(" Run it as root: sudo dl previews\n");
337
+ return 1;
338
+ }
339
+ return await previewsCommand(system, io.out, colors);
340
+ }
294
341
  case "servers":
295
342
  return await servers(parsed.positionals.slice(1), laptopIO);
296
343
  case "use":
package/dist/home.js CHANGED
@@ -3,6 +3,7 @@ import { AUTOPILOT_PORT, CELL_MEMORY_MB, SERVICES } from "./layout.js";
3
3
  import { approvePairing, createLaptopCredential, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
4
4
  import { describeAccess, readState } from "./state.js";
5
5
  import { palette } from "./ui.js";
6
+ import { readPreviews } from "./preview.js";
6
7
  import { nextSteps } from "./up.js";
7
8
  const ABOUT = "Background coding agents on your own server: send a task from your laptop, get a pull request back.";
8
9
  /** Quick health for the home screen and the login line: services up and the API answering. */
@@ -21,8 +22,8 @@ async function health(system) {
21
22
  }
22
23
  /** `dl` on a server: what this is, whether it's healthy, who's connected, what's running, what to do. */
23
24
  export async function serverHome(system, state, write, c) {
24
- const [status, laptops, runs, pending, facts] = await Promise.all([
25
- health(system), listLaptops(system), listServerRuns(system), pendingPairings(system), detectHost(system),
25
+ const [status, laptops, runs, pending, facts, previews] = await Promise.all([
26
+ health(system), listLaptops(system), listServerRuns(system), pendingPairings(system), detectHost(system), readPreviews(system),
26
27
  ]);
27
28
  const verdict = status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`);
28
29
  const week = Date.now() - 7 * 24 * 60 * 60 * 1000;
@@ -33,6 +34,9 @@ export async function serverHome(system, state, write, c) {
33
34
  write(row("Laptops", laptops === undefined ? c.dim("unknown (run dl up to update)") : laptops.length === 0 ? "none connected yet" : `${laptops.length} connected · ${laptops.map((laptop) => laptop.name).join(", ")}`, "dl laptops"));
34
35
  write(row("Runs", runs === undefined ? c.dim("unknown (run dl up to update)") : `${running} running · ${doneThisWeek} this week`, "dl runs"));
35
36
  write(row("Capacity", `~${agentCapacity(facts, CELL_MEMORY_MB)} agents at once (estimate)`));
37
+ if (previews.previews.length > 0) {
38
+ write(row("Previews", `${previews.previews.length} live · ${previews.previews.map((preview) => preview.id).join(", ")}`, "dl previews"));
39
+ }
36
40
  if (pending.length > 0) {
37
41
  write(`\n ${c.yellow("!")} Waiting for approval: ${pending.map((entry) => `${c.bold(entry.code)} (${entry.name})`).join(", ")} ${c.dim("dl connect-laptop <code>")}\n`);
38
42
  }
@@ -77,6 +77,25 @@ export class RemoteClient {
77
77
  cancel(conversationId) {
78
78
  return this.request("POST", `/conversations/${encodeURIComponent(conversationId)}/cancel`);
79
79
  }
80
+ /**
81
+ * The newest event of a finished conversation: one request, then the stream is dropped.
82
+ * `dl runs` uses it to say why a task failed without replaying the whole run.
83
+ */
84
+ async lastEvent(conversationId, lastEventId) {
85
+ const controller = new AbortController();
86
+ try {
87
+ for await (const event of this.events(conversationId, Math.max(0, lastEventId - 1), controller.signal)) {
88
+ return event;
89
+ }
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ finally {
95
+ controller.abort();
96
+ }
97
+ return undefined;
98
+ }
80
99
  /**
81
100
  * Events after `cursor`, reconnecting with the last seen ID when the stream drops,
82
101
  * until the caller stops iterating or the signal aborts.
@@ -12,6 +12,7 @@ import { DEFAULT_MODEL, laptopModel } from "./settings.js";
12
12
  import { sshCommandLine } from "./ssh.js";
13
13
  import { openBrowser } from "./open-browser.js";
14
14
  import { withConnections } from "./tunnel.js";
15
+ import { outdatedServers } from "./update.js";
15
16
  export { DEFAULT_MODEL, readAutopilotModel } from "./settings.js";
16
17
  /**
17
18
  * `dl` on a laptop: send tasks to where agents run (this computer, or your own server),
@@ -129,12 +130,16 @@ async function listRuns(remotes, connections, json, io) {
129
130
  io.out(`\n No tasks yet. From inside a GitHub repository: ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
130
131
  return;
131
132
  }
133
+ const reasons = await failureReasons(rows, remotes, connections);
132
134
  io.out("\n");
133
135
  for (const entry of rows) {
134
136
  const running = entry.activeRunId !== null;
135
137
  const glyph = running ? c.cyan("◐") : entry.status === "completed" || entry.status === "merged" ? c.green("✓") : ["failed", "interrupted"].includes(entry.status) ? c.red("✗") : c.dim("○");
136
138
  const multi = remotes.length > 1 ? `${entry.server} ` : "";
137
139
  io.out(` ${glyph} ${entry.conversationId.slice(0, 8)} ${multi}${(running ? "running" : entry.status).padEnd(11)} ${(entry.repository ?? "").padEnd(26)} ${c.dim(entry.pullRequestURL ?? entry.branch ?? "")}\n`);
140
+ const reason = reasons.get(entry.conversationId);
141
+ if (reason)
142
+ io.out(` ${c.dim(oneLine(reason, 100))}\n`);
138
143
  }
139
144
  io.out(`\n ${c.dim("Follow one:")} dl watch <id> ${c.dim("Check one out:")} dl pull <id>\n\n`);
140
145
  }
@@ -262,7 +267,7 @@ export async function renameServer(args, io = processIO, path) {
262
267
  return 0;
263
268
  }
264
269
  /** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
265
- export async function laptopHome(io = processIO, open, model = laptopModel) {
270
+ export async function laptopHome(io = processIO, open, model = laptopModel, versions) {
266
271
  const c = palette(io.color === true);
267
272
  const remotes = await loadRemotes();
268
273
  if (remotes.length === 0)
@@ -276,12 +281,19 @@ export async function laptopHome(io = processIO, open, model = laptopModel) {
276
281
  total += list?.length ?? 0;
277
282
  }
278
283
  });
279
- const [current, chosen] = await Promise.all([defaultName(), model()]);
284
+ const [current, chosen, outdated] = await Promise.all([
285
+ defaultName(),
286
+ model(),
287
+ versions ? outdatedServers(remotes, versions).catch(() => []) : Promise.resolve([]),
288
+ ]);
280
289
  io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
281
290
  io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
282
291
  io.out(` ${c.dim("Runs on".padEnd(11))}${remotes.map((remote) => `${mark(remote, current, c)} ${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim(remotes.length > 1 ? "dl servers · dl use NAME" : "dl servers")}\n`);
283
292
  io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n`);
284
- io.out(` ${c.dim("Model".padEnd(11))}${chosen ?? DEFAULT_MODEL} ${c.dim("dl model")}\n\n`);
293
+ io.out(` ${c.dim("Model".padEnd(11))}${chosen ?? DEFAULT_MODEL} ${c.dim("dl model")}\n`);
294
+ for (const entry of outdated)
295
+ io.out(` ${c.yellow("!")} ${entry.name} is behind: ${entry.notice} ${c.dim(`dl update ${entry.name}`)}\n`);
296
+ io.out("\n");
285
297
  io.out(` ${c.dim("Start a session: cd into a GitHub repository, then type")} ${c.bold("dl")}\n`);
286
298
  io.out(` ${c.dim("Or hand off a one-off task:")} ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
287
299
  return true;
@@ -293,12 +305,15 @@ async function follow(client, remote, conversationId, cursor, signal, io, replay
293
305
  signal?.addEventListener("abort", stop, { once: true });
294
306
  const renderer = new EventRenderer(io, c);
295
307
  let outcome;
308
+ let finalEvent;
296
309
  try {
297
310
  if (replayUntil === undefined || replayUntil > cursor) {
298
311
  for await (const event of client.events(conversationId, cursor, controller.signal)) {
299
312
  renderer.render(event);
300
- if (RUN_END_EVENTS.has(event.type))
313
+ if (RUN_END_EVENTS.has(event.type)) {
301
314
  outcome = event.type;
315
+ finalEvent = event;
316
+ }
302
317
  if (replayUntil === undefined ? RUN_END_EVENTS.has(event.type) : event.id >= replayUntil)
303
318
  break;
304
319
  }
@@ -325,9 +340,18 @@ async function follow(client, remote, conversationId, cursor, signal, io, replay
325
340
  return 0;
326
341
  }
327
342
  io.err(`\n ${c.red("✗")} ${id} ${outcome}\n`);
343
+ const reason = failureReason(finalEvent);
344
+ if (reason)
345
+ for (const line of wrap(reason, 100, 3))
346
+ io.err(` ${c.dim(line)}\n`);
328
347
  if (renderer.unpriced || isModelNotPriced(JSON.stringify(final))) {
329
348
  io.err(` This model isn't in your server's price list yet. Run ${c.bold("dl setup")} to refresh it, or pick another model with -m.\n`);
330
349
  }
350
+ else {
351
+ const hint = reason ? failureHint(reason) : undefined;
352
+ if (hint)
353
+ io.err(` ${c.dim(hint)}\n`);
354
+ }
331
355
  io.err("\n");
332
356
  return 1;
333
357
  }
@@ -439,6 +463,78 @@ function oneLine(text, max) {
439
463
  const flat = text.replace(/\s+/g, " ").trim();
440
464
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
441
465
  }
466
+ /** The reason a run ended, from its final event. Servers have used `reason`, `error` and `message`. */
467
+ export function failureReason(event) {
468
+ if (!event)
469
+ return undefined;
470
+ for (const key of ["reason", "error", "message", "detail"]) {
471
+ const value = event.data[key];
472
+ if (typeof value === "string" && value.trim())
473
+ return value.trim();
474
+ }
475
+ return undefined;
476
+ }
477
+ /** A next step for the reasons we recognise, or undefined. */
478
+ export function failureHint(reason) {
479
+ const text = reason.toLowerCase();
480
+ if (/context window|token limit|too many tokens|maximum context/.test(text))
481
+ return "pick a model with a bigger window: dl model";
482
+ if (/not priced|no such model|model not found|not offered|unknown model/.test(text))
483
+ return "pick another model: dl model";
484
+ if (/rate limit|429|quota/.test(text))
485
+ return "wait a moment, or check the OpenRouter key: dl keys openrouter";
486
+ if (/git_push_failed|push failed|permission denied|bad credentials|401/.test(text))
487
+ return "check the GitHub token: dl keys github";
488
+ if (/out of memory|oom|killed/.test(text))
489
+ return "the sandbox ran out of memory; try a smaller task";
490
+ return undefined;
491
+ }
492
+ /** `text` broken into at most `maxLines` lines of `width`, with an ellipsis when it was cut. */
493
+ export function wrap(text, width, maxLines) {
494
+ const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
495
+ const lines = [];
496
+ let line = "";
497
+ for (const word of words) {
498
+ if (line && line.length + 1 + word.length > width) {
499
+ lines.push(line);
500
+ line = word;
501
+ }
502
+ else {
503
+ line = line ? `${line} ${word}` : word;
504
+ }
505
+ }
506
+ if (line)
507
+ lines.push(line);
508
+ if (lines.length <= maxLines)
509
+ return lines;
510
+ const kept = lines.slice(0, maxLines);
511
+ kept[maxLines - 1] = `${kept[maxLines - 1].slice(0, width - 1)}…`;
512
+ return kept;
513
+ }
514
+ /** How many failed tasks `dl runs` asks about; each one is a request. */
515
+ const REASON_LOOKUPS = 10;
516
+ /** One line per failed task, from its final event. */
517
+ async function failureReasons(rows, remotes, connections) {
518
+ const reasons = new Map();
519
+ let lookups = 0;
520
+ for (const entry of rows) {
521
+ if (lookups >= REASON_LOOKUPS)
522
+ break;
523
+ if (entry.activeRunId !== null || !["failed", "interrupted"].includes(entry.status))
524
+ continue;
525
+ const remote = remotes.find((candidate) => candidate.name === entry.server);
526
+ if (!remote)
527
+ continue;
528
+ lookups++;
529
+ const event = await connections.client(remote)
530
+ .then((client) => client.lastEvent(entry.conversationId, entry.lastEventId))
531
+ .catch(() => undefined);
532
+ const reason = failureReason(event);
533
+ if (reason)
534
+ reasons.set(entry.conversationId, reason);
535
+ }
536
+ return reasons;
537
+ }
442
538
  export function describeError(error) {
443
539
  if (error instanceof RemoteApiError)
444
540
  return `${error.message} (${error.code})`;
@@ -0,0 +1,60 @@
1
+ import { palette } from "../ui.js";
2
+ import { resolveRemote } from "./config.js";
3
+ import { openBrowser } from "./open-browser.js";
4
+ import { checkServer, realSshExec, shellQuote } from "./ssh.js";
5
+ import { createForwarder } from "./tunnel.js";
6
+ /** Non-root users run the server commands with sudo; checkServer made sure it needs no password. */
7
+ function sudo(facts) {
8
+ return facts.root ? "" : "sudo -n ";
9
+ }
10
+ export async function preview(input, deps, io) {
11
+ const c = palette(io.color === true);
12
+ const remote = await resolveRemote(input.server);
13
+ if (!remote.ssh) {
14
+ io.err(`\n ${c.red("✗")} ${remote.name} wasn't set up over SSH, so this laptop can't reach a preview on it.\n On the server: ${c.bold(`dl preview ${input.id}`)}\n\n`);
15
+ return 1;
16
+ }
17
+ const ssh = deps.ssh ?? realSshExec;
18
+ const facts = await checkServer(remote.ssh, ssh, remote.name);
19
+ io.err(` ${c.cyan("·")} Deploying ${input.id} on ${remote.name}…\n`);
20
+ const result = await ssh(remote.ssh, `${sudo(facts)}dl preview ${shellQuote(input.id)} --json`);
21
+ const answer = lastJson(result.stdout);
22
+ if (result.code !== 0 || typeof answer?.port !== "number") {
23
+ const detail = lastLine(result.stderr) || lastLine(result.stdout) || `dl preview exited with ${result.code}`;
24
+ io.err(`\n ${c.red("✗")} ${detail}\n\n`);
25
+ return 1;
26
+ }
27
+ const forward = deps.forward ?? createForwarder();
28
+ const endpoint = await forward(remote.ssh, answer.port, remote.name);
29
+ const id = typeof answer.id === "string" ? answer.id : input.id;
30
+ io.out(`\n ${c.green("✓")} ${id} is live at ${c.bold(endpoint.url)}\n`);
31
+ io.out(` ${c.dim("Ctrl-C closes the tunnel; the preview keeps running (dl preview " + id + " --remove stops it).")}\n\n`);
32
+ if ((deps.openBrowser ?? openBrowser)(endpoint.url))
33
+ io.out(` ${c.dim("(opened it for you)")}\n\n`);
34
+ try {
35
+ await (deps.hold ?? holdUntilInterrupted)();
36
+ }
37
+ finally {
38
+ await endpoint.close();
39
+ }
40
+ return 0;
41
+ }
42
+ function holdUntilInterrupted() {
43
+ return new Promise((resolve) => {
44
+ process.once("SIGINT", () => resolve());
45
+ });
46
+ }
47
+ function lastJson(text) {
48
+ for (const line of text.trim().split("\n").reverse()) {
49
+ try {
50
+ return JSON.parse(line);
51
+ }
52
+ catch {
53
+ // keep looking: a banner or warning may come before the JSON
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ function lastLine(text) {
59
+ return text.trim().split("\n").at(-1)?.trim() ?? "";
60
+ }
@@ -7,17 +7,13 @@ const realSpawn = (args) => spawn("ssh", args, { stdio: ["ignore", "ignore", "pi
7
7
  export function tunnelArgs(access, localPort, remotePort) {
8
8
  return ["-N", "-o", "ExitOnForwardFailure=yes", ...sshOptions(access), "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`, access.target];
9
9
  }
10
- export function createOpener(options = {}) {
10
+ export function createForwarder(options = {}) {
11
11
  const spawnTunnel = options.spawn ?? realSpawn;
12
12
  const freePort = options.freePort ?? pickFreePort;
13
13
  const accepts = options.accepts ?? acceptsConnections;
14
14
  const timeoutMs = options.timeoutMs ?? 20_000;
15
15
  const pollMs = options.pollMs ?? 100;
16
- return async (remote) => {
17
- if (!remote.ssh)
18
- return { url: remote.url, close: async () => { } };
19
- const access = remote.ssh;
20
- const remotePort = Number(new URL(remote.url).port || 80);
16
+ return async (access, remotePort, display = access.target) => {
21
17
  const port = await freePort();
22
18
  const child = spawnTunnel(tunnelArgs(access, port, remotePort));
23
19
  let stderr = "";
@@ -44,18 +40,26 @@ export function createOpener(options = {}) {
44
40
  if (exited) {
45
41
  await close();
46
42
  const detail = stderr.trim().split("\n").at(-1)?.trim();
47
- throw new Error(`Couldn't open an SSH tunnel to ${remote.name} (${access.target})${detail ? `: ${detail}` : ""}\n Check that this works: ${sshCommandLine(access)}`);
43
+ throw new Error(`Couldn't open an SSH tunnel to ${display} (${access.target})${detail ? `: ${detail}` : ""}\n Check that this works: ${sshCommandLine(access)}`);
48
44
  }
49
45
  if (await accepts(port))
50
46
  return { url: `http://127.0.0.1:${port}`, close };
51
47
  if (Date.now() >= deadline) {
52
48
  await close();
53
- throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s opening an SSH tunnel to ${remote.name} (${access.target}).\n Check that this works: ${sshCommandLine(access)}`);
49
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s opening an SSH tunnel to ${display} (${access.target}).\n Check that this works: ${sshCommandLine(access)}`);
54
50
  }
55
51
  await new Promise((resolve) => setTimeout(resolve, pollMs));
56
52
  }
57
53
  };
58
54
  }
55
+ export function createOpener(options = {}) {
56
+ const forward = createForwarder(options);
57
+ return async (remote) => {
58
+ if (!remote.ssh)
59
+ return { url: remote.url, close: async () => { } };
60
+ return forward(remote.ssh, Number(new URL(remote.url).port || 80), remote.name);
61
+ };
62
+ }
59
63
  export const openRemote = createOpener();
60
64
  /**
61
65
  * For an interactive session: ssh in its own process group, so Ctrl-C typed into the agent's
@@ -0,0 +1,206 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { palette } from "../ui.js";
7
+ import { resolveRemote } from "./config.js";
8
+ import { EventReplayer } from "./remote-install.js";
9
+ import { checkServer } from "./ssh.js";
10
+ import { withConnections } from "./tunnel.js";
11
+ const execFileAsync = promisify(execFile);
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+ const STATE_FILE = "/etc/directionallylocal/state.json";
14
+ /** Non-root users run the server commands with sudo; checkServer made sure it needs no password. */
15
+ function sudo(facts) {
16
+ return facts.root ? "" : "sudo -n ";
17
+ }
18
+ /** The versions a server has installed, from the state file `dl up` writes (world-readable). */
19
+ export async function serverVersions(access, ssh) {
20
+ const result = await ssh(access, `cat ${STATE_FILE}`);
21
+ if (result.code !== 0)
22
+ return undefined;
23
+ try {
24
+ const state = JSON.parse(result.stdout);
25
+ return typeof state.autopilotVersion === "string" && typeof state.hotcellVersion === "string"
26
+ ? { autopilot: state.autopilotVersion, hotcell: state.hotcellVersion }
27
+ : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ /** `1.2.10` is newer than `1.2.9`; a pre-release suffix is ignored, since npm's `latest` has none. */
34
+ export function compareVersions(a, b) {
35
+ const parse = (version) => version.replace(/^v/, "").split(/[.+-]/).slice(0, 3).map((part) => Number(part) || 0);
36
+ const left = parse(a);
37
+ const right = parse(b);
38
+ for (let index = 0; index < 3; index++) {
39
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
40
+ if (difference !== 0)
41
+ return difference < 0 ? -1 : 1;
42
+ }
43
+ return 0;
44
+ }
45
+ /** "Autopilot 2.7.0 → 2.7.2 · Hotcell 1.4.0 → 1.4.1", or undefined when the server is current. */
46
+ export function behindNotice(server, latest) {
47
+ const parts = [];
48
+ if (compareVersions(server.autopilot, latest.autopilot) < 0)
49
+ parts.push(`Autopilot ${server.autopilot} → ${latest.autopilot}`);
50
+ if (compareVersions(server.hotcell, latest.hotcell) < 0)
51
+ parts.push(`Hotcell ${server.hotcell} → ${latest.hotcell}`);
52
+ return parts.length > 0 ? parts.join(" · ") : undefined;
53
+ }
54
+ export function versionsCachePath(env = process.env) {
55
+ const cacheHome = env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache");
56
+ return join(cacheHome, "directionallylocal", "versions.json");
57
+ }
58
+ async function npmVersion(pkg) {
59
+ try {
60
+ const { stdout } = await execFileAsync("npm", ["view", pkg, "version"], { timeout: 15_000 });
61
+ const version = stdout.trim();
62
+ return /^\d+\.\d+\.\d+/.test(version) ? version : undefined;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ /**
69
+ * npm's newest Autopilot and Hotcell, looked up at most once a day. Offline keeps the last
70
+ * answer rather than pretending the server is current.
71
+ */
72
+ export async function latestVersions(options = {}) {
73
+ const path = options.cacheFile ?? versionsCachePath();
74
+ const now = (options.now ?? Date.now)();
75
+ const maxAge = options.maxAgeMs ?? DAY_MS;
76
+ const cached = await readCache(path);
77
+ if (cached && now - cached.checkedAt < maxAge)
78
+ return cached;
79
+ const npm = options.npm ?? npmVersion;
80
+ const [autopilot, hotcell] = await Promise.all([npm("autopilot-ai"), npm("hotcell")]);
81
+ if (!autopilot || !hotcell)
82
+ return cached;
83
+ const fresh = { autopilot, hotcell, checkedAt: now };
84
+ await writeCache(path, fresh);
85
+ return fresh;
86
+ }
87
+ async function readCache(path) {
88
+ try {
89
+ const parsed = JSON.parse(await readFile(path, "utf8"));
90
+ return typeof parsed.autopilot === "string" && typeof parsed.hotcell === "string" && typeof parsed.checkedAt === "number"
91
+ ? { autopilot: parsed.autopilot, hotcell: parsed.hotcell, checkedAt: parsed.checkedAt }
92
+ : undefined;
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ async function writeCache(path, versions) {
99
+ try {
100
+ await mkdir(dirname(path), { recursive: true });
101
+ const temp = `${path}.${process.pid}.tmp`;
102
+ await writeFile(temp, `${JSON.stringify(versions, null, 2)}\n`, { mode: 0o644 });
103
+ await rename(temp, path);
104
+ }
105
+ catch {
106
+ // A cache that can't be written only costs another lookup next time.
107
+ }
108
+ }
109
+ /** How many tasks are running on a server, or undefined when it can't be asked. */
110
+ export async function runningTasks(remote, open) {
111
+ try {
112
+ return await withConnections(open, async (connections) => {
113
+ const list = await (await connections.client(remote)).listConversations(200);
114
+ return list.filter((entry) => entry.activeRunId !== null).length;
115
+ });
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ }
121
+ /** Servers that are behind npm, for `dl status`. One SSH round trip each, and a cached lookup. */
122
+ export async function outdatedServers(remotes, deps) {
123
+ const latest = await (deps.latest ?? (() => latestVersions()))();
124
+ if (!latest)
125
+ return [];
126
+ const outdated = [];
127
+ for (const remote of remotes) {
128
+ if (!remote.ssh)
129
+ continue;
130
+ const server = await serverVersions(remote.ssh, deps.ssh);
131
+ if (!server)
132
+ continue;
133
+ const notice = behindNotice(server, latest);
134
+ if (notice)
135
+ outdated.push({ name: remote.name, notice });
136
+ }
137
+ return outdated;
138
+ }
139
+ /** `dl update [NAME] [--wait]`: update a server over SSH, without interrupting its tasks. */
140
+ export async function update(args, deps) {
141
+ const c = palette(deps.io.color === true);
142
+ const name = args.find((arg) => !arg.startsWith("-"));
143
+ const remote = await resolveRemote(name);
144
+ if (!remote.ssh) {
145
+ deps.io.err(`\n ${c.red("✗")} ${remote.name} wasn't set up over SSH, so this laptop can't update it.\n On the server: ${c.bold("sudo dl up")}\n\n`);
146
+ return 1;
147
+ }
148
+ const facts = await checkServer(remote.ssh, deps.ssh, remote.name);
149
+ const before = await serverVersions(remote.ssh, deps.ssh);
150
+ const running = deps.running ?? ((target) => runningTasks(target, deps.open));
151
+ const count = await running(remote);
152
+ if (count === undefined) {
153
+ deps.io.err(` ${c.yellow("!")} Couldn't ask ${remote.name} what's running; updating anyway.\n`);
154
+ }
155
+ else if (count > 0) {
156
+ if (!(await settleRunning(remote, count, deps, running, c)))
157
+ return 1;
158
+ }
159
+ deps.io.out(`\n ${c.bold("Updating")} ${remote.name}${before ? c.dim(` (Autopilot ${before.autopilot} · Hotcell ${before.hotcell})`) : ""}\n\n`);
160
+ const replay = new EventReplayer(deps.newUi);
161
+ // No --access: the server keeps the way it's reached. `{}` means "no new answers", so a
162
+ // missing key fails at once instead of waiting for a prompt nobody can see.
163
+ const result = await deps.ssh(remote.ssh, `${sudo(facts)}dl up --answers-stdin --events json`, {
164
+ input: "{}\n",
165
+ onStdoutLine: (line) => replay.line(line),
166
+ });
167
+ replay.finish();
168
+ if (result.code !== 0 || replay.error) {
169
+ deps.io.err(`\n ${c.red("✗")} Updating ${remote.name} failed: ${replay.error ?? `dl up exited with ${result.code}`}\n`);
170
+ for (const line of replay.tail())
171
+ deps.io.err(` ${c.dim(line)}\n`);
172
+ deps.io.err("\n");
173
+ return 1;
174
+ }
175
+ const after = await serverVersions(remote.ssh, deps.ssh);
176
+ const changed = before && after && (before.autopilot !== after.autopilot || before.hotcell !== after.hotcell);
177
+ const versions = after ? ` ${c.dim(`Autopilot ${after.autopilot} · Hotcell ${after.hotcell}`)}` : "";
178
+ deps.io.out(`\n ${c.green("✓")} ${remote.name} is up to date${changed ? "" : c.dim(" (already was)")}${versions}\n\n`);
179
+ return 0;
180
+ }
181
+ /**
182
+ * Decides what to do about running tasks: wait when asked or when nobody can answer, and
183
+ * otherwise ask. Returns whether to go ahead.
184
+ */
185
+ async function settleRunning(remote, count, deps, running, c) {
186
+ const tasks = `${count} task${count === 1 ? "" : "s"}`;
187
+ if (deps.wait || !deps.interactive) {
188
+ deps.io.out(` ${c.cyan("·")} ${tasks} running on ${remote.name}; waiting for ${count === 1 ? "it" : "them"} to finish (Ctrl-C stops waiting).\n`);
189
+ await waitForIdle(remote, deps, running);
190
+ return true;
191
+ }
192
+ const answer = (await deps.prompter.text(` ${tasks} running on ${remote.name}. Update now and interrupt ${count === 1 ? "it" : "them"}? [y/N] `)).trim().toLowerCase();
193
+ if (answer === "y" || answer === "yes")
194
+ return true;
195
+ deps.io.out(` ${c.dim("Not updating. Wait for them, then run")} dl update ${remote.name}${c.dim(", or add --wait.")}\n\n`);
196
+ return false;
197
+ }
198
+ async function waitForIdle(remote, deps, running) {
199
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
200
+ for (;;) {
201
+ const count = await running(remote);
202
+ if (count === undefined || count === 0)
203
+ return;
204
+ await sleep(15_000);
205
+ }
206
+ }
package/dist/layout.js CHANGED
@@ -17,6 +17,9 @@ export const PATHS = {
17
17
  autopilotHome: "/var/lib/autopilot",
18
18
  workspace: "/var/lib/autopilot/workspaces/default",
19
19
  units: "/etc/systemd/system",
20
+ /** One checkout and one Serverless Objects node per preview (docs/plans/preview-deploys.md). */
21
+ previews: "/var/lib/directionallylocal/previews",
22
+ previewsState: "/var/lib/directionallylocal/previews.json",
20
23
  };
21
24
  /** Lets Ubuntu's unattended-upgrades install Node.js fixes from NodeSource, like its own packages. */
22
25
  export const NODE_AUTO_UPDATES = "/etc/apt/apt.conf.d/52directionallylocal-nodejs";
@@ -0,0 +1,219 @@
1
+ import { join } from "node:path";
2
+ import { parseEnvFile, PATHS, USERS } from "./layout.js";
3
+ import { listServerRuns } from "./server-api.js";
4
+ import { palette } from "./ui.js";
5
+ /**
6
+ * `dl preview`: deploy an agent's Workers branch on this server with Serverless Objects and
7
+ * reach it over the laptop's SSH tunnel. Design: docs/plans/preview-deploys.md.
8
+ *
9
+ * Serverless Objects is pre-alpha, so a preview failing is expected rather than exceptional:
10
+ * it never fails the task or its pull request, and it is reported in `dl previews`.
11
+ */
12
+ export const MAX_PREVIEWS = 3;
13
+ export const PREVIEW_PORT_FIRST = 8090;
14
+ export const PREVIEW_PORT_LAST = 8099;
15
+ /** Root only: a monorepo with several Wrangler projects is out of scope for Phase 1. */
16
+ const WRANGLER_CONFIGS = /^wrangler\.(json|jsonc|toml)$/;
17
+ export function previewId(conversationId) {
18
+ return conversationId.slice(0, 8);
19
+ }
20
+ /** A branch is previewable when its root has a Wrangler config (design §3). */
21
+ export function isWranglerProject(files) {
22
+ return files.some((name) => WRANGLER_CONFIGS.test(name));
23
+ }
24
+ /** The first free port in the range, or undefined when every slot is taken. */
25
+ export function freePort(previews) {
26
+ const used = new Set(previews.map((preview) => preview.port));
27
+ for (let port = PREVIEW_PORT_FIRST; port <= PREVIEW_PORT_LAST; port++)
28
+ if (!used.has(port))
29
+ return port;
30
+ return undefined;
31
+ }
32
+ /** The preview's own node config (design §2.3): its own port and its own bucket prefix. */
33
+ export function sobjectsToml(input) {
34
+ return `[celld]\nbucket = ${JSON.stringify(input.bucket)}\nlisten = "127.0.0.1:${input.port}"\n`;
35
+ }
36
+ export function bucketFor(prefix, id) {
37
+ return `${prefix.replace(/\/+$/, "")}/${id}`;
38
+ }
39
+ /**
40
+ * The token reaches git through the environment, never argv: `ps` would show it there.
41
+ * The helper string itself holds no secret.
42
+ */
43
+ const CREDENTIAL_HELPER = 'credential.helper=!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f';
44
+ export function cloneArgs(repository, branch, directory) {
45
+ return ["-c", CREDENTIAL_HELPER, "clone", "--depth", "1", "--branch", branch, `https://github.com/${repository}.git`, directory];
46
+ }
47
+ export function fetchArgs(branch) {
48
+ return ["-c", CREDENTIAL_HELPER, "fetch", "--depth", "1", "origin", branch];
49
+ }
50
+ export async function readPreviews(system) {
51
+ const raw = await system.readFile(PATHS.previewsState);
52
+ if (!raw)
53
+ return { version: 1, previews: [] };
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ return parsed.version === 1 && Array.isArray(parsed.previews) ? { version: 1, previews: parsed.previews } : { version: 1, previews: [] };
57
+ }
58
+ catch {
59
+ return { version: 1, previews: [] };
60
+ }
61
+ }
62
+ export async function writePreviews(system, state) {
63
+ await system.writeFile(PATHS.previewsState, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o644 });
64
+ }
65
+ /** The server's GitHub token, for a read-only clone as the autopilot user (design §2.5). */
66
+ export async function githubToken(system) {
67
+ const token = parseEnvFile(await system.readFile(PATHS.githubEnv)).GITHUB_TOKEN;
68
+ return token || undefined;
69
+ }
70
+ /** Deploys (or redeploys) one run's branch, keeping its port and URL stable. */
71
+ export async function deployPreview(run, deps) {
72
+ const { system } = deps;
73
+ const { repository, branch } = run;
74
+ if (!repository || !branch)
75
+ throw new Error("That task has no branch to preview yet; it pushes one when it finishes a step.");
76
+ const id = previewId(run.conversationId);
77
+ const directory = join(PATHS.previews, id);
78
+ const state = await readPreviews(system);
79
+ const existing = state.previews.find((preview) => preview.id === id);
80
+ const port = existing?.port ?? freePort(state.previews);
81
+ if (port === undefined)
82
+ throw new Error(`All ${MAX_PREVIEWS} preview slots are in use. Remove one: dl preview <id> --remove`);
83
+ await syncCheckout(system, { repository, branch, directory }, deps.token);
84
+ if (!(await isWranglerCheckout(system, directory))) {
85
+ throw new Error(`${repository} at ${branch} has no wrangler.json, wrangler.jsonc or wrangler.toml at its root, so there's nothing to preview.`);
86
+ }
87
+ await system.writeFile(join(directory, "sobjects.toml"), sobjectsToml({ bucket: bucketFor(deps.bucket, id), port }), {
88
+ mode: 0o644,
89
+ owner: `${USERS.autopilot}:${USERS.autopilot}`,
90
+ });
91
+ const env = { SOBJECTS_STATE_DIR: join(directory, "state") };
92
+ await system.run("sobjects", ["deploy", directory], { env, describe: `sobjects deploy ${id}` });
93
+ await system.run("sobjects", ["up", "--detach"], { env, cwd: directory, describe: `sobjects up ${id}` });
94
+ const wait = deps.waitForPort ?? ((target) => waitForPort(system, target));
95
+ if (!(await wait(port)))
96
+ throw new Error(`The preview started but nothing answered on 127.0.0.1:${port}. Check its log: dl logs`);
97
+ const preview = {
98
+ id,
99
+ conversationId: run.conversationId,
100
+ repository,
101
+ branch,
102
+ port,
103
+ directory,
104
+ createdAt: (deps.now?.() ?? new Date()).toISOString(),
105
+ };
106
+ await writePreviews(system, { version: 1, previews: [...state.previews.filter((entry) => entry.id !== id), preview] });
107
+ return preview;
108
+ }
109
+ /** Stops a preview's node and deletes its checkout and state. */
110
+ export async function removePreview(id, system) {
111
+ const state = await readPreviews(system);
112
+ const preview = state.previews.find((entry) => entry.id === id);
113
+ if (!preview)
114
+ return false;
115
+ await system.run("sobjects", ["down"], {
116
+ cwd: preview.directory,
117
+ env: { SOBJECTS_STATE_DIR: join(preview.directory, "state") },
118
+ allowFailure: true,
119
+ describe: `sobjects down ${id}`,
120
+ });
121
+ await system.remove(preview.directory);
122
+ await writePreviews(system, { version: 1, previews: state.previews.filter((entry) => entry.id !== id) });
123
+ return true;
124
+ }
125
+ async function syncCheckout(system, input, token) {
126
+ const owner = `${USERS.autopilot}:${USERS.autopilot}`;
127
+ const env = { GITHUB_TOKEN: token };
128
+ if (!(await system.exists(input.directory))) {
129
+ await system.mkdir(PATHS.previews, { owner });
130
+ await system.run("runuser", ["-u", USERS.autopilot, "--", "git", ...cloneArgs(input.repository, input.branch, input.directory)], {
131
+ env,
132
+ describe: `clone ${input.repository}`,
133
+ });
134
+ return;
135
+ }
136
+ await system.run("runuser", ["-u", USERS.autopilot, "--", "git", "-C", input.directory, ...fetchArgs(input.branch)], {
137
+ env,
138
+ describe: `fetch ${input.branch}`,
139
+ });
140
+ await system.run("runuser", ["-u", USERS.autopilot, "--", "git", "-C", input.directory, "reset", "--hard", "FETCH_HEAD"], {
141
+ describe: `reset ${input.branch}`,
142
+ });
143
+ }
144
+ async function isWranglerCheckout(system, directory) {
145
+ const listing = await system.probe("ls", ["-1", directory]);
146
+ return listing.code === 0 && isWranglerProject(listing.stdout.split("\n").map((line) => line.trim()).filter(Boolean));
147
+ }
148
+ async function waitForPort(system, port, attempts = 30) {
149
+ for (let attempt = 0; attempt < attempts; attempt++) {
150
+ const answer = await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "2", `http://127.0.0.1:${port}/`]);
151
+ const code = answer.stdout.trim();
152
+ if (answer.code === 0 && code !== "" && code !== "000")
153
+ return true;
154
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
155
+ }
156
+ return false;
157
+ }
158
+ /** `dl preview <id> [--remove] [--json]`: deploy a run's branch, or stop its preview. */
159
+ export async function previewCommand(system, args, options, write, c) {
160
+ const id = args.find((arg) => !arg.startsWith("-"));
161
+ if (!id) {
162
+ write(` Usage: dl preview <id> ${c.dim("(from dl runs)")} ${c.dim("--remove stops it")}\n`);
163
+ return 2;
164
+ }
165
+ if (args.includes("--remove")) {
166
+ const removed = await removePreview(id, system);
167
+ if (options.json)
168
+ write(`${JSON.stringify({ id, removed })}\n`);
169
+ else
170
+ write(`\n ${removed ? c.green("✓") : c.red("✗")} ${removed ? `Removed the preview for ${id}` : `No preview for ${id}`}\n\n`);
171
+ return removed ? 0 : 1;
172
+ }
173
+ const runs = await listServerRuns(system, 200);
174
+ if (runs === undefined) {
175
+ write(`\n ${c.red("✗")} Couldn't list runs; run ${c.bold("dl up")} to update this server.\n\n`);
176
+ return 1;
177
+ }
178
+ const matches = runs.filter((run) => run.conversationId.startsWith(id));
179
+ if (matches.length === 0) {
180
+ write(`\n ${c.red("✗")} No task ${id}. ${c.dim("dl runs")} lists them.\n\n`);
181
+ return 1;
182
+ }
183
+ if (matches.length > 1) {
184
+ write(`\n ${c.red("✗")} ${id} matches several tasks; use more characters.\n\n`);
185
+ return 1;
186
+ }
187
+ const token = await githubToken(system);
188
+ if (!token) {
189
+ write(`\n ${c.red("✗")} This server has no GitHub token, so it can't clone the branch. Set one: ${c.bold("dl keys set github")}\n\n`);
190
+ return 1;
191
+ }
192
+ const bucket = options.bucket ?? process.env.DL_PREVIEWS_BUCKET;
193
+ if (!bucket) {
194
+ write(`\n ${c.red("✗")} Previews need a bucket for their state. Set ${c.bold("DL_PREVIEWS_BUCKET")} in ${c.dim(PATHS.autopilotEnv)} (e.g. s3://my-previews).\n\n`);
195
+ return 1;
196
+ }
197
+ const preview = await deployPreview(matches[0], { system, bucket, token });
198
+ if (options.json) {
199
+ write(`${JSON.stringify({ id: preview.id, port: preview.port, url: `http://127.0.0.1:${preview.port}` })}\n`);
200
+ return 0;
201
+ }
202
+ write(`\n ${c.green("✓")} ${preview.id} is live at ${c.bold(`http://127.0.0.1:${preview.port}`)}\n`);
203
+ write(` ${c.dim("From your laptop:")} dl preview ${preview.id} ${c.dim("Stop it:")} dl preview ${preview.id} --remove\n\n`);
204
+ return 0;
205
+ }
206
+ /** `dl previews`: the live previews, newest first. */
207
+ export async function previewsCommand(system, write, c) {
208
+ const { previews } = await readPreviews(system);
209
+ if (previews.length === 0) {
210
+ write(`\n No previews. From a task's branch: ${c.bold("dl preview <id>")}\n\n`);
211
+ return 0;
212
+ }
213
+ write("\n");
214
+ for (const preview of [...previews].sort((a, b) => b.createdAt.localeCompare(a.createdAt))) {
215
+ write(` ${c.green("●")} ${preview.id} ${c.dim(`:${preview.port}`)} ${(preview.repository ?? "").padEnd(28)} ${c.dim(preview.branch)}\n`);
216
+ }
217
+ write(`\n ${c.dim("Open one from your laptop:")} dl preview <id> ${c.dim("Stop one:")} dl preview <id> --remove\n\n`);
218
+ return 0;
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Directionally Local: background coding agents on your own server. One-line server install, and the dl command for your server and laptop.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://directionallylocal.com",
@@ -27,8 +27,10 @@
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsc -p tsconfig.build.json",
30
- "typecheck": "tsc --noEmit && tsc -p site/tsconfig.json",
31
- "test": "tsx --test \"src/**/*.test.ts\" \"site/**/*.test.ts\"",
30
+ "typecheck": "tsc --noEmit && tsc -p site/tsconfig.json && tsc -p services/tsconfig.json",
31
+ "test": "tsx --test \"src/**/*.test.ts\" \"site/**/*.test.ts\" \"services/**/*.test.ts\"",
32
+ "sync:site-script": "node site/generate-install-script.mjs",
33
+ "check:portable": "node scripts/check-portable-apis.mjs services/name services/shared",
32
34
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
33
35
  },
34
36
  "devDependencies": {