directionallylocal 0.5.0 → 0.7.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 +91 -22
- package/dist/cli.js +19 -1
- package/dist/laptop/client.js +19 -0
- package/dist/laptop/commands.js +107 -33
- package/dist/laptop/session.js +3 -2
- package/dist/laptop/settings.js +193 -0
- package/dist/laptop/setup.js +18 -0
- package/dist/laptop/update.js +206 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,59 +1,125 @@
|
|
|
1
1
|
# Directionally Local installer
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
8
|
+
npm install -g directionallylocal
|
|
9
|
+
dl
|
|
7
10
|
```
|
|
8
11
|
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
44
|
+
Then, from inside a GitHub repository:
|
|
24
45
|
|
|
25
46
|
```sh
|
|
26
|
-
|
|
27
|
-
dl
|
|
28
|
-
dl
|
|
29
|
-
dl
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 servers` | Where agents run. `dl servers rename OLD NEW`, `dl servers remove NAME`. |
|
|
75
|
+
| `dl use [NAME]` | Where tasks go without `--server`. With no name, shows it. |
|
|
76
|
+
| `dl keys [openrouter\|github]` | Which keys are set, or replace one. `--server NAME` picks the server. |
|
|
77
|
+
| `dl model [ID]` | The model for tasks and sessions. With no ID, picks from a list. |
|
|
78
|
+
| `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. |
|
|
79
|
+
| `dl local status` | Agents running on this computer: running?, where, how many fit. |
|
|
80
|
+
| `dl local uninstall [--purge]` | Stop and remove them. `--purge` also deletes keys and data. |
|
|
81
|
+
|
|
82
|
+
Run options: `--server NAME`, `-m/--model MODEL`, `--repo OWNER/NAME`, `--detach`.
|
|
83
|
+
|
|
84
|
+
### On the server
|
|
85
|
+
|
|
86
|
+
Run these as root. `dl` on its own shows what this is, whether it's healthy, the connected laptops, and the runs.
|
|
40
87
|
|
|
41
88
|
| Command | What it does |
|
|
42
89
|
| --- | --- |
|
|
43
90
|
| `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
91
|
| `dl up --dry-run` | Print what `up` would change, without changing anything. |
|
|
92
|
+
| `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. |
|
|
93
|
+
| `dl laptops` | Connected laptops. `dl laptops remove NAME` disconnects one. |
|
|
94
|
+
| `dl runs` | Tasks running or done, with their pull requests. |
|
|
95
|
+
| `dl logs [hotcell\|autopilot] [-f]` | The services' logs. |
|
|
45
96
|
| `dl doctor` | Check every part, with a fix for each failure. |
|
|
97
|
+
| `dl keys [set openrouter\|github]` | Which keys are set, or replace one (hidden input). |
|
|
46
98
|
| `dl status` | Show the installed versions and where to reach the server. |
|
|
47
99
|
| `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
100
|
|
|
49
|
-
|
|
101
|
+
### Unattended installs
|
|
102
|
+
|
|
103
|
+
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
104
|
|
|
51
|
-
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
## The one-line server install
|
|
108
|
+
|
|
109
|
+
The curl path is still there for a server you set up by hand, as root on Ubuntu or Debian:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
curl -fsSL https://directionallylocal.run | sh
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
## What it sets up on a server
|
|
52
118
|
|
|
53
119
|
- **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
120
|
- **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
121
|
- **Hotcell and Autopilot from npm,** under `/opt/directionallylocal/releases/<versions>`, with `current` pointing at the active release.
|
|
56
|
-
- **
|
|
122
|
+
- **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
123
|
- **A sandbox image** with Node, git, Python, and the Autopilot runtime baked in.
|
|
58
124
|
- **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
125
|
|
|
@@ -63,6 +129,8 @@ Supported: Ubuntu 24.04 and 22.04 and Debian 12, on x86_64 or arm64, with at lea
|
|
|
63
129
|
|
|
64
130
|
`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
131
|
|
|
132
|
+
`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).
|
|
133
|
+
|
|
66
134
|
## Development
|
|
67
135
|
|
|
68
136
|
```sh
|
|
@@ -70,6 +138,7 @@ npm install
|
|
|
70
138
|
npm test # node:test through tsx; a simulated server exercises up, doctor, and uninstall
|
|
71
139
|
npm run typecheck
|
|
72
140
|
npm run build
|
|
141
|
+
npm run sync:site-script # after editing install.sh
|
|
73
142
|
```
|
|
74
143
|
|
|
75
144
|
## License
|
package/dist/cli.js
CHANGED
|
@@ -7,8 +7,11 @@ import { briefStatus, connectLaptop, laptopsCommand, logsCommand, pairLaptop, se
|
|
|
7
7
|
import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, use, watch } from "./laptop/commands.js";
|
|
8
8
|
import { loadRemotes } from "./laptop/config.js";
|
|
9
9
|
import { keys } from "./laptop/keys.js";
|
|
10
|
+
import { model } from "./laptop/settings.js";
|
|
10
11
|
import { projectSession } from "./laptop/session.js";
|
|
11
12
|
import { setup } from "./laptop/setup.js";
|
|
13
|
+
import { realSshExec } from "./laptop/ssh.js";
|
|
14
|
+
import { update } from "./laptop/update.js";
|
|
12
15
|
import { localCommand } from "./local/cli.js";
|
|
13
16
|
import { setUpOnThisComputer } from "./local/setup.js";
|
|
14
17
|
import { TtyPrompter } from "./prompt.js";
|
|
@@ -36,6 +39,9 @@ On your laptop:
|
|
|
36
39
|
dl servers where agents run (dl servers remove NAME · dl servers rename OLD NEW)
|
|
37
40
|
dl use [NAME] where tasks go without --server (no NAME: show it)
|
|
38
41
|
dl keys [openrouter|github] which keys are set, or replace one (--server NAME)
|
|
42
|
+
dl model [ID] the model for tasks and sessions (no ID: pick from a list)
|
|
43
|
+
dl update [NAME] update a server's Autopilot, Hotcell and sandbox image
|
|
44
|
+
(--wait: wait for running tasks instead of asking)
|
|
39
45
|
run options: --server NAME, -m/--model MODEL, --repo OWNER/NAME, --detach
|
|
40
46
|
dl local status agents running on this computer: running?, where, how many fit
|
|
41
47
|
dl local uninstall [--purge] stop and remove them (--purge also deletes keys and data)
|
|
@@ -104,6 +110,7 @@ export async function main(argv, io = {
|
|
|
104
110
|
needs: { type: "boolean" },
|
|
105
111
|
identity: { type: "string" },
|
|
106
112
|
stdin: { type: "boolean" },
|
|
113
|
+
wait: { type: "boolean" },
|
|
107
114
|
},
|
|
108
115
|
});
|
|
109
116
|
}
|
|
@@ -218,7 +225,7 @@ export async function main(argv, io = {
|
|
|
218
225
|
}
|
|
219
226
|
// A laptop connected to servers gets its own home (`dl status` shows it from anywhere);
|
|
220
227
|
// otherwise, what this is and how to start.
|
|
221
|
-
if ((!command || command === "status") && await laptopHome(laptopIO))
|
|
228
|
+
if ((!command || command === "status") && await laptopHome(laptopIO, undefined, undefined, { ssh: realSshExec }))
|
|
222
229
|
return 0;
|
|
223
230
|
// A laptop with nothing set up yet: the wizard (it needs someone to answer it).
|
|
224
231
|
if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
|
|
@@ -240,6 +247,15 @@ export async function main(argv, io = {
|
|
|
240
247
|
}
|
|
241
248
|
case "setup":
|
|
242
249
|
return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input), ...(values.identity ? { identityFile: values.identity } : {}) });
|
|
250
|
+
case "update":
|
|
251
|
+
return await update(parsed.positionals.slice(1), {
|
|
252
|
+
ssh: realSshExec,
|
|
253
|
+
prompter: new TtyPrompter(),
|
|
254
|
+
newUi: () => terminal ? new TerminalUi(io.out, undefined, colorOn) : new PlainUi(io.out),
|
|
255
|
+
io: laptopIO,
|
|
256
|
+
interactive: terminal,
|
|
257
|
+
wait: Boolean(values.wait),
|
|
258
|
+
});
|
|
243
259
|
case "connect": {
|
|
244
260
|
const url = parsed.positionals[1];
|
|
245
261
|
if (!url) {
|
|
@@ -293,6 +309,8 @@ export async function main(argv, io = {
|
|
|
293
309
|
return await servers(parsed.positionals.slice(1), laptopIO);
|
|
294
310
|
case "use":
|
|
295
311
|
return await use(parsed.positionals.slice(1), laptopIO);
|
|
312
|
+
case "model":
|
|
313
|
+
return await model(parsed.positionals.slice(1), { io: laptopIO, terminal });
|
|
296
314
|
case "runs":
|
|
297
315
|
// On a server, its runs; on a laptop, the tasks it sent.
|
|
298
316
|
if (!(await readState(system))) {
|
package/dist/laptop/client.js
CHANGED
|
@@ -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.
|
package/dist/laptop/commands.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import { homedir, hostname } from "node:os";
|
|
3
|
+
import { hostname } from "node:os";
|
|
5
4
|
import { promisify } from "node:util";
|
|
6
5
|
import { isModelNotPriced } from "../model-prices.js";
|
|
7
6
|
import { palette } from "../ui.js";
|
|
8
7
|
import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
|
|
9
8
|
import { defaultRemoteName, loadRemotes, loadRemotesFile, normalizeServerUrl, recordLastUsed, REMOTE_NAME_RE, removeRemote, renameRemote, resolveRemote, resolveTarget, saveRemote, setDefaultRemote, } from "./config.js";
|
|
10
|
-
import { autopilotConfigPath } from "./credentials.js";
|
|
11
9
|
import { PAIRED_NAME } from "../local/layout.js";
|
|
12
10
|
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
11
|
+
import { DEFAULT_MODEL, laptopModel } from "./settings.js";
|
|
13
12
|
import { sshCommandLine } from "./ssh.js";
|
|
14
13
|
import { openBrowser } from "./open-browser.js";
|
|
15
14
|
import { withConnections } from "./tunnel.js";
|
|
15
|
+
import { outdatedServers } from "./update.js";
|
|
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),
|
|
18
19
|
* follow them, and check out the pull requests. It talks to the server's API directly (the
|
|
@@ -23,8 +24,6 @@ import { withConnections } from "./tunnel.js";
|
|
|
23
24
|
*/
|
|
24
25
|
const execFileAsync = promisify(execFile);
|
|
25
26
|
const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
|
|
26
|
-
/** The model a task gets when neither -m nor the laptop's Autopilot picks one the server offers. */
|
|
27
|
-
export const DEFAULT_MODEL = "moonshotai/kimi-k2.6";
|
|
28
27
|
const processIO = {
|
|
29
28
|
out: (text) => process.stdout.write(text),
|
|
30
29
|
err: (text) => process.stderr.write(text),
|
|
@@ -68,7 +67,7 @@ export async function connect(input, io = processIO) {
|
|
|
68
67
|
*/
|
|
69
68
|
export async function run(input, io = processIO) {
|
|
70
69
|
const { remote, reason } = await resolveTarget(input.server);
|
|
71
|
-
const preferred = input.preferredModel ?? await (input.autopilotModel ??
|
|
70
|
+
const preferred = input.preferredModel ?? await (input.autopilotModel ?? laptopModel)();
|
|
72
71
|
return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, reason, { ...input, ...(preferred ? { preferredModel: preferred } : {}) }, io));
|
|
73
72
|
}
|
|
74
73
|
async function runOn(client, remote, reason, input, io) {
|
|
@@ -131,12 +130,16 @@ async function listRuns(remotes, connections, json, io) {
|
|
|
131
130
|
io.out(`\n No tasks yet. From inside a GitHub repository: ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
|
|
132
131
|
return;
|
|
133
132
|
}
|
|
133
|
+
const reasons = await failureReasons(rows, remotes, connections);
|
|
134
134
|
io.out("\n");
|
|
135
135
|
for (const entry of rows) {
|
|
136
136
|
const running = entry.activeRunId !== null;
|
|
137
137
|
const glyph = running ? c.cyan("◐") : entry.status === "completed" || entry.status === "merged" ? c.green("✓") : ["failed", "interrupted"].includes(entry.status) ? c.red("✗") : c.dim("○");
|
|
138
138
|
const multi = remotes.length > 1 ? `${entry.server} ` : "";
|
|
139
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`);
|
|
140
143
|
}
|
|
141
144
|
io.out(`\n ${c.dim("Follow one:")} dl watch <id> ${c.dim("Check one out:")} dl pull <id>\n\n`);
|
|
142
145
|
}
|
|
@@ -264,7 +267,7 @@ export async function renameServer(args, io = processIO, path) {
|
|
|
264
267
|
return 0;
|
|
265
268
|
}
|
|
266
269
|
/** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
|
|
267
|
-
export async function laptopHome(io = processIO, open) {
|
|
270
|
+
export async function laptopHome(io = processIO, open, model = laptopModel, versions) {
|
|
268
271
|
const c = palette(io.color === true);
|
|
269
272
|
const remotes = await loadRemotes();
|
|
270
273
|
if (remotes.length === 0)
|
|
@@ -278,11 +281,19 @@ export async function laptopHome(io = processIO, open) {
|
|
|
278
281
|
total += list?.length ?? 0;
|
|
279
282
|
}
|
|
280
283
|
});
|
|
281
|
-
const current = await
|
|
284
|
+
const [current, chosen, outdated] = await Promise.all([
|
|
285
|
+
defaultName(),
|
|
286
|
+
model(),
|
|
287
|
+
versions ? outdatedServers(remotes, versions).catch(() => []) : Promise.resolve([]),
|
|
288
|
+
]);
|
|
282
289
|
io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
|
|
283
290
|
io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
|
|
284
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`);
|
|
285
|
-
io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n
|
|
292
|
+
io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\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");
|
|
286
297
|
io.out(` ${c.dim("Start a session: cd into a GitHub repository, then type")} ${c.bold("dl")}\n`);
|
|
287
298
|
io.out(` ${c.dim("Or hand off a one-off task:")} ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
|
|
288
299
|
return true;
|
|
@@ -294,12 +305,15 @@ async function follow(client, remote, conversationId, cursor, signal, io, replay
|
|
|
294
305
|
signal?.addEventListener("abort", stop, { once: true });
|
|
295
306
|
const renderer = new EventRenderer(io, c);
|
|
296
307
|
let outcome;
|
|
308
|
+
let finalEvent;
|
|
297
309
|
try {
|
|
298
310
|
if (replayUntil === undefined || replayUntil > cursor) {
|
|
299
311
|
for await (const event of client.events(conversationId, cursor, controller.signal)) {
|
|
300
312
|
renderer.render(event);
|
|
301
|
-
if (RUN_END_EVENTS.has(event.type))
|
|
313
|
+
if (RUN_END_EVENTS.has(event.type)) {
|
|
302
314
|
outcome = event.type;
|
|
315
|
+
finalEvent = event;
|
|
316
|
+
}
|
|
303
317
|
if (replayUntil === undefined ? RUN_END_EVENTS.has(event.type) : event.id >= replayUntil)
|
|
304
318
|
break;
|
|
305
319
|
}
|
|
@@ -326,9 +340,18 @@ async function follow(client, remote, conversationId, cursor, signal, io, replay
|
|
|
326
340
|
return 0;
|
|
327
341
|
}
|
|
328
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`);
|
|
329
347
|
if (renderer.unpriced || isModelNotPriced(JSON.stringify(final))) {
|
|
330
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`);
|
|
331
349
|
}
|
|
350
|
+
else {
|
|
351
|
+
const hint = reason ? failureHint(reason) : undefined;
|
|
352
|
+
if (hint)
|
|
353
|
+
io.err(` ${c.dim(hint)}\n`);
|
|
354
|
+
}
|
|
332
355
|
io.err("\n");
|
|
333
356
|
return 1;
|
|
334
357
|
}
|
|
@@ -402,7 +425,7 @@ async function findRun(id, server, connections) {
|
|
|
402
425
|
return matches[0];
|
|
403
426
|
}
|
|
404
427
|
/**
|
|
405
|
-
* The task's model: -m/--model; else the laptop's
|
|
428
|
+
* The task's model: -m/--model; else the laptop's default (dl model, else Autopilot's), if the server offers it;
|
|
406
429
|
* else DEFAULT_MODEL, if offered. Never just the first in the server's list (that's the
|
|
407
430
|
* whole OpenRouter catalog, in no useful order).
|
|
408
431
|
*/
|
|
@@ -419,30 +442,9 @@ export function pickModel(models, requested, preferred) {
|
|
|
419
442
|
return DEFAULT_MODEL;
|
|
420
443
|
if (models.length === 0)
|
|
421
444
|
throw new Error("The server offers no models; check its OpenRouter key (on the server: dl doctor)");
|
|
422
|
-
const tried = preferred && preferred !== DEFAULT_MODEL ? `your
|
|
445
|
+
const tried = preferred && preferred !== DEFAULT_MODEL ? `your model (${preferred}) or ${DEFAULT_MODEL}` : DEFAULT_MODEL;
|
|
423
446
|
throw new Error(`The server doesn't offer ${tried}. ${how}`);
|
|
424
447
|
}
|
|
425
|
-
/**
|
|
426
|
-
* The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
|
|
427
|
-
* `model` in its config. undefined when neither is set or the config can't be read.
|
|
428
|
-
*/
|
|
429
|
-
export async function readAutopilotModel(sources = {}) {
|
|
430
|
-
const env = sources.env ?? process.env;
|
|
431
|
-
const fromEnv = env.KIMI_MODEL?.trim();
|
|
432
|
-
if (fromEnv)
|
|
433
|
-
return fromEnv;
|
|
434
|
-
const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
|
|
435
|
-
const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
|
|
436
|
-
if (!raw)
|
|
437
|
-
return undefined;
|
|
438
|
-
try {
|
|
439
|
-
const model = JSON.parse(raw).model;
|
|
440
|
-
return typeof model === "string" && model.trim() ? model.trim() : undefined;
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
return undefined;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
448
|
/** Where a place is, as people know it: this computer, its URL, or the SSH destination its tunnel goes to. */
|
|
447
449
|
function where(remote) {
|
|
448
450
|
if (remote.ssh)
|
|
@@ -461,6 +463,78 @@ function oneLine(text, max) {
|
|
|
461
463
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
462
464
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
463
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
|
+
}
|
|
464
538
|
export function describeError(error) {
|
|
465
539
|
if (error instanceof RemoteApiError)
|
|
466
540
|
return `${error.message} (${error.code})`;
|
package/dist/laptop/session.js
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { palette } from "../ui.js";
|
|
9
9
|
import { RemoteApiError } from "./client.js";
|
|
10
|
-
import { describeError, pickModel
|
|
10
|
+
import { describeError, pickModel } from "./commands.js";
|
|
11
|
+
import { laptopModel } from "./settings.js";
|
|
11
12
|
import { recordLastUsed, resolveTarget } from "./config.js";
|
|
12
13
|
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
13
14
|
import { findSession, forgetSession, rememberSession, sessionsPath } from "./session-store.js";
|
|
@@ -121,7 +122,7 @@ export async function projectSession(input, io) {
|
|
|
121
122
|
const [workspaces, models, preferred] = await step(Promise.all([
|
|
122
123
|
client.workspaces(),
|
|
123
124
|
client.models(),
|
|
124
|
-
input.model ? Promise.resolve(undefined) : (input.autopilotModel ??
|
|
125
|
+
input.model ? Promise.resolve(undefined) : (input.autopilotModel ?? laptopModel)(),
|
|
125
126
|
]));
|
|
126
127
|
const workspace = workspaces[0];
|
|
127
128
|
if (!workspace)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fetchModelPrices } from "../model-prices.js";
|
|
5
|
+
import { PromptCancelled, TtyPrompter } from "../prompt.js";
|
|
6
|
+
import { palette, PlainUi, TerminalUi } from "../ui.js";
|
|
7
|
+
import { autopilotConfigPath } from "./credentials.js";
|
|
8
|
+
/**
|
|
9
|
+
* dl's own settings on this laptop (~/.config/directionallylocal/config.json). Today only
|
|
10
|
+
* the model tasks and sessions use when none is named with -m. It's dl's, so people who
|
|
11
|
+
* never installed Autopilot can set it; Autopilot's own default is still honoured when
|
|
12
|
+
* dl has none.
|
|
13
|
+
*/
|
|
14
|
+
/** What tasks and sessions use when neither -m, dl model nor Autopilot names a model. */
|
|
15
|
+
export const DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash-1017";
|
|
16
|
+
/** The suggestions setup and `dl model` offer first; any OpenRouter ID works too. */
|
|
17
|
+
export const SUGGESTED_MODELS = [
|
|
18
|
+
{ id: "deepseek/deepseek-v4.1-flash-1017", note: "fast and cheap" },
|
|
19
|
+
{ id: "moonshotai/kimi-k2.6", note: "strong at agentic coding" },
|
|
20
|
+
{ id: "anthropic/claude-sonnet-5.5", note: "stronger, pricier" },
|
|
21
|
+
{ id: "openai/gpt-6.1-sol", note: "stronger, pricier" },
|
|
22
|
+
];
|
|
23
|
+
export const OPENROUTER_ID_EXAMPLE = "provider/model, e.g. z-ai/glm-5";
|
|
24
|
+
export function settingsPath(env = process.env) {
|
|
25
|
+
const configHome = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
26
|
+
return join(configHome, "directionallylocal", "config.json");
|
|
27
|
+
}
|
|
28
|
+
export async function loadSettings(path = settingsPath()) {
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = await readFile(path, "utf8");
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === "ENOENT")
|
|
35
|
+
return {};
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return typeof parsed.model === "string" && parsed.model.trim() ? { model: parsed.model.trim() } : {};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A damaged file only loses the saved model; the next save rewrites it.
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function saveModel(model, path = settingsPath()) {
|
|
48
|
+
const settings = { ...(await loadSettings(path)), model };
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
50
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
51
|
+
await writeFile(temp, `${JSON.stringify({ version: 1, ...settings }, null, 2)}\n`, { mode: 0o600 });
|
|
52
|
+
await rename(temp, path);
|
|
53
|
+
}
|
|
54
|
+
/** The model to use when none is named: dl's setting, else Autopilot's default, with where it came from. */
|
|
55
|
+
export async function preferredModel(autopilotModel, path) {
|
|
56
|
+
const { model } = await loadSettings(path);
|
|
57
|
+
if (model)
|
|
58
|
+
return { model, source: "dl" };
|
|
59
|
+
const fromAutopilot = await autopilotModel();
|
|
60
|
+
return fromAutopilot ? { model: fromAutopilot, source: "autopilot" } : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** This laptop's default model: the one set with `dl model`, else Autopilot's. */
|
|
63
|
+
export async function laptopModel() {
|
|
64
|
+
return (await preferredModel(() => readAutopilotModel()))?.model;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
|
|
68
|
+
* `model` in its config. undefined when neither is set or the config can't be read.
|
|
69
|
+
*/
|
|
70
|
+
export async function readAutopilotModel(sources = {}) {
|
|
71
|
+
const env = sources.env ?? process.env;
|
|
72
|
+
const fromEnv = env.KIMI_MODEL?.trim();
|
|
73
|
+
if (fromEnv)
|
|
74
|
+
return fromEnv;
|
|
75
|
+
const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
|
|
76
|
+
const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
|
|
77
|
+
if (!raw)
|
|
78
|
+
return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const model = JSON.parse(raw).model;
|
|
81
|
+
return typeof model === "string" && model.trim() ? model.trim() : undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** OpenRouter's prices, or undefined when they can't be fetched: the list then just has no prices. */
|
|
88
|
+
export async function modelPrices(fetchImpl = fetch) {
|
|
89
|
+
return fetchModelPrices(fetchImpl).then(({ table }) => table, () => undefined);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The picker's options: the suggestions (with prices when known, and the current model
|
|
93
|
+
* added if it isn't one), then "Another model", which takes a typed ID.
|
|
94
|
+
*/
|
|
95
|
+
export function modelOptions(current, prices, anotherPrompt) {
|
|
96
|
+
const known = prices ? SUGGESTED_MODELS.filter((entry) => entry.id.toLowerCase() in prices) : SUGGESTED_MODELS;
|
|
97
|
+
const entries = current && !known.some((entry) => entry.id === current) ? [{ id: current, note: "your current model" }, ...known] : known;
|
|
98
|
+
return [
|
|
99
|
+
...entries.map((entry) => ({ value: entry.id, label: entry.id, detail: [entry.note, priceLabel(prices?.[entry.id.toLowerCase()])].filter(Boolean).join(" · ") })),
|
|
100
|
+
{ value: "another", label: "Another model", detail: "any OpenRouter model ID", input: { prompt: anotherPrompt, placeholder: OPENROUTER_ID_EXAMPLE } },
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
function priceLabel(price) {
|
|
104
|
+
if (!price)
|
|
105
|
+
return "";
|
|
106
|
+
if (price.inputPerMTok === 0 && price.outputPerMTok === 0)
|
|
107
|
+
return "free";
|
|
108
|
+
return `$${price.inputPerMTok} in / $${price.outputPerMTok} out per M tokens`;
|
|
109
|
+
}
|
|
110
|
+
/** A typed model ID, or why it isn't one. Checked against OpenRouter's catalog when it's known. */
|
|
111
|
+
export function checkModelId(text, prices) {
|
|
112
|
+
const model = text.trim();
|
|
113
|
+
if (!/^[a-z0-9][\w.-]*\/[\w.:-]+$/i.test(model))
|
|
114
|
+
return { ok: false, message: `"${model}" isn't an OpenRouter model ID (${OPENROUTER_ID_EXAMPLE})` };
|
|
115
|
+
if (prices && !(model.toLowerCase() in prices))
|
|
116
|
+
return { ok: false, message: `OpenRouter has no model ${model} (see https://openrouter.ai/models)` };
|
|
117
|
+
return { ok: true, model };
|
|
118
|
+
}
|
|
119
|
+
/** The arrow-key list of models, with `current` preselected; a typed ID gets 3 tries. */
|
|
120
|
+
export async function askModel(prompter, ui, current, prices) {
|
|
121
|
+
const options = modelOptions(current, prices, ui.prompt("Model ID", OPENROUTER_ID_EXAMPLE));
|
|
122
|
+
const initial = Math.max(0, options.findIndex((option) => option.value === current));
|
|
123
|
+
for (let attempt = 1;; attempt++) {
|
|
124
|
+
const picked = await prompter.select(ui.prompt("Model", "used for tasks and sessions"), options, initial);
|
|
125
|
+
if (picked.value !== "another")
|
|
126
|
+
return picked.value;
|
|
127
|
+
const checked = checkModelId(picked.text ?? "", prices);
|
|
128
|
+
if (checked.ok)
|
|
129
|
+
return checked.model;
|
|
130
|
+
if (attempt >= 3)
|
|
131
|
+
throw new Error(checked.message);
|
|
132
|
+
ui.rejected(`${checked.message}. Try again.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* `dl model [ID]`: the model `dl run` and new sessions use when -m doesn't name one. With an
|
|
137
|
+
* ID, sets it; without, picks from a list on a terminal, or says which it is otherwise.
|
|
138
|
+
*/
|
|
139
|
+
export async function modelCommand(args, deps) {
|
|
140
|
+
const c = palette(deps.color);
|
|
141
|
+
const current = await preferredModel(deps.autopilotModel, deps.settingsFile);
|
|
142
|
+
const model = current?.model ?? DEFAULT_MODEL;
|
|
143
|
+
const from = current?.source === "dl" ? "set with dl model" : current?.source === "autopilot" ? "Autopilot's default" : "dl's default";
|
|
144
|
+
if (args.length > 1) {
|
|
145
|
+
deps.write(`\n ${c.red("✗")} dl model takes one model ID, e.g. dl model ${DEFAULT_MODEL}\n\n`);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
if (args.length === 0 && !deps.interactive) {
|
|
149
|
+
deps.write(`\n ${c.bold("Model")} ${model} ${c.dim(`(${from})`)}\n ${c.dim("Change it:")} dl model <id> ${c.dim("(model IDs: https://openrouter.ai/models)")}\n\n`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
const prices = await modelPrices(deps.fetch);
|
|
153
|
+
let chosen;
|
|
154
|
+
if (args[0] !== undefined) {
|
|
155
|
+
const checked = checkModelId(args[0], prices);
|
|
156
|
+
if (!checked.ok) {
|
|
157
|
+
deps.write(`\n ${c.red("✗")} ${checked.message}\n\n`);
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
chosen = checked.model;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const ui = deps.newUi();
|
|
164
|
+
deps.write("\n");
|
|
165
|
+
try {
|
|
166
|
+
chosen = await askModel(deps.prompter, ui, model, prices);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!(error instanceof PromptCancelled))
|
|
170
|
+
throw error;
|
|
171
|
+
deps.write(` ${c.dim(`Unchanged: ${model}`)}\n\n`);
|
|
172
|
+
return 130;
|
|
173
|
+
}
|
|
174
|
+
ui.finish();
|
|
175
|
+
}
|
|
176
|
+
await saveModel(chosen, deps.settingsFile);
|
|
177
|
+
deps.write(`\n ${c.green("✓")} ${c.bold("Model")} ${chosen}\n ${c.dim("Used by dl run and new sessions. A running session keeps its model; switch it there with")} /model\n\n`);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
/** `dl model` with the real terminal and network. */
|
|
181
|
+
export async function model(args, options) {
|
|
182
|
+
const { io, terminal } = options;
|
|
183
|
+
const color = io.color === true;
|
|
184
|
+
return modelCommand(args, {
|
|
185
|
+
newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
|
|
186
|
+
prompter: new TtyPrompter({ color }),
|
|
187
|
+
write: io.out,
|
|
188
|
+
color,
|
|
189
|
+
interactive: terminal,
|
|
190
|
+
fetch: globalThis.fetch,
|
|
191
|
+
autopilotModel: () => readAutopilotModel(),
|
|
192
|
+
});
|
|
193
|
+
}
|
package/dist/laptop/setup.js
CHANGED
|
@@ -9,6 +9,7 @@ import { loadRemotes, loadRemotesFile, normalizeServerUrl, REMOTE_NAME_RE, remot
|
|
|
9
9
|
import { confirm, gatherKeys } from "./credentials.js";
|
|
10
10
|
import { bootstrap, INSTALL_URL, installOnServer, pairLaptop, serverNeeds } from "./remote-install.js";
|
|
11
11
|
import { checkServer, describeHost, discoverKeys, hostOf, parseSshConfig, parseTarget, realSshExec, SshAuthError } from "./ssh.js";
|
|
12
|
+
import { askModel, DEFAULT_MODEL, loadSettings, modelPrices, readAutopilotModel, saveModel } from "./settings.js";
|
|
12
13
|
import { openRemote, withConnections } from "./tunnel.js";
|
|
13
14
|
const RUN_EXAMPLE = 'dl run "fix the flaky checkout test"';
|
|
14
15
|
const PLACES = [
|
|
@@ -40,6 +41,7 @@ export async function setupWizard(deps) {
|
|
|
40
41
|
await saveRemote(remote, remotesFile);
|
|
41
42
|
if (isNew)
|
|
42
43
|
await chooseDefault(deps, remote, remotesFile);
|
|
44
|
+
await chooseModel(deps);
|
|
43
45
|
finishScreen(deps.newUi(), capacity);
|
|
44
46
|
return remote;
|
|
45
47
|
}
|
|
@@ -100,6 +102,7 @@ async function setUpServer(deps) {
|
|
|
100
102
|
connectedUi.finish();
|
|
101
103
|
if (!known.some((entry) => entry.name === paired.name))
|
|
102
104
|
await chooseDefault(deps, remote, remotesFile);
|
|
105
|
+
await chooseModel(deps);
|
|
103
106
|
finishScreen(deps.newUi(), capacity);
|
|
104
107
|
return remote;
|
|
105
108
|
}
|
|
@@ -155,6 +158,21 @@ async function chooseDefault(deps, added, remotesFile) {
|
|
|
155
158
|
ui.answered("Default", value);
|
|
156
159
|
ui.finish();
|
|
157
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Which model tasks and sessions use, asked once: setup skips it when dl already has one
|
|
163
|
+
* (dl model changes it). Autopilot's own default is preselected for people who have one.
|
|
164
|
+
*/
|
|
165
|
+
async function chooseModel(deps) {
|
|
166
|
+
if ((await loadSettings(deps.settingsFile)).model)
|
|
167
|
+
return;
|
|
168
|
+
const [current, prices] = await Promise.all([readAutopilotModel(deps.sources), deps.sources.fetch ? modelPrices(deps.sources.fetch) : undefined]);
|
|
169
|
+
const ui = deps.newUi();
|
|
170
|
+
deps.write("\n");
|
|
171
|
+
const model = await askModel(deps.prompter, ui, current ?? DEFAULT_MODEL, prices);
|
|
172
|
+
await saveModel(model, deps.settingsFile);
|
|
173
|
+
ui.answered("Model", `${model} (change it any time: dl model)`);
|
|
174
|
+
ui.finish();
|
|
175
|
+
}
|
|
158
176
|
function describePlace(remote) {
|
|
159
177
|
if (remote.ssh)
|
|
160
178
|
return `server · ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|