directionallylocal 0.4.1 → 0.6.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.
@@ -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
+ }
@@ -3,21 +3,24 @@ 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
+ 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"';
15
+ const PLACES = [
16
+ { value: "local", label: "On this computer", detail: "start now · nothing to rent · pauses when this computer sleeps" },
17
+ { value: "server", label: "On my own server", detail: "keeps going when your laptop is closed · needs SSH access" },
18
+ ];
14
19
  export async function setupWizard(deps) {
15
20
  const c = palette(deps.color);
16
21
  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
22
  const ui = deps.newUi();
20
- let where = await pick(ui, deps.prompter, "Where", "1 or 2 · Enter for 1", { "1": "local", "2": "server" });
23
+ let where = (await deps.prompter.select(ui.prompt("Where"), PLACES, 0)).value;
21
24
  ui.answered("Where", where === "local" ? "On this computer" : "On my own server");
22
25
  if (where === "local" && !deps.localSetup) {
23
26
  ui.line("info", "On this computer", "coming soon");
@@ -30,11 +33,16 @@ export async function setupWizard(deps) {
30
33
  }
31
34
  if (where === "local") {
32
35
  const keysUi = deps.newUi();
33
- const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources });
36
+ const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources, place: "this computer" });
34
37
  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);
38
+ const { remote, capacity } = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
39
+ const remotesFile = deps.remotesFile ?? remotesPath();
40
+ const isNew = !(await loadRemotes(remotesFile)).some((entry) => entry.name === remote.name);
41
+ await saveRemote(remote, remotesFile);
42
+ if (isNew)
43
+ await chooseDefault(deps, remote, remotesFile);
44
+ await chooseModel(deps);
45
+ finishScreen(deps.newUi(), capacity);
38
46
  return remote;
39
47
  }
40
48
  return setUpServer(deps);
@@ -61,7 +69,8 @@ async function setUpServer(deps) {
61
69
  // 4. A credential for this laptop, checked through the tunnel every command will use. Each
62
70
  // `dl pair` makes a new credential on the server, so a re-run keeps the saved one if it works.
63
71
  const remotesFile = deps.remotesFile ?? remotesPath();
64
- const saved = (await loadRemotes(remotesFile)).find((entry) => sameServer(entry, access));
72
+ const known = await loadRemotes(remotesFile);
73
+ const saved = known.find((entry) => sameServer(entry, access));
65
74
  let remote;
66
75
  if (saved && await stillWorks({ ...saved, ssh: access }, deps.open))
67
76
  remote = { ...saved, ssh: access };
@@ -91,25 +100,26 @@ async function setUpServer(deps) {
91
100
  await saveRemote(remote, remotesFile);
92
101
  connectedUi.line("done", "This laptop", `connected as ${remote.name}`);
93
102
  connectedUi.finish();
103
+ if (!known.some((entry) => entry.name === paired.name))
104
+ await chooseDefault(deps, remote, remotesFile);
105
+ await chooseModel(deps);
94
106
  finishScreen(deps.newUi(), capacity);
95
107
  return remote;
96
108
  }
97
109
  async function chooseServer(deps, hosts) {
98
- const c = palette(deps.color);
99
110
  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";
111
+ deps.write("\n");
112
+ const example = "user@host[:port], e.g. root@203.0.113.10";
113
+ const options = [
114
+ ...hosts.map((host, index) => ({ value: String(index), label: host.alias, detail: describeHost(host) })),
115
+ { value: "another", label: "Another server", input: { prompt: ui.prompt("Server address", example), placeholder: example } },
116
+ ];
109
117
  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];
118
+ // Without saved hosts, a one-item list would only be in the way: just ask for the address.
119
+ const picked = hosts.length > 0
120
+ ? await deps.prompter.select(ui.prompt("Server"), options, 0)
121
+ : { value: "another", text: (await deps.prompter.text(ui.prompt("Server", example))).trim() };
122
+ const host = picked.value === "another" ? undefined : hosts[Number(picked.value)];
113
123
  if (host) {
114
124
  ui.answered("SSH", host.alias);
115
125
  return {
@@ -118,12 +128,8 @@ async function chooseServer(deps, hosts) {
118
128
  display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
119
129
  };
120
130
  }
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
131
  try {
126
- const access = parseTarget(answer);
132
+ const access = parseTarget(picked.text ?? "");
127
133
  ui.answered("SSH", access.target);
128
134
  return { access, display: `${access.target}${access.port ? `:${access.port}` : ""}` };
129
135
  }
@@ -134,6 +140,50 @@ async function chooseServer(deps, hosts) {
134
140
  }
135
141
  }
136
142
  }
