directionallylocal 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +4 -0
- package/dist/cli.js +20 -2
- package/dist/doctor.js +6 -3
- package/dist/laptop/commands.js +12 -2
- package/dist/laptop/credentials.js +5 -5
- package/dist/laptop/session-store.js +62 -0
- package/dist/laptop/session.js +279 -0
- package/dist/laptop/setup.js +92 -63
- package/dist/laptop/tunnel.js +8 -0
- package/dist/layout.js +4 -0
- package/dist/local/cli.js +4 -1
- package/dist/local/layout.js +3 -0
- package/dist/local/setup.js +14 -6
- package/dist/local/test-support/fake-computer.js +7 -0
- package/dist/model-prices.js +119 -0
- package/dist/prompt.js +333 -16
- package/dist/up.js +10 -1
- package/package.json +5 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { constants, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, delimiter, dirname, join, resolve as resolvePath } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { palette } from "../ui.js";
|
|
9
|
+
import { RemoteApiError } from "./client.js";
|
|
10
|
+
import { describeError, pickModel, readAutopilotModel } from "./commands.js";
|
|
11
|
+
import { recordLastUsed, resolveTarget } from "./config.js";
|
|
12
|
+
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
13
|
+
import { findSession, forgetSession, rememberSession, sessionsPath } from "./session-store.js";
|
|
14
|
+
import { openRemoteForSession, withConnections } from "./tunnel.js";
|
|
15
|
+
/**
|
|
16
|
+
* `dl` inside a project: Autopilot's own interactive screen, attached to this repository's
|
|
17
|
+
* conversation on the place agents run (`autopilot attach-remote`). The first time it starts
|
|
18
|
+
* one from this checkout (base commit + uncommitted changes, as `dl run` sends them); after
|
|
19
|
+
* that it reopens the same one. dl only finds the conversation, holds the SSH tunnel open,
|
|
20
|
+
* and gets out of the way: Autopilot owns the terminal until the user detaches.
|
|
21
|
+
*/
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
const realSpawn = (command, args, env) => spawn(command, args, { stdio: "inherit", env });
|
|
24
|
+
const FORWARDED = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
25
|
+
const UPDATE = "npm install -g directionallylocal";
|
|
26
|
+
class Interrupted extends Error {
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Opens the session, or returns undefined when this isn't a project dl can open one for
|
|
30
|
+
* (not a Git repository, origin not on GitHub, nothing pushed yet, or no place to pick),
|
|
31
|
+
* after a one-line note for the last three, so the caller shows the overview instead.
|
|
32
|
+
*/
|
|
33
|
+
export async function projectSession(input, io) {
|
|
34
|
+
const c = palette(io.color === true);
|
|
35
|
+
const note = (text) => io.err(` ${c.yellow("!")} ${text}\n`);
|
|
36
|
+
const root = await gitRoot(input.cwd);
|
|
37
|
+
if (!root)
|
|
38
|
+
return undefined;
|
|
39
|
+
let repository;
|
|
40
|
+
try {
|
|
41
|
+
repository = await detectGitHubRepository(root);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
note(`${basename(root)}'s origin isn't on GitHub, so there's no agent conversation to open here.`);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
let target;
|
|
48
|
+
try {
|
|
49
|
+
target = await resolveTarget(input.server);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (input.server !== undefined)
|
|
53
|
+
throw error;
|
|
54
|
+
note(describeError(error));
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const { remote } = target;
|
|
58
|
+
const key = { place: remote.name, repository, root };
|
|
59
|
+
const file = input.sessionsFile ?? sessionsPath();
|
|
60
|
+
const autopilot = await (input.autopilot ?? findAutopilot)();
|
|
61
|
+
// Asked while the tunnel opens; it only matters right before launching.
|
|
62
|
+
const ready = autopilot ? (input.probe ?? supportsAttachRemote)(autopilot).catch(() => false) : Promise.resolve(false);
|
|
63
|
+
const missing = () => {
|
|
64
|
+
io.err(` ${c.red("✗")} The Autopilot that comes with dl ${autopilot ? "is too old to open a session" : "isn't installed"}. Update: ${c.bold(UPDATE)}\n`);
|
|
65
|
+
return 1;
|
|
66
|
+
};
|
|
67
|
+
// Ctrl-C (or a hang-up) before Autopilot starts stops dl, closing the tunnel on the way out;
|
|
68
|
+
// once it runs, Autopilot handles Ctrl-C itself and dl only passes on SIGTERM/SIGHUP.
|
|
69
|
+
const signals = input.signals ?? process;
|
|
70
|
+
let child;
|
|
71
|
+
let interrupt = () => { };
|
|
72
|
+
const interrupted = new Promise((_, reject) => { interrupt = () => reject(new Interrupted()); });
|
|
73
|
+
interrupted.catch(() => { });
|
|
74
|
+
const handlers = FORWARDED.map((name) => {
|
|
75
|
+
const handler = () => {
|
|
76
|
+
if (!child)
|
|
77
|
+
interrupt();
|
|
78
|
+
else if (name !== "SIGINT")
|
|
79
|
+
child.kill(name);
|
|
80
|
+
};
|
|
81
|
+
signals.on(name, handler);
|
|
82
|
+
return [name, handler];
|
|
83
|
+
});
|
|
84
|
+
const step = (work) => Promise.race([work, interrupted]);
|
|
85
|
+
try {
|
|
86
|
+
return await withConnections(input.open ?? openRemoteForSession, async (connections) => {
|
|
87
|
+
const client = await step(connections.client(remote));
|
|
88
|
+
let remembered = input.fresh ? undefined : await findSession(key, file);
|
|
89
|
+
if (remembered) {
|
|
90
|
+
const alive = await step(client.getConversation(remembered.conversationId).then((conversation) => conversation.status !== "destroyed", (error) => {
|
|
91
|
+
if (error instanceof RemoteApiError && (error.status === 404 || error.status === 410))
|
|
92
|
+
return false;
|
|
93
|
+
throw error;
|
|
94
|
+
}));
|
|
95
|
+
if (!alive) {
|
|
96
|
+
await forgetSession(key, file);
|
|
97
|
+
note(`The last conversation for ${repository} on ${remote.name} is gone; starting a new one.`);
|
|
98
|
+
remembered = undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const scratch = await mkdtemp(join(tmpdir(), "dl-session-"));
|
|
102
|
+
try {
|
|
103
|
+
const stateFile = join(scratch, "state.json");
|
|
104
|
+
const args = ["attach-remote", "--state-file", stateFile, "--title", `${remote.name} · ${repository}`];
|
|
105
|
+
let detail;
|
|
106
|
+
if (remembered) {
|
|
107
|
+
args.push("--conversation", remembered.conversationId);
|
|
108
|
+
detail = remembered.conversationId.slice(0, 8);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
let snapshot;
|
|
112
|
+
try {
|
|
113
|
+
snapshot = await step(snapshotWorkingTree(root));
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof Interrupted)
|
|
117
|
+
throw error;
|
|
118
|
+
note(`${describeError(error)}.`.replace(/\.\.$/, "."));
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const [workspaces, models, preferred] = await step(Promise.all([
|
|
122
|
+
client.workspaces(),
|
|
123
|
+
client.models(),
|
|
124
|
+
input.model ? Promise.resolve(undefined) : (input.autopilotModel ?? (() => readAutopilotModel()))(),
|
|
125
|
+
]));
|
|
126
|
+
const workspace = workspaces[0];
|
|
127
|
+
if (!workspace)
|
|
128
|
+
throw new Error(`${remote.name} has no workspace this laptop can use`);
|
|
129
|
+
const model = pickModel(models, input.model, preferred);
|
|
130
|
+
args.push("--workspace", workspace.id, "--repo", repository, "--model", model, "--base-commit", snapshot.baseCommit);
|
|
131
|
+
if (snapshot.patch) {
|
|
132
|
+
const patchFile = join(scratch, "changes.patch");
|
|
133
|
+
await writeFile(patchFile, snapshot.patch, { mode: 0o600 });
|
|
134
|
+
args.push("--patch-file", patchFile);
|
|
135
|
+
}
|
|
136
|
+
const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
|
|
137
|
+
detail = `new conversation from ${snapshot.baseCommit.slice(0, 7)}${changes}, ${model}`;
|
|
138
|
+
}
|
|
139
|
+
if (!autopilot || !(await step(ready)))
|
|
140
|
+
return missing();
|
|
141
|
+
// `dl new`: the old conversation keeps running on the place (dl runs lists it).
|
|
142
|
+
if (input.fresh)
|
|
143
|
+
await forgetSession(key, file);
|
|
144
|
+
io.err(`\n ${c.cyan("·")} Connecting to ${c.bold(remote.name)} · ${repository} … ${c.dim(`(${detail})`)}\n`);
|
|
145
|
+
// The token goes in the environment only: argv is visible to every user on the machine.
|
|
146
|
+
const env = { ...process.env, AUTOPILOT_REMOTE_URL: client.baseUrl, AUTOPILOT_REMOTE_TOKEN: remote.token };
|
|
147
|
+
let saved = remembered?.conversationId;
|
|
148
|
+
const save = async () => {
|
|
149
|
+
const state = await readState(stateFile);
|
|
150
|
+
if (!state || state.conversationId === saved)
|
|
151
|
+
return;
|
|
152
|
+
saved = state.conversationId;
|
|
153
|
+
await rememberSession({ ...key, conversationId: state.conversationId, ...(state.branch ? { branch: state.branch } : {}) }, file);
|
|
154
|
+
};
|
|
155
|
+
// Remember it as soon as it exists, so a closed terminal doesn't lose it.
|
|
156
|
+
const poll = setInterval(() => { void save().catch(() => { }); }, 500);
|
|
157
|
+
poll.unref?.();
|
|
158
|
+
const outcome = await new Promise((resolve) => {
|
|
159
|
+
const started = (input.spawn ?? realSpawn)(autopilot.command, [...autopilot.args, ...args], env);
|
|
160
|
+
child = started;
|
|
161
|
+
started.on("exit", (code) => resolve({ code }));
|
|
162
|
+
started.on("error", (error) => resolve(error));
|
|
163
|
+
}).finally(() => clearInterval(poll));
|
|
164
|
+
await save().catch(() => { });
|
|
165
|
+
if (outcome instanceof Error)
|
|
166
|
+
return missing();
|
|
167
|
+
if (outcome.code === 2)
|
|
168
|
+
return missing();
|
|
169
|
+
if (outcome.code !== 0)
|
|
170
|
+
return 1;
|
|
171
|
+
await recordLastUsed(remote.name).catch(() => undefined);
|
|
172
|
+
io.err(`\n It keeps running on ${c.bold(remote.name)}. Type ${c.bold("dl")} here to come back, or ${c.bold("dl runs")}.\n\n`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await rm(scratch, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (!(error instanceof Interrupted))
|
|
182
|
+
throw error;
|
|
183
|
+
io.err("\n");
|
|
184
|
+
return 130;
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
for (const [name, handler] of handlers)
|
|
188
|
+
signals.off(name, handler);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** What `attach-remote --state-file` wrote, once the conversation exists. */
|
|
192
|
+
async function readState(path) {
|
|
193
|
+
const raw = await readFile(path, "utf8").catch(() => undefined);
|
|
194
|
+
if (!raw)
|
|
195
|
+
return undefined;
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(raw);
|
|
198
|
+
if (typeof parsed.conversationId !== "string" || !parsed.conversationId)
|
|
199
|
+
return undefined;
|
|
200
|
+
return { conversationId: parsed.conversationId, ...(typeof parsed.branch === "string" && parsed.branch ? { branch: parsed.branch } : {}) };
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined; // still being written
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function gitRoot(cwd) {
|
|
207
|
+
try {
|
|
208
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
209
|
+
return stdout.trim() || undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** The Autopilot installed with dl (run with this Node), else an `autopilot` on PATH. */
|
|
216
|
+
export async function findAutopilot() {
|
|
217
|
+
const bundled = bundledAutopilot();
|
|
218
|
+
if (bundled)
|
|
219
|
+
return { command: process.execPath, args: [bundled] };
|
|
220
|
+
const onPath = await autopilotOnPath();
|
|
221
|
+
return onPath ? { command: onPath, args: [] } : undefined;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* autopilot-ai's bin script, found from where Node resolves the package for dl. Its exports
|
|
225
|
+
* don't include package.json, so the entry point is resolved and its package root found above it.
|
|
226
|
+
*/
|
|
227
|
+
export function bundledAutopilot(resolve = (specifier) => import.meta.resolve(specifier)) {
|
|
228
|
+
for (const specifier of ["autopilot-ai/package.json", "autopilot-ai"]) {
|
|
229
|
+
let dir;
|
|
230
|
+
try {
|
|
231
|
+
dir = dirname(fileURLToPath(resolve(specifier)));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
for (;;) {
|
|
237
|
+
const manifest = join(dir, "package.json");
|
|
238
|
+
if (existsSync(manifest)) {
|
|
239
|
+
try {
|
|
240
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
241
|
+
if (pkg.name === "autopilot-ai") {
|
|
242
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.autopilot;
|
|
243
|
+
const path = typeof bin === "string" ? resolvePath(dir, bin) : undefined;
|
|
244
|
+
return path && existsSync(path) ? path : undefined;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// not a readable manifest; keep looking up
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const parent = dirname(dir);
|
|
252
|
+
if (parent === dir)
|
|
253
|
+
break;
|
|
254
|
+
dir = parent;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
export async function autopilotOnPath(env = process.env) {
|
|
260
|
+
for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
261
|
+
const candidate = join(dir, "autopilot");
|
|
262
|
+
if (await access(candidate, constants.X_OK).then(() => true, () => false))
|
|
263
|
+
return candidate;
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
/** An Autopilot that knows `attach-remote` lists its options (an older one prints its general help). */
|
|
268
|
+
export async function supportsAttachRemote(autopilot) {
|
|
269
|
+
try {
|
|
270
|
+
const { stdout, stderr } = await execFileAsync(autopilot.command, [...autopilot.args, "attach-remote", "--help"], {
|
|
271
|
+
timeout: 30_000,
|
|
272
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
273
|
+
});
|
|
274
|
+
return `${stdout}${stderr}`.includes("--state-file");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
package/dist/laptop/setup.js
CHANGED
|
@@ -3,21 +3,23 @@ import { access as fileAccess, readdir, readFile } from "node:fs/promises";
|
|
|
3
3
|
import { homedir, hostname } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { TtyPrompter } from "../prompt.js";
|
|
6
|
+
import { PromptCancelled, TtyPrompter } from "../prompt.js";
|
|
7
7
|
import { palette, PlainUi, TerminalUi } from "../ui.js";
|
|
8
|
-
import { loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, saveRemote } from "./config.js";
|
|
8
|
+
import { loadRemotes, loadRemotesFile, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, saveRemote, setDefaultRemote } from "./config.js";
|
|
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
12
|
import { openRemote, withConnections } from "./tunnel.js";
|
|
13
13
|
const RUN_EXAMPLE = 'dl run "fix the flaky checkout test"';
|
|
14
|
+
const PLACES = [
|
|
15
|
+
{ value: "local", label: "On this computer", detail: "start now · nothing to rent · pauses when this computer sleeps" },
|
|
16
|
+
{ value: "server", label: "On my own server", detail: "keeps going when your laptop is closed · needs SSH access" },
|
|
17
|
+
];
|
|
14
18
|
export async function setupWizard(deps) {
|
|
15
19
|
const c = palette(deps.color);
|
|
16
20
|
deps.write(`\n ${c.bold("Directionally Local")}\n Background coding agents. Where should they run?\n\n`);
|
|
17
|
-
deps.write(` ${c.cyan("1")} ${"On this computer".padEnd(22)} ${c.dim("start now · nothing to rent · pauses when this computer sleeps")}\n`);
|
|
18
|
-
deps.write(` ${c.cyan("2")} ${"On my own server".padEnd(22)} ${c.dim("keeps going when your laptop is closed · needs SSH access")}\n\n`);
|
|
19
21
|
const ui = deps.newUi();
|
|
20
|
-
let where = await
|
|
22
|
+
let where = (await deps.prompter.select(ui.prompt("Where"), PLACES, 0)).value;
|
|
21
23
|
ui.answered("Where", where === "local" ? "On this computer" : "On my own server");
|
|
22
24
|
if (where === "local" && !deps.localSetup) {
|
|
23
25
|
ui.line("info", "On this computer", "coming soon");
|
|
@@ -30,11 +32,15 @@ export async function setupWizard(deps) {
|
|
|
30
32
|
}
|
|
31
33
|
if (where === "local") {
|
|
32
34
|
const keysUi = deps.newUi();
|
|
33
|
-
const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources });
|
|
35
|
+
const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources, place: "this computer" });
|
|
34
36
|
keysUi.finish();
|
|
35
|
-
const remote = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const { remote, capacity } = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
|
|
38
|
+
const remotesFile = deps.remotesFile ?? remotesPath();
|
|
39
|
+
const isNew = !(await loadRemotes(remotesFile)).some((entry) => entry.name === remote.name);
|
|
40
|
+
await saveRemote(remote, remotesFile);
|
|
41
|
+
if (isNew)
|
|
42
|
+
await chooseDefault(deps, remote, remotesFile);
|
|
43
|
+
finishScreen(deps.newUi(), capacity);
|
|
38
44
|
return remote;
|
|
39
45
|
}
|
|
40
46
|
return setUpServer(deps);
|
|
@@ -61,7 +67,8 @@ async function setUpServer(deps) {
|
|
|
61
67
|
// 4. A credential for this laptop, checked through the tunnel every command will use. Each
|
|
62
68
|
// `dl pair` makes a new credential on the server, so a re-run keeps the saved one if it works.
|
|
63
69
|
const remotesFile = deps.remotesFile ?? remotesPath();
|
|
64
|
-
const
|
|
70
|
+
const known = await loadRemotes(remotesFile);
|
|
71
|
+
const saved = known.find((entry) => sameServer(entry, access));
|
|
65
72
|
let remote;
|
|
66
73
|
if (saved && await stillWorks({ ...saved, ssh: access }, deps.open))
|
|
67
74
|
remote = { ...saved, ssh: access };
|
|
@@ -91,25 +98,25 @@ async function setUpServer(deps) {
|
|
|
91
98
|
await saveRemote(remote, remotesFile);
|
|
92
99
|
connectedUi.line("done", "This laptop", `connected as ${remote.name}`);
|
|
93
100
|
connectedUi.finish();
|
|
101
|
+
if (!known.some((entry) => entry.name === paired.name))
|
|
102
|
+
await chooseDefault(deps, remote, remotesFile);
|
|
94
103
|
finishScreen(deps.newUi(), capacity);
|
|
95
104
|
return remote;
|
|
96
105
|
}
|
|
97
106
|
async function chooseServer(deps, hosts) {
|
|
98
|
-
const c = palette(deps.color);
|
|
99
107
|
const ui = deps.newUi();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
deps.write("\n");
|
|
107
|
-
}
|
|
108
|
-
const hint = hosts.length > 0 ? `1-${hosts.length + 1}, or user@host[:port]` : "user@host[:port], e.g. root@203.0.113.10";
|
|
108
|
+
deps.write("\n");
|
|
109
|
+
const example = "user@host[:port], e.g. root@203.0.113.10";
|
|
110
|
+
const options = [
|
|
111
|
+
...hosts.map((host, index) => ({ value: String(index), label: host.alias, detail: describeHost(host) })),
|
|
112
|
+
{ value: "another", label: "Another server", input: { prompt: ui.prompt("Server address", example), placeholder: example } },
|
|
113
|
+
];
|
|
109
114
|
for (let attempt = 1;; attempt++) {
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
115
|
+
// Without saved hosts, a one-item list would only be in the way: just ask for the address.
|
|
116
|
+
const picked = hosts.length > 0
|
|
117
|
+
? await deps.prompter.select(ui.prompt("Server"), options, 0)
|
|
118
|
+
: { value: "another", text: (await deps.prompter.text(ui.prompt("Server", example))).trim() };
|
|
119
|
+
const host = picked.value === "another" ? undefined : hosts[Number(picked.value)];
|
|
113
120
|
if (host) {
|
|
114
121
|
ui.answered("SSH", host.alias);
|
|
115
122
|
return {
|
|
@@ -118,12 +125,8 @@ async function chooseServer(deps, hosts) {
|
|
|
118
125
|
display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
|
|
119
126
|
};
|
|
120
127
|
}
|
|
121
|
-
if (index === hosts.length && hosts.length > 0) {
|
|
122
|
-
ui.answered("SSH", "another server");
|
|
123
|
-
answer = (await deps.prompter.text(ui.prompt("Server address", "user@host[:port], e.g. root@203.0.113.10"))).trim();
|
|
124
|
-
}
|
|
125
128
|
try {
|
|
126
|
-
const access = parseTarget(
|
|
129
|
+
const access = parseTarget(picked.text ?? "");
|
|
127
130
|
ui.answered("SSH", access.target);
|
|
128
131
|
return { access, display: `${access.target}${access.port ? `:${access.port}` : ""}` };
|
|
129
132
|
}
|
|
@@ -134,6 +137,35 @@ async function chooseServer(deps, hosts) {
|
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* With two or more places set up, where `dl run` sends tasks unless told otherwise. Asked
|
|
142
|
+
* only when setup adds a place, with the new one preselected; the first place becomes the
|
|
143
|
+
* default by itself (saveRemote).
|
|
144
|
+
*/
|
|
145
|
+
async function chooseDefault(deps, added, remotesFile) {
|
|
146
|
+
const { remotes } = await loadRemotesFile(remotesFile);
|
|
147
|
+
if (remotes.length < 2)
|
|
148
|
+
return;
|
|
149
|
+
const ui = deps.newUi();
|
|
150
|
+
deps.write("\n");
|
|
151
|
+
const options = remotes.map((remote) => ({ value: remote.name, label: remote.name, detail: describePlace(remote) }));
|
|
152
|
+
const initial = Math.max(0, remotes.findIndex((remote) => remote.name === added.name));
|
|
153
|
+
const { value } = await deps.prompter.select(ui.prompt("Where should tasks go by default?"), options, initial);
|
|
154
|
+
await setDefaultRemote(value, remotesFile);
|
|
155
|
+
ui.answered("Default", value);
|
|
156
|
+
ui.finish();
|
|
157
|
+
}
|
|
158
|
+
function describePlace(remote) {
|
|
159
|
+
if (remote.ssh)
|
|
160
|
+
return `server · ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
|
|
161
|
+
try {
|
|
162
|
+
const host = new URL(remote.url).hostname;
|
|
163
|
+
return host === "127.0.0.1" || host === "localhost" ? "this computer" : `server · ${host}`;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return remote.url;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
137
169
|
/**
|
|
138
170
|
* Connects and checks the server. If it refuses the default keys (ssh's own: the agent and
|
|
139
171
|
* ~/.ssh/id_*), each key pair in ~/.ssh is tried in turn with -i, without asking: auth
|
|
@@ -246,25 +278,13 @@ export function serverLabel(text) {
|
|
|
246
278
|
}
|
|
247
279
|
function finishScreen(ui, capacity) {
|
|
248
280
|
ui.ready(capacity ? `Ready · fits ~${capacity} agents at once (estimate)` : "Ready", [
|
|
249
|
-
{ label: "
|
|
250
|
-
{ label: "
|
|
251
|
-
]);
|
|
281
|
+
{ label: "Start a session: cd into a GitHub repository, then type", value: "dl" },
|
|
282
|
+
{ label: "Or hand off a one-off task", value: RUN_EXAMPLE },
|
|
283
|
+
], { footer: "dl status shows where agents run and what they're doing." });
|
|
252
284
|
}
|
|
253
285
|
function laptopName(name) {
|
|
254
286
|
return name.replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
|
|
255
287
|
}
|
|
256
|
-
async function pick(ui, prompter, label, hint, choices) {
|
|
257
|
-
const values = Object.values(choices);
|
|
258
|
-
for (let attempt = 1;; attempt++) {
|
|
259
|
-
const answer = (await prompter.text(ui.prompt(label, hint))).trim();
|
|
260
|
-
const choice = answer === "" ? values[0] : choices[answer];
|
|
261
|
-
if (choice)
|
|
262
|
-
return choice;
|
|
263
|
-
if (attempt >= 3)
|
|
264
|
-
throw new Error(`Pick one of ${Object.keys(choices).join(", ")}`);
|
|
265
|
-
ui.rejected(`Type ${Object.keys(choices).join(" or ")}.`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
288
|
/** `dl setup` with the real terminal, ssh, gh, and filesystem. */
|
|
269
289
|
export async function setup(options) {
|
|
270
290
|
const { io, terminal } = options;
|
|
@@ -277,25 +297,34 @@ export async function setup(options) {
|
|
|
277
297
|
identityFile = resolve(options.identityFile.replace(/^~(?=$|\/)/, home).replace(/\.pub$/, ""));
|
|
278
298
|
await fileAccess(identityFile).catch(() => { throw new Error(`No SSH key at ${options.identityFile} (--identity takes the private key, e.g. ~/.ssh/id_ed25519)`); });
|
|
279
299
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
+
try {
|
|
301
|
+
await setupWizard({
|
|
302
|
+
newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
|
|
303
|
+
prompter: new TtyPrompter({ color }),
|
|
304
|
+
write: io.out,
|
|
305
|
+
color,
|
|
306
|
+
ssh: realSshExec,
|
|
307
|
+
open: openRemote,
|
|
308
|
+
sources: {
|
|
309
|
+
env: process.env,
|
|
310
|
+
home,
|
|
311
|
+
readFile: (path) => readFile(path, "utf8").catch(() => undefined),
|
|
312
|
+
listDir: (path) => readdir(path).catch(() => []),
|
|
313
|
+
gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
|
|
314
|
+
fetch: globalThis.fetch,
|
|
315
|
+
},
|
|
316
|
+
hostname: hostname(),
|
|
317
|
+
installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
|
|
318
|
+
...(options.localSetup ? { localSetup: options.localSetup } : {}),
|
|
319
|
+
...(identityFile ? { identityFile } : {}),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (!(error instanceof PromptCancelled))
|
|
324
|
+
throw error;
|
|
325
|
+
// Esc / Ctrl-C at a question: the terminal is restored; say so quietly and stop.
|
|
326
|
+
io.out(` ${palette(color).dim("Cancelled. Run")} dl setup ${palette(color).dim("when you're ready.")}\n\n`);
|
|
327
|
+
return 130;
|
|
328
|
+
}
|
|
300
329
|
return 0;
|
|
301
330
|
}
|
package/dist/laptop/tunnel.js
CHANGED
|
@@ -57,6 +57,14 @@ export function createOpener(options = {}) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
export const openRemote = createOpener();
|
|
60
|
+
/**
|
|
61
|
+
* For an interactive session: ssh in its own process group, so Ctrl-C typed into the agent's
|
|
62
|
+
* screen (which the terminal sends to its whole foreground group) doesn't drop the tunnel.
|
|
63
|
+
* The session closes it itself when the agent exits, or when dl is told to stop.
|
|
64
|
+
*/
|
|
65
|
+
export const openRemoteForSession = createOpener({
|
|
66
|
+
spawn: (args) => spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"], detached: true }),
|
|
67
|
+
});
|
|
60
68
|
/**
|
|
61
69
|
* The endpoints one command uses: at most one tunnel per server, reused across its
|
|
62
70
|
* requests, all closed when the command ends (commands close it in `finally`, which also
|
package/dist/layout.js
CHANGED
|
@@ -8,6 +8,8 @@ export const PATHS = {
|
|
|
8
8
|
hotcellEnv: "/etc/hotcell/hotcell.env",
|
|
9
9
|
hotcellEtc: "/etc/hotcell",
|
|
10
10
|
hotcellHome: "/var/lib/hotcell",
|
|
11
|
+
/** OpenRouter's prices, so Hotcell's spend-capped gateway forwards current models. */
|
|
12
|
+
modelPrices: "/var/lib/hotcell/model-prices.json",
|
|
11
13
|
autopilotEtc: "/etc/autopilot",
|
|
12
14
|
autopilotEnv: "/etc/autopilot/autopilot.env",
|
|
13
15
|
githubEnv: "/etc/autopilot/github.env",
|
|
@@ -70,6 +72,8 @@ export function hotcellEnv(input) {
|
|
|
70
72
|
HOTCELL_EGRESS_HOST: input.bridgeIp,
|
|
71
73
|
HOTCELL_EGRESS_PORT: String(HOTCELL_EGRESS_PORT),
|
|
72
74
|
HOTCELL_EGRESS_ADVERTISE_HOST: "host.docker.internal",
|
|
75
|
+
// Under a spend cap the gateway refuses models it can't price; `dl up` refreshes this list.
|
|
76
|
+
HOTCELL_MODEL_PRICES: PATHS.modelPrices,
|
|
73
77
|
});
|
|
74
78
|
}
|
|
75
79
|
export function autopilotEnv(input) {
|
package/dist/local/cli.js
CHANGED
|
@@ -28,12 +28,15 @@ export function formatLocalStatus(status, c, home) {
|
|
|
28
28
|
const mark = (ok) => (ok ? c.green("✓") : c.red("✗"));
|
|
29
29
|
const line = (ok, label, detail) => ` ${mark(ok)} ${label.padEnd(12)}${c.dim(detail)}\n`;
|
|
30
30
|
const describe = (entry) => entry.running ? `running${entry.pid ? ` (pid ${entry.pid})` : ""}` : entry.installed ? "stopped" : "not installed";
|
|
31
|
-
const healthy = services.hotcell.running && services.autopilot.running && status.answering && status.dockerRunning;
|
|
31
|
+
const healthy = services.hotcell.running && services.autopilot.running && status.answering && status.dockerRunning && status.modelPrices.ok;
|
|
32
32
|
return [
|
|
33
33
|
`\n ${c.bold("Directionally Local")} ${c.dim("on this computer")} ${healthy ? c.green("running") : c.red("needs attention")}\n\n`,
|
|
34
34
|
line(status.dockerRunning, "Docker", status.dockerRunning ? state.dockerRuntime : `${state.dockerRuntime} isn't running; start it`),
|
|
35
35
|
line(services.hotcell.running, "Hotcell", `${describe(services.hotcell)} · 127.0.0.1:${state.ports.hotcell} · ${state.hotcellVersion}`),
|
|
36
36
|
line(services.autopilot.running && status.answering, "Autopilot", `${describe(services.autopilot)}${status.answering ? "" : ", not answering"} · ${state.url} · ${state.autopilotVersion}`),
|
|
37
|
+
status.modelPrices.warn
|
|
38
|
+
? ` ${c.yellow("!")} ${"Prices".padEnd(12)}${c.dim(status.modelPrices.detail)}\n`
|
|
39
|
+
: line(status.modelPrices.ok, "Prices", status.modelPrices.detail),
|
|
37
40
|
`\n ${c.dim("Fits")} ${status.capacity !== undefined ? `~${status.capacity} agents at once (estimate, from Docker's memory)` : "unknown while Docker is stopped"}\n`,
|
|
38
41
|
` ${c.dim("Installed")} ${short(status.paths.current)}\n`,
|
|
39
42
|
` ${c.dim("Logs")} ${short(status.paths.logs)}\n`,
|
package/dist/local/layout.js
CHANGED
|
@@ -21,6 +21,7 @@ export function localPaths(home, platform) {
|
|
|
21
21
|
githubEnv: join(config, "github.env"),
|
|
22
22
|
asterConfig: join(config, "aster.json"),
|
|
23
23
|
hotcellHome: join(root, "hotcell"),
|
|
24
|
+
modelPrices: join(root, "hotcell", "model-prices.json"),
|
|
24
25
|
autopilotHome,
|
|
25
26
|
workspace: join(autopilotHome, "workspaces", "default"),
|
|
26
27
|
services: platform === "darwin" ? join(home, "Library", "LaunchAgents") : join(home, ".config", "systemd", "user"),
|
|
@@ -89,6 +90,8 @@ export function localHotcellEnv(input) {
|
|
|
89
90
|
HOTCELL_EGRESS_HOST: input.net.listenHost,
|
|
90
91
|
HOTCELL_EGRESS_PORT: String(ports.hotcellEgress),
|
|
91
92
|
HOTCELL_EGRESS_ADVERTISE_HOST: "host.docker.internal",
|
|
93
|
+
// Under a spend cap the gateway refuses models it can't price; setup refreshes this list.
|
|
94
|
+
HOTCELL_MODEL_PRICES: paths.modelPrices,
|
|
92
95
|
});
|
|
93
96
|
}
|
|
94
97
|
export function localAutopilotEnv(input) {
|
package/dist/local/setup.js
CHANGED
|
@@ -28,6 +28,7 @@ import { removeRemote } from "../laptop/config.js";
|
|
|
28
28
|
import { RealSystem } from "../system.js";
|
|
29
29
|
import { checkComputer, computerProblems, localCapacity, servicePath, supportedPlatform } from "./check.js";
|
|
30
30
|
import { LOCAL_SERVICES, PAIRED_NAME, PREFERRED_PORTS, autopilotXdg, localAsterConfig, localAutopilotBin, localAutopilotEnv, localGithubEnv, localHotcellBin, localHotcellEnv, localPaths, parseLocalEnvFile, serviceSpecs, } from "./layout.js";
|
|
31
|
+
import { fileMtime, modelPricesHealth, refreshModelPrices } from "../model-prices.js";
|
|
31
32
|
import { httpStatus, portFree } from "./probes.js";
|
|
32
33
|
import { installService, removeServices, serviceState, stopService, userId } from "./services.js";
|
|
33
34
|
import { readLocalState, writeLocalState } from "./state.js";
|
|
@@ -153,6 +154,14 @@ export async function setUpOnThisComputer(input) {
|
|
|
153
154
|
const previousAutopilot = parseLocalEnvFile(await system.readFile(paths.autopilotEnv));
|
|
154
155
|
const ports = await choosePorts(ctx, node, net, previousPorts(previous, previousAutopilot));
|
|
155
156
|
const apiKey = previous.HOTCELL_API_KEY || randomBytes(24).toString("hex");
|
|
157
|
+
// Hotcell reads it once at start, so it's in place before the service starts below.
|
|
158
|
+
const prices = await refreshModelPrices(system, {
|
|
159
|
+
path: paths.modelPrices,
|
|
160
|
+
write: { mode: 0o644 },
|
|
161
|
+
fetchImpl: input.fetchImpl ?? fetch,
|
|
162
|
+
warn: (text) => ui.warn(text),
|
|
163
|
+
retry: "dl setup",
|
|
164
|
+
});
|
|
156
165
|
await system.writeFile(paths.hotcellEnv, localHotcellEnv({ paths, apiKey, ports, net }), { mode: 0o600, secret: true });
|
|
157
166
|
if (!(await system.readFile(paths.asterConfig)))
|
|
158
167
|
await system.writeFile(paths.asterConfig, localAsterConfig(paths), { mode: 0o600 });
|
|
@@ -168,7 +177,7 @@ export async function setUpOnThisComputer(input) {
|
|
|
168
177
|
// Without a token the Aster API answers 401: up, and refusing strangers.
|
|
169
178
|
await waitUntil(ctx, "Autopilot", join(paths.logs, LOCAL_SERVICES.autopilot.log), async () => await httpStatus(system, node, `http://127.0.0.1:${ports.autopilot}/api/v1/health`) === 401);
|
|
170
179
|
await pruneReleases(system, paths, release);
|
|
171
|
-
return { value: ports, detail: `start at login · logs in ${tilde(paths.logs, ctx.home)}` };
|
|
180
|
+
return { value: ports, detail: `start at login · logs in ${tilde(paths.logs, ctx.home)}${prices ? ` · ${prices}` : ""}` };
|
|
172
181
|
});
|
|
173
182
|
const url = `http://127.0.0.1:${ports.autopilot}`;
|
|
174
183
|
return await step("connect", async () => {
|
|
@@ -212,10 +221,8 @@ export async function setUpOnThisComputer(input) {
|
|
|
212
221
|
expiresAt: new Date(credential.expiresAt).toISOString(),
|
|
213
222
|
addedAt: at,
|
|
214
223
|
};
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
detail: `${url.replace(/^http:\/\//, "")} · fits ~${localCapacity(docker)} at once · tasks pause while this computer sleeps`,
|
|
218
|
-
};
|
|
224
|
+
// The address is an implementation detail here; how many fit goes on the wizard's Ready line.
|
|
225
|
+
return { value: { remote, capacity: localCapacity(docker) }, detail: "this computer · tasks pause while it sleeps" };
|
|
219
226
|
});
|
|
220
227
|
}
|
|
221
228
|
/**
|
|
@@ -296,7 +303,8 @@ export async function localStatus(options = {}) {
|
|
|
296
303
|
capacity = localCapacity({ memoryMb: Math.round(Number(mem) / 1024 / 1024), cpus: Number(cpus) || 0 });
|
|
297
304
|
}
|
|
298
305
|
}
|
|
299
|
-
|
|
306
|
+
const modelPrices = modelPricesHealth(await system.readFile(paths.modelPrices), await fileMtime(system, paths.modelPrices, platform), (options.now ?? (() => new Date()))(), "dl setup");
|
|
307
|
+
return { installed: Boolean(state), state, services: { hotcell, autopilot }, answering, capacity, dockerRunning, modelPrices, paths, platform: ctx.rawPlatform };
|
|
300
308
|
}
|
|
301
309
|
function checkOptions(ctx, options) {
|
|
302
310
|
return {
|