directionallylocal 0.4.1 → 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/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ 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 { projectSession } from "./laptop/session.js";
10
11
  import { setup } from "./laptop/setup.js";
11
12
  import { localCommand } from "./local/cli.js";
12
13
  import { setUpOnThisComputer } from "./local/setup.js";
@@ -23,6 +24,9 @@ Send a task; it runs in its own sandbox and comes back as a pull request.
23
24
  On your laptop:
24
25
  dl set up the first time; afterwards, an overview
25
26
  dl setup set up or repair where your agents run (safe to re-run)
27
+ dl (in a GitHub repo) talk to the repository's agent; it keeps running when you leave
28
+ dl new in a GitHub repo: start a fresh conversation for it
29
+ dl status where your agents run and what they're doing (works anywhere)
26
30
  dl connect URL connect to a server someone else set up (approve the code it shows)
27
31
  dl run "TASK" run a task, from inside a GitHub repository
28
32
  (your uncommitted changes go along; Ctrl-C stops watching, not the task)
@@ -206,8 +210,15 @@ export async function main(argv, io = {
206
210
  if (!state) {
207
211
  if (values.brief)
208
212
  return 0;
209
- // A laptop connected to servers gets its own home; otherwise, what this is and how to start.
210
- if (!command && await laptopHome(laptopIO))
213
+ // Inside a GitHub repository, that repository's conversation; elsewhere, the laptop's home.
214
+ if (!command && terminal && (await loadRemotes()).length > 0) {
215
+ const code = await projectSession({ cwd: process.cwd(), ...(values.server ? { server: values.server } : {}), ...(values.model ? { model: values.model } : {}) }, laptopIO);
216
+ if (code !== undefined)
217
+ return code;
218
+ }
219
+ // A laptop connected to servers gets its own home (`dl status` shows it from anywhere);
220
+ // otherwise, what this is and how to start.
221
+ if ((!command || command === "status") && await laptopHome(laptopIO))
211
222
  return 0;
212
223
  // A laptop with nothing set up yet: the wizard (it needs someone to answer it).
213
224
  if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
@@ -271,6 +282,13 @@ export async function main(argv, io = {
271
282
  await pull({ id, cwd: process.cwd(), ...server }, laptopIO);
272
283
  return 0;
273
284
  }
285
+ case "new": {
286
+ const code = await projectSession({ cwd: process.cwd(), fresh: true, ...(values.server ? { server: values.server } : {}), ...(values.model ? { model: values.model } : {}) }, laptopIO);
287
+ if (code !== undefined)
288
+ return code;
289
+ io.err(` dl new starts a fresh conversation from inside a GitHub repository (with something set up: dl setup).\n`);
290
+ return 1;
291
+ }
274
292
  case "servers":
275
293
  return await servers(parsed.positionals.slice(1), laptopIO);
276
294
  case "use":
@@ -283,7 +283,8 @@ export async function laptopHome(io = processIO, open) {
283
283
  io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
284
284
  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
285
  io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
286
- io.out(` ${c.dim("Send a task, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
286
+ io.out(` ${c.dim("Start a session: cd into a GitHub repository, then type")} ${c.bold("dl")}\n`);
287
+ io.out(` ${c.dim("Or hand off a one-off task:")} ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
287
288
  return true;
288
289
  }
289
290
  async function follow(client, remote, conversationId, cursor, signal, io, replayUntil) {
@@ -45,14 +45,14 @@ export async function gatherKeys(needs, input) {
45
45
  const wanted = needs.filter((need) => need === "github" || need === "openrouter");
46
46
  if (wanted.length === 0)
47
47
  return answers;
48
- ui.questions(`${wanted.length === 1 ? "1 key" : "2 keys"}, kept on your server only; then it sets itself up.`);
48
+ ui.questions(`${wanted.length === 1 ? "1 key" : "2 keys"}, kept on ${input.place ?? "your server"} only; then it sets itself up.`);
49
49
  if (wanted.includes("github"))
50
50
  answers.githubToken = await githubToken(input);
51
51
  if (wanted.includes("openrouter"))
52
52
  answers.openrouterKey = await openrouterKey(input);
53
53
  return answers;
54
54
  }
55
- export async function githubToken({ ui, prompter, sources }) {
55
+ export async function githubToken({ ui, prompter, sources, place = "your server" }) {
56
56
  const fromCli = (await sources.gh().catch(() => undefined))?.trim();
57
57
  if (fromCli) {
58
58
  const checked = await validateGitHubToken(fromCli, sources.fetch);
@@ -61,7 +61,7 @@ export async function githubToken({ ui, prompter, sources }) {
61
61
  }
62
62
  else {
63
63
  const who = checked.login ? ` (${checked.login})` : "";
64
- if (await confirm(ui, prompter, `Use your GitHub CLI login${who}? It stays on your server.`)) {
64
+ if (await confirm(ui, prompter, `Use your GitHub CLI login${who}? It stays on ${place}.`)) {
65
65
  ui.answered("GitHub", `${checked.login ? `signed in as ${checked.login} · ` : ""}from the GitHub CLI`);
66
66
  return fromCli;
67
67
  }
@@ -73,7 +73,7 @@ export async function githubToken({ ui, prompter, sources }) {
73
73
  return { value: token, ...(checked.problem ? { problem: checked.problem } : {}), ...(checked.login ? { detail: `signed in as ${checked.login}` } : {}) };
74
74
  });
75
75
  }
76
- export async function openrouterKey({ ui, prompter, sources }) {
76
+ export async function openrouterKey({ ui, prompter, sources, place = "your server" }) {
77
77
  const candidates = [];
78
78
  const fromEnv = sources.env.OPENROUTER_API_KEY?.trim();
79
79
  if (fromEnv)
@@ -88,7 +88,7 @@ export async function openrouterKey({ ui, prompter, sources }) {
88
88
  ui.warn(`The OpenRouter key ${candidate.from} didn't work (${problem})`);
89
89
  continue;
90
90
  }
91
- if (await confirm(ui, prompter, `${candidate.question} It stays on your server.`)) {
91
+ if (await confirm(ui, prompter, `${candidate.question} It stays on ${place}.`)) {
92
92
  ui.answered("OpenRouter", candidate.from);
93
93
  return candidate.key;
94
94
  }
@@ -0,0 +1,62 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export function sessionsPath(env = process.env) {
5
+ const configHome = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
6
+ return join(configHome, "directionallylocal", "sessions.json");
7
+ }
8
+ export async function loadSessions(path = sessionsPath()) {
9
+ let raw;
10
+ try {
11
+ raw = await readFile(path, "utf8");
12
+ }
13
+ catch (error) {
14
+ if (error.code === "ENOENT")
15
+ return [];
16
+ throw error;
17
+ }
18
+ try {
19
+ const parsed = JSON.parse(raw);
20
+ return Array.isArray(parsed.sessions) ? parsed.sessions.filter(isEntry) : [];
21
+ }
22
+ catch {
23
+ // A damaged file only costs a new conversation; it gets rewritten on the next save.
24
+ return [];
25
+ }
26
+ }
27
+ export async function findSession(key, path = sessionsPath()) {
28
+ return (await loadSessions(path)).find((entry) => sameKey(entry, key));
29
+ }
30
+ /** Remembers (or replaces) the conversation for this place + repository + checkout. */
31
+ export async function rememberSession(entry, path = sessionsPath()) {
32
+ const kept = (await loadSessions(path)).filter((existing) => !sameKey(existing, entry));
33
+ kept.push({ ...entry, updatedAt: new Date().toISOString() });
34
+ await writeSessions(kept, path);
35
+ }
36
+ /** Forgets this key's conversation (it keeps running on the place); true when there was one. */
37
+ export async function forgetSession(key, path = sessionsPath()) {
38
+ const sessions = await loadSessions(path);
39
+ const kept = sessions.filter((entry) => !sameKey(entry, key));
40
+ if (kept.length === sessions.length)
41
+ return false;
42
+ await writeSessions(kept, path);
43
+ return true;
44
+ }
45
+ function sameKey(a, b) {
46
+ return a.place === b.place && a.repository === b.repository && a.root === b.root;
47
+ }
48
+ async function writeSessions(sessions, path) {
49
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
50
+ const temp = `${path}.${process.pid}.tmp`;
51
+ const body = { version: 1, sessions };
52
+ await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
53
+ await chmod(temp, 0o600);
54
+ await rename(temp, path);
55
+ }
56
+ function isEntry(value) {
57
+ if (!value || typeof value !== "object")
58
+ return false;
59
+ const entry = value;
60
+ return ["place", "repository", "root", "conversationId", "updatedAt"].every((key) => typeof entry[key] === "string") &&
61
+ (entry.branch === undefined || typeof entry.branch === "string");
62
+ }
@@ -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
+ }
@@ -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 pick(ui, deps.prompter, "Where", "1 or 2 · Enter for 1", { "1": "local", "2": "server" });
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
- await saveRemote(remote, deps.remotesFile ?? remotesPath());
37
- finishScreen(deps.newUi(), undefined);
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 saved = (await loadRemotes(remotesFile)).find((entry) => sameServer(entry, access));
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
- if (hosts.length > 0) {
101
- deps.write(`\n ${c.dim("Your servers in ~/.ssh/config:")}\n`);
102
- hosts.forEach((host, index) => deps.write(` ${c.cyan(String(index + 1))} ${host.alias.padEnd(22)} ${c.dim(describeHost(host))}\n`));
103
- deps.write(` ${c.cyan(String(hosts.length + 1))} Another server\n\n`);
104
- }
105
- else {
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
- let answer = (await deps.prompter.text(ui.prompt("Server", hint))).trim();
111
- const index = /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
112
- const host = hosts[index];
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(answer);
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: "From any GitHub repository", value: RUN_EXAMPLE },
250
- { label: "See progress, then check out the result", value: "dl runs\ndl pull ID" },
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
- await setupWizard({
281
- newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
282
- prompter: new TtyPrompter(),
283
- write: io.out,
284
- color,
285
- ssh: realSshExec,
286
- open: openRemote,
287
- sources: {
288
- env: process.env,
289
- home,
290
- readFile: (path) => readFile(path, "utf8").catch(() => undefined),
291
- listDir: (path) => readdir(path).catch(() => []),
292
- gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
293
- fetch: globalThis.fetch,
294
- },
295
- hostname: hostname(),
296
- installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
297
- ...(options.localSetup ? { localSetup: options.localSetup } : {}),
298
- ...(identityFile ? { identityFile } : {}),
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
  }
@@ -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
@@ -221,10 +221,8 @@ export async function setUpOnThisComputer(input) {
221
221
  expiresAt: new Date(credential.expiresAt).toISOString(),
222
222
  addedAt: at,
223
223
  };
224
- return {
225
- value: remote,
226
- detail: `${url.replace(/^http:\/\//, "")} · fits ~${localCapacity(docker)} at once · tasks pause while this computer sleeps`,
227
- };
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" };
228
226
  });
229
227
  }
230
228
  /**
package/dist/prompt.js CHANGED
@@ -1,11 +1,52 @@
1
1
  import { openSync, closeSync } from "node:fs";
2
2
  import { createInterface } from "node:readline";
3
3
  import { ReadStream, WriteStream } from "node:tty";
4
+ import { palette } from "./ui.js";
5
+ /** Thrown when someone presses Esc or Ctrl-C at a question; the terminal is already restored. */
6
+ export class PromptCancelled extends Error {
7
+ constructor() {
8
+ super("Cancelled.");
9
+ this.name = "PromptCancelled";
10
+ }
11
+ }
12
+ function openDevTty() {
13
+ let fd;
14
+ try {
15
+ fd = openSync("/dev/tty", "r+");
16
+ }
17
+ catch {
18
+ throw new Error("No terminal to ask on; pass the answers as environment variables (see `dl --help`).");
19
+ }
20
+ const input = new ReadStream(fd);
21
+ const output = new WriteStream(fd);
22
+ return {
23
+ input,
24
+ output,
25
+ close: () => {
26
+ input.destroy();
27
+ output.destroy();
28
+ try {
29
+ closeSync(fd);
30
+ }
31
+ catch { /* already closed with the streams */ }
32
+ },
33
+ };
34
+ }
35
+ const HIDE_CURSOR = "\x1b[?25l";
36
+ const SHOW_CURSOR = "\x1b[?25h";
4
37
  /**
5
38
  * Reads from /dev/tty, so prompts work under `curl … | sh` where stdin is the script.
6
39
  * Without a terminal every question fails with the env var that answers it.
7
40
  */
8
41
  export class TtyPrompter {
42
+ open;
43
+ color;
44
+ env;
45
+ constructor(options = {}) {
46
+ this.open = options.open ?? openDevTty;
47
+ this.env = options.env ?? process.env;
48
+ this.color = options.color ?? (!this.env.NO_COLOR && this.env.TERM !== "dumb");
49
+ }
9
50
  async choose(question, options, fallback) {
10
51
  const lines = options.map((option, index) => ` ${index + 1}. ${option.label}`).join("\n");
11
52
  const answer = (await this.ask(`${question}\n${lines}\nChoice [1]: `, false)).trim();
@@ -14,22 +55,50 @@ export class TtyPrompter {
14
55
  const index = Number(answer) - 1;
15
56
  return options[index]?.value ?? options.find((option) => option.value === answer)?.value ?? fallback;
16
57
  }
58
+ async select(question, options, initial = 0) {
59
+ if (options.length === 0)
60
+ throw new Error("select() needs at least one option");
61
+ const start = Math.min(Math.max(0, initial), options.length - 1);
62
+ const terminal = this.open();
63
+ const raw = typeof terminal.input.setRawMode === "function" && terminal.input.isTTY === true && this.env.TERM !== "dumb";
64
+ if (!raw) {
65
+ terminal.close();
66
+ return this.numbered(question, options, start);
67
+ }
68
+ return new ArrowSelect(terminal, question, options, start, this.color).run();
69
+ }
17
70
  secret(question) {
18
71
  return this.ask(question, true);
19
72
  }
20
73
  text(question) {
21
74
  return this.ask(question, false);
22
75
  }
23
- ask(question, hidden) {
24
- let fd;
25
- try {
26
- fd = openSync("/dev/tty", "r+");
27
- }
28
- catch {
29
- throw new Error("No terminal to ask on; pass the answers as environment variables (see `dl --help`).");
76
+ /** The plain fallback: a numbered list, then a typed choice (Enter for the preselected one). */
77
+ async numbered(question, options, start) {
78
+ const c = palette(this.color);
79
+ const width = Math.max(...options.map((option) => option.label.length)) + 2;
80
+ const list = options.map((option, index) => ` ${c.cyan(String(index + 1))} ${option.detail ? `${option.label.padEnd(width)} ${c.dim(option.detail)}` : option.label}`).join("\n");
81
+ const typed = options.find((option) => option.input);
82
+ const range = options.length === 2 ? "1 or 2" : `1-${options.length}`;
83
+ const hint = `${range}${typed ? `, or ${typed.input.placeholder.split(",")[0]}` : ""} · Enter for ${start + 1}`;
84
+ const split = question.lastIndexOf("\n");
85
+ const styled = split >= 0 ? `${question.slice(0, split)} ${c.dim(hint)}${question.slice(split)}` : question;
86
+ for (let attempt = 1;; attempt++) {
87
+ const answer = (await this.ask(`${attempt === 1 ? `${list}\n\n` : ""}${styled}`, false)).trim();
88
+ const index = answer === "" ? start : /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
89
+ const option = options[index];
90
+ if (option?.input)
91
+ return { value: option.value, text: (await this.ask(option.input.prompt, false)).trim() };
92
+ if (option)
93
+ return { value: option.value };
94
+ if (typed && answer)
95
+ return { value: typed.value, text: answer };
96
+ if (attempt >= 3)
97
+ throw new Error(`Pick one of ${range}`);
30
98
  }
31
- const input = new ReadStream(fd);
32
- const output = new WriteStream(fd);
99
+ }
100
+ ask(question, hidden) {
101
+ const { input, output, close } = this.open();
33
102
  // A styled prompt is "label line\n› ": print the label line ourselves and give readline
34
103
  // only the last line, so its redraws (and the hidden-input filter) stay on one line.
35
104
  const split = question.lastIndexOf("\n");
@@ -38,7 +107,7 @@ export class TtyPrompter {
38
107
  if (head)
39
108
  output.write(head);
40
109
  return new Promise((resolve) => {
41
- const rl = createInterface({ input, output, terminal: true });
110
+ const rl = createInterface({ input, output, terminal: input.isTTY === true });
42
111
  if (hidden) {
43
112
  // Echo nothing while the secret is typed.
44
113
  rl._writeToOutput = (text) => {
@@ -50,17 +119,248 @@ export class TtyPrompter {
50
119
  if (hidden)
51
120
  output.write("\n");
52
121
  rl.close();
53
- input.destroy();
54
- output.destroy();
55
- try {
56
- closeSync(fd);
57
- }
58
- catch { /* already closed with the streams */ }
122
+ close();
59
123
  resolve(answer.trim());
60
124
  });
61
125
  });
62
126
  }
63
127
  }
128
+ /** Splits a chunk of terminal input into keys (one chunk can hold several, e.g. a paste). */
129
+ export function parseKeys(chunk) {
130
+ const keys = [];
131
+ let text = "";
132
+ const push = (key) => {
133
+ if (text)
134
+ keys.push({ text });
135
+ text = "";
136
+ keys.push(key);
137
+ };
138
+ for (let i = 0; i < chunk.length; i++) {
139
+ const ch = chunk[i];
140
+ if (ch === "\x1b") {
141
+ const next = chunk[i + 1];
142
+ if (next === "[" || next === "O") {
143
+ // CSI / SS3: skip the parameters up to the final byte.
144
+ let end = i + 2;
145
+ while (end < chunk.length && !/[A-Za-z~]/.test(chunk[end]))
146
+ end++;
147
+ const final = chunk[end];
148
+ if (final === "A")
149
+ push("up");
150
+ else if (final === "B")
151
+ push("down");
152
+ i = end;
153
+ }
154
+ else {
155
+ push("escape");
156
+ }
157
+ }
158
+ else if (ch === "\r" || ch === "\n") {
159
+ push("enter");
160
+ }
161
+ else if (ch === "\x03" || ch === "\x04") {
162
+ push("cancel");
163
+ }
164
+ else if (ch === "\x7f" || ch === "\b") {
165
+ push("backspace");
166
+ }
167
+ else if (ch === "\x15") {
168
+ push("clear");
169
+ }
170
+ else if (ch >= " ") {
171
+ text += ch;
172
+ }
173
+ }
174
+ if (text)
175
+ keys.push({ text });
176
+ return keys;
177
+ }
178
+ /**
179
+ * The arrow-key list, drawn inline under the question in normal scrollback: ↑/↓ or j/k move
180
+ * the `›` cursor, a digit jumps, Enter picks, Esc or Ctrl-C cancels. It redraws only its own
181
+ * lines and hides the cursor meanwhile; raw mode and the cursor come back on every way out,
182
+ * including a crash or a SIGINT/SIGTERM from elsewhere.
183
+ */
184
+ class ArrowSelect {
185
+ terminal;
186
+ options;
187
+ cursor;
188
+ typing = false;
189
+ text = "";
190
+ digits = "";
191
+ drawn = 0;
192
+ head;
193
+ marker;
194
+ caret;
195
+ c;
196
+ constructor(terminal, question, options, start, color) {
197
+ this.terminal = terminal;
198
+ this.options = options;
199
+ this.c = palette(color);
200
+ this.cursor = start;
201
+ this.caret = color ? "\x1b[7m \x1b[27m" : "▏";
202
+ const split = question.lastIndexOf("\n");
203
+ this.head = split >= 0 ? question.slice(0, split) : question.trimEnd();
204
+ this.marker = split >= 0 ? question.slice(split + 1) : `${this.c.cyan("›")} `;
205
+ }
206
+ run() {
207
+ const { input, output } = this.terminal;
208
+ return new Promise((resolve, reject) => {
209
+ let finished = false;
210
+ const restore = () => {
211
+ input.setRawMode?.(false);
212
+ output.write(SHOW_CURSOR);
213
+ };
214
+ const onExit = () => restore();
215
+ const onSignal = () => { restore(); process.exit(130); };
216
+ const end = (outcome) => {
217
+ if (finished)
218
+ return;
219
+ finished = true;
220
+ input.off("data", onData);
221
+ process.off("exit", onExit);
222
+ process.off("SIGINT", onSignal);
223
+ process.off("SIGTERM", onSignal);
224
+ restore();
225
+ this.terminal.close();
226
+ outcome();
227
+ };
228
+ const onData = (chunk) => {
229
+ try {
230
+ for (const key of parseKeys(typeof chunk === "string" ? chunk : chunk.toString("utf8"))) {
231
+ const result = this.handle(key);
232
+ if (result === "cancel") {
233
+ this.erase(true);
234
+ end(() => reject(new PromptCancelled()));
235
+ return;
236
+ }
237
+ if (result) {
238
+ // Leave what an answered text prompt leaves: the question and `› choice`.
239
+ this.erase(false);
240
+ output.write(`${this.marker}${result.text ?? this.options.find((option) => option.value === result.value).label}\n`);
241
+ end(() => resolve(result));
242
+ return;
243
+ }
244
+ }
245
+ this.draw();
246
+ }
247
+ catch (error) {
248
+ end(() => reject(error));
249
+ }
250
+ };
251
+ process.once("exit", onExit);
252
+ process.on("SIGINT", onSignal);
253
+ process.on("SIGTERM", onSignal);
254
+ input.setRawMode(true);
255
+ input.on("data", onData);
256
+ input.resume();
257
+ output.write(`${HIDE_CURSOR}${this.fit(this.head, this.c.dim(" ↑/↓ to move · Enter to pick"))}\n`);
258
+ this.draw();
259
+ });
260
+ }
261
+ /** Applies a key: a result when something was picked, "cancel" to stop, else nothing (redraw). */
262
+ handle(key) {
263
+ if (key === "cancel")
264
+ return "cancel";
265
+ if (this.typing) {
266
+ if (key === "escape") {
267
+ this.typing = false;
268
+ this.text = "";
269
+ }
270
+ else if (key === "enter")
271
+ return this.text.trim() ? { value: this.options[this.cursor].value, text: this.text.trim() } : undefined;
272
+ else if (key === "backspace")
273
+ this.text = this.text.slice(0, -1);
274
+ else if (key === "clear")
275
+ this.text = "";
276
+ else if (typeof key === "object")
277
+ this.text += key.text;
278
+ return undefined;
279
+ }
280
+ if (key === "escape")
281
+ return "cancel";
282
+ if (key === "up")
283
+ this.move(-1);
284
+ else if (key === "down")
285
+ this.move(1);
286
+ else if (key === "enter") {
287
+ const option = this.options[this.cursor];
288
+ if (!option.input)
289
+ return { value: option.value };
290
+ this.typing = true;
291
+ }
292
+ else if (typeof key === "object") {
293
+ for (const ch of key.text) {
294
+ if (ch === "k")
295
+ this.move(-1);
296
+ else if (ch === "j")
297
+ this.move(1);
298
+ else if (/\d/.test(ch))
299
+ this.jump(ch);
300
+ }
301
+ }
302
+ return undefined;
303
+ }
304
+ move(by) {
305
+ this.digits = "";
306
+ this.cursor = (this.cursor + by + this.options.length) % this.options.length;
307
+ }
308
+ /** A digit jumps to that option; with 10 or more, a second digit right after makes 10+. */
309
+ jump(digit) {
310
+ const combined = Number(this.digits + digit) - 1;
311
+ if (this.digits && combined < this.options.length) {
312
+ this.cursor = combined;
313
+ this.digits = "";
314
+ return;
315
+ }
316
+ const single = Number(digit) - 1;
317
+ if (single >= 0 && single < this.options.length)
318
+ this.cursor = single;
319
+ this.digits = this.options.length >= 10 && single >= 0 ? digit : "";
320
+ }
321
+ lines() {
322
+ if (this.typing) {
323
+ const option = this.options[this.cursor];
324
+ const placeholder = this.text ? "" : this.c.dim(option.input.placeholder);
325
+ // The real cursor stays hidden (it sits below the list); draw one where typing goes.
326
+ return [this.c.dim(` ${option.label} · Esc to go back`), `${this.marker}${this.text}${this.caret}${placeholder}`];
327
+ }
328
+ const width = Math.max(...this.options.map((option) => option.label.length)) + 2;
329
+ return this.options.map((option, index) => {
330
+ const selected = index === this.cursor;
331
+ const label = option.detail ? option.label.padEnd(width) : option.label;
332
+ const line = ` ${selected ? this.c.cyan("›") : " "} ${selected ? this.c.bold(label) : label}`;
333
+ return this.fit(line, option.detail ? this.c.dim(option.detail) : "");
334
+ });
335
+ }
336
+ /** Redraws the lines under the question in place; lines never wrap, so the count holds. */
337
+ draw() {
338
+ const lines = this.lines();
339
+ const rewind = this.drawn > 0 ? `\x1b[${this.drawn}F` : "";
340
+ // When there are fewer lines than last time (typing mode), clear what's left below.
341
+ const stale = this.drawn > lines.length ? "\x1b[J" : "";
342
+ this.terminal.output.write(`${rewind}${lines.map((line) => `\x1b[2K${line}\n`).join("")}${stale}`);
343
+ this.drawn = lines.length;
344
+ }
345
+ /** Erases the list (and, when cancelling, the question too). */
346
+ erase(all) {
347
+ const up = this.drawn + (all ? 1 : 0);
348
+ this.terminal.output.write(`${up > 0 ? `\x1b[${up}F` : ""}\x1b[J`);
349
+ this.drawn = 0;
350
+ }
351
+ /** `styled` plus a dim tail, the tail cut so the line fits the terminal's width. */
352
+ fit(styled, tail) {
353
+ const columns = this.terminal.output.columns || 80;
354
+ const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, "");
355
+ const room = columns - 1 - visible(styled).length;
356
+ const plainTail = visible(tail);
357
+ if (plainTail.length <= room)
358
+ return `${styled}${tail}`;
359
+ if (room <= 1)
360
+ return styled;
361
+ return `${styled}${this.c.dim(`${plainTail.slice(0, room - 1)}…`)}`;
362
+ }
363
+ }
64
364
  /** Answers from a map (tests); a missing answer is a test bug, so it throws. */
65
365
  export class ScriptedPrompter {
66
366
  answers;
@@ -71,6 +371,23 @@ export class ScriptedPrompter {
71
371
  async choose(question) {
72
372
  return this.answer(question);
73
373
  }
374
+ /**
375
+ * An answer is a number (1-based), an option's value or label, or "" for the preselected
376
+ * one. Picking an option that takes input then asks its prompt; other text goes to it directly.
377
+ */
378
+ async select(question, options, initial = 0) {
379
+ const answer = this.answer(question).trim();
380
+ const index = answer === "" ? initial : /^\d+$/.test(answer) ? Number(answer) - 1 : options.findIndex((option) => option.value === answer || option.label === answer);
381
+ const option = options[index];
382
+ if (option?.input)
383
+ return { value: option.value, text: this.answer(option.input.prompt).trim() };
384
+ if (option)
385
+ return { value: option.value };
386
+ const typed = options.find((entry) => entry.input);
387
+ if (typed)
388
+ return { value: typed.value, text: answer };
389
+ throw new Error(`no option "${answer}" for: ${question}`);
390
+ }
74
391
  async secret(question) {
75
392
  return this.answer(question);
76
393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -35,5 +35,8 @@
35
35
  "@types/node": "^22.10.0",
36
36
  "tsx": "^4.19.2",
37
37
  "typescript": "^5.7.2"
38
+ },
39
+ "dependencies": {
40
+ "autopilot-ai": "^2.7.0"
38
41
  }
39
42
  }