143
+ /**
144
+ * With two or more places set up, where `dl run` sends tasks unless told otherwise. Asked
145
+ * only when setup adds a place, with the new one preselected; the first place becomes the
146
+ * default by itself (saveRemote).
147
+ */
148
+ async function chooseDefault(deps, added, remotesFile) {
149
+ const { remotes } = await loadRemotesFile(remotesFile);
150
+ if (remotes.length < 2)
151
+ return;
152
+ const ui = deps.newUi();
153
+ deps.write("\n");
154
+ const options = remotes.map((remote) => ({ value: remote.name, label: remote.name, detail: describePlace(remote) }));
155
+ const initial = Math.max(0, remotes.findIndex((remote) => remote.name === added.name));
156
+ const { value } = await deps.prompter.select(ui.prompt("Where should tasks go by default?"), options, initial);
157
+ await setDefaultRemote(value, remotesFile);
158
+ ui.answered("Default", value);
159
+ ui.finish();
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
+ }
176
+ function describePlace(remote) {
177
+ if (remote.ssh)
178
+ return `server · ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
179
+ try {
180
+ const host = new URL(remote.url).hostname;
181
+ return host === "127.0.0.1" || host === "localhost" ? "this computer" : `server · ${host}`;
182
+ }
183
+ catch {
184
+ return remote.url;
185
+ }
186
+ }
137
187
  /**
138
188
  * Connects and checks the server. If it refuses the default keys (ssh's own: the agent and
139
189
  * ~/.ssh/id_*), each key pair in ~/.ssh is tried in turn with -i, without asking: auth
@@ -246,25 +296,13 @@ export function serverLabel(text) {
246
296
  }
247
297
  function finishScreen(ui, capacity) {
248
298
  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
- ]);
299
+ { label: "Start a session: cd into a GitHub repository, then type", value: "dl" },
300
+ { label: "Or hand off a one-off task", value: RUN_EXAMPLE },
301
+ ], { footer: "dl status shows where agents run and what they're doing." });
252
302
  }
253
303
  function laptopName(name) {
254
304
  return name.replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
255
305
  }
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
306
  /** `dl setup` with the real terminal, ssh, gh, and filesystem. */
269
307
  export async function setup(options) {
270
308
  const { io, terminal } = options;
@@ -277,25 +315,34 @@ export async function setup(options) {
277
315
  identityFile = resolve(options.identityFile.replace(/^~(?=$|\/)/, home).replace(/\.pub$/, ""));
278
316
  await fileAccess(identityFile).catch(() => { throw new Error(`No SSH key at ${options.identityFile} (--identity takes the private key, e.g. ~/.ssh/id_ed25519)`); });
279
317
  }
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
- });
318
+ try {
319
+ await setupWizard({
320
+ newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
321
+ prompter: new TtyPrompter({ color }),
322
+ write: io.out,
323
+ color,
324
+ ssh: realSshExec,
325
+ open: openRemote,
326
+ sources: {
327
+ env: process.env,
328
+ home,
329
+ readFile: (path) => readFile(path, "utf8").catch(() => undefined),
330
+ listDir: (path) => readdir(path).catch(() => []),
331
+ gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
332
+ fetch: globalThis.fetch,
333
+ },
334
+ hostname: hostname(),
335
+ installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
336
+ ...(options.localSetup ? { localSetup: options.localSetup } : {}),
337
+ ...(identityFile ? { identityFile } : {}),
338
+ });
339
+ }
340
+ catch (error) {
341
+ if (!(error instanceof PromptCancelled))
342
+ throw error;
343
+ // Esc / Ctrl-C at a question: the terminal is restored; say so quietly and stop.
344
+ io.out(` ${palette(color).dim("Cancelled. Run")} dl setup ${palette(color).dim("when you're ready.")}\n\n`);
345
+ return 130;
346
+ }
300
347
  return 0;
301
348
  }
@@ -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
  /**