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.
package/dist/cli.js CHANGED
@@ -7,6 +7,8 @@ import { briefStatus, connectLaptop, laptopsCommand, logsCommand, pairLaptop, se
7
7
  import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, use, watch } from "./laptop/commands.js";
8
8
  import { loadRemotes } from "./laptop/config.js";
9
9
  import { keys } from "./laptop/keys.js";
10
+ import { model } from "./laptop/settings.js";
11
+ import { projectSession } from "./laptop/session.js";
10
12
  import { setup } from "./laptop/setup.js";
11
13
  import { localCommand } from "./local/cli.js";
12
14
  import { setUpOnThisComputer } from "./local/setup.js";
@@ -23,6 +25,9 @@ Send a task; it runs in its own sandbox and comes back as a pull request.
23
25
  On your laptop:
24
26
  dl set up the first time; afterwards, an overview
25
27
  dl setup set up or repair where your agents run (safe to re-run)
28
+ dl (in a GitHub repo) talk to the repository's agent; it keeps running when you leave
29
+ dl new in a GitHub repo: start a fresh conversation for it
30
+ dl status where your agents run and what they're doing (works anywhere)
26
31
  dl connect URL connect to a server someone else set up (approve the code it shows)
27
32
  dl run "TASK" run a task, from inside a GitHub repository
28
33
  (your uncommitted changes go along; Ctrl-C stops watching, not the task)
@@ -32,6 +37,7 @@ On your laptop:
32
37
  dl servers where agents run (dl servers remove NAME · dl servers rename OLD NEW)
33
38
  dl use [NAME] where tasks go without --server (no NAME: show it)
34
39
  dl keys [openrouter|github] which keys are set, or replace one (--server NAME)
40
+ dl model [ID] the model for tasks and sessions (no ID: pick from a list)
35
41
  run options: --server NAME, -m/--model MODEL, --repo OWNER/NAME, --detach
36
42
  dl local status agents running on this computer: running?, where, how many fit
37
43
  dl local uninstall [--purge] stop and remove them (--purge also deletes keys and data)
@@ -206,8 +212,15 @@ export async function main(argv, io = {
206
212
  if (!state) {
207
213
  if (values.brief)
208
214
  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))
215
+ // Inside a GitHub repository, that repository's conversation; elsewhere, the laptop's home.
216
+ if (!command && terminal && (await loadRemotes()).length > 0) {
217
+ const code = await projectSession({ cwd: process.cwd(), ...(values.server ? { server: values.server } : {}), ...(values.model ? { model: values.model } : {}) }, laptopIO);
218
+ if (code !== undefined)
219
+ return code;
220
+ }
221
+ // A laptop connected to servers gets its own home (`dl status` shows it from anywhere);
222
+ // otherwise, what this is and how to start.
223
+ if ((!command || command === "status") && await laptopHome(laptopIO))
211
224
  return 0;
212
225
  // A laptop with nothing set up yet: the wizard (it needs someone to answer it).
213
226
  if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
@@ -271,10 +284,19 @@ export async function main(argv, io = {
271
284
  await pull({ id, cwd: process.cwd(), ...server }, laptopIO);
272
285
  return 0;
273
286
  }
287
+ case "new": {
288
+ const code = await projectSession({ cwd: process.cwd(), fresh: true, ...(values.server ? { server: values.server } : {}), ...(values.model ? { model: values.model } : {}) }, laptopIO);
289
+ if (code !== undefined)
290
+ return code;
291
+ io.err(` dl new starts a fresh conversation from inside a GitHub repository (with something set up: dl setup).\n`);
292
+ return 1;
293
+ }
274
294
  case "servers":
275
295
  return await servers(parsed.positionals.slice(1), laptopIO);
276
296
  case "use":
277
297
  return await use(parsed.positionals.slice(1), laptopIO);
298
+ case "model":
299
+ return await model(parsed.positionals.slice(1), { io: laptopIO, terminal });
278
300
  case "runs":
279
301
  // On a server, its runs; on a laptop, the tasks it sent.
280
302
  if (!(await readState(system))) {
@@ -1,18 +1,18 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFile } from "node:fs/promises";
4
- import { homedir, hostname } from "node:os";
3
+ import { hostname } from "node:os";
5
4
  import { promisify } from "node:util";
6
5
  import { isModelNotPriced } from "../model-prices.js";
7
6
  import { palette } from "../ui.js";
8
7
  import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
9
8
  import { defaultRemoteName, loadRemotes, loadRemotesFile, normalizeServerUrl, recordLastUsed, REMOTE_NAME_RE, removeRemote, renameRemote, resolveRemote, resolveTarget, saveRemote, setDefaultRemote, } from "./config.js";
10
- import { autopilotConfigPath } from "./credentials.js";
11
9
  import { PAIRED_NAME } from "../local/layout.js";
12
10
  import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
11
+ import { DEFAULT_MODEL, laptopModel } from "./settings.js";
13
12
  import { sshCommandLine } from "./ssh.js";
14
13
  import { openBrowser } from "./open-browser.js";
15
14
  import { withConnections } from "./tunnel.js";
15
+ export { DEFAULT_MODEL, readAutopilotModel } from "./settings.js";
16
16
  /**
17
17
  * `dl` on a laptop: send tasks to where agents run (this computer, or your own server),
18
18
  * follow them, and check out the pull requests. It talks to the server's API directly (the
@@ -23,8 +23,6 @@ import { withConnections } from "./tunnel.js";
23
23
  */
24
24
  const execFileAsync = promisify(execFile);
25
25
  const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
26
- /** The model a task gets when neither -m nor the laptop's Autopilot picks one the server offers. */
27
- export const DEFAULT_MODEL = "moonshotai/kimi-k2.6";
28
26
  const processIO = {
29
27
  out: (text) => process.stdout.write(text),
30
28
  err: (text) => process.stderr.write(text),
@@ -68,7 +66,7 @@ export async function connect(input, io = processIO) {
68
66
  */
69
67
  export async function run(input, io = processIO) {
70
68
  const { remote, reason } = await resolveTarget(input.server);
71
- const preferred = input.preferredModel ?? await (input.autopilotModel ?? (() => readAutopilotModel()))();
69
+ const preferred = input.preferredModel ?? await (input.autopilotModel ?? laptopModel)();
72
70
  return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, reason, { ...input, ...(preferred ? { preferredModel: preferred } : {}) }, io));
73
71
  }
74
72
  async function runOn(client, remote, reason, input, io) {
@@ -264,7 +262,7 @@ export async function renameServer(args, io = processIO, path) {
264
262
  return 0;
265
263
  }
266
264
  /** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
267
- export async function laptopHome(io = processIO, open) {
265
+ export async function laptopHome(io = processIO, open, model = laptopModel) {
268
266
  const c = palette(io.color === true);
269
267
  const remotes = await loadRemotes();
270
268
  if (remotes.length === 0)
@@ -278,12 +276,14 @@ export async function laptopHome(io = processIO, open) {
278
276
  total += list?.length ?? 0;
279
277
  }
280
278
  });
281
- const current = await defaultName();
279
+ const [current, chosen] = await Promise.all([defaultName(), model()]);
282
280
  io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
283
281
  io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
284
282
  io.out(` ${c.dim("Runs on".padEnd(11))}${remotes.map((remote) => `${mark(remote, current, c)} ${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim(remotes.length > 1 ? "dl servers · dl use NAME" : "dl servers")}\n`);
285
- io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\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`);
283
+ io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n`);
284
+ io.out(` ${c.dim("Model".padEnd(11))}${chosen ?? DEFAULT_MODEL} ${c.dim("dl model")}\n\n`);
285
+ io.out(` ${c.dim("Start a session: cd into a GitHub repository, then type")} ${c.bold("dl")}\n`);
286
+ io.out(` ${c.dim("Or hand off a one-off task:")} ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
287
287
  return true;
288
288
  }
289
289
  async function follow(client, remote, conversationId, cursor, signal, io, replayUntil) {
@@ -401,7 +401,7 @@ async function findRun(id, server, connections) {
401
401
  return matches[0];
402
402
  }
403
403
  /**
404
- * The task's model: -m/--model; else the laptop's Autopilot default, if the server offers it;
404
+ * The task's model: -m/--model; else the laptop's default (dl model, else Autopilot's), if the server offers it;
405
405
  * else DEFAULT_MODEL, if offered. Never just the first in the server's list (that's the
406
406
  * whole OpenRouter catalog, in no useful order).
407
407
  */
@@ -418,30 +418,9 @@ export function pickModel(models, requested, preferred) {
418
418
  return DEFAULT_MODEL;
419
419
  if (models.length === 0)
420
420
  throw new Error("The server offers no models; check its OpenRouter key (on the server: dl doctor)");
421
- const tried = preferred && preferred !== DEFAULT_MODEL ? `your Autopilot model (${preferred}) or ${DEFAULT_MODEL}` : DEFAULT_MODEL;
421
+ const tried = preferred && preferred !== DEFAULT_MODEL ? `your model (${preferred}) or ${DEFAULT_MODEL}` : DEFAULT_MODEL;
422
422
  throw new Error(`The server doesn't offer ${tried}. ${how}`);
423
423
  }
424
- /**
425
- * The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
426
- * `model` in its config. undefined when neither is set or the config can't be read.
427
- */
428
- export async function readAutopilotModel(sources = {}) {
429
- const env = sources.env ?? process.env;
430
- const fromEnv = env.KIMI_MODEL?.trim();
431
- if (fromEnv)
432
- return fromEnv;
433
- const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
434
- const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
435
- if (!raw)
436
- return undefined;
437
- try {
438
- const model = JSON.parse(raw).model;
439
- return typeof model === "string" && model.trim() ? model.trim() : undefined;
440
- }
441
- catch {
442
- return undefined;
443
- }
444
- }
445
424
  /** Where a place is, as people know it: this computer, its URL, or the SSH destination its tunnel goes to. */
446
425
  function where(remote) {
447
426
  if (remote.ssh)
@@ -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,280 @@
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 } from "./commands.js";
11
+ import { laptopModel } from "./settings.js";
12
+ import { recordLastUsed, resolveTarget } from "./config.js";
13
+ import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
14
+ import { findSession, forgetSession, rememberSession, sessionsPath } from "./session-store.js";
15
+ import { openRemoteForSession, withConnections } from "./tunnel.js";
16
+ /**
17
+ * `dl` inside a project: Autopilot's own interactive screen, attached to this repository's
18
+ * conversation on the place agents run (`autopilot attach-remote`). The first time it starts
19
+ * one from this checkout (base commit + uncommitted changes, as `dl run` sends them); after
20
+ * that it reopens the same one. dl only finds the conversation, holds the SSH tunnel open,
21
+ * and gets out of the way: Autopilot owns the terminal until the user detaches.
22
+ */
23
+ const execFileAsync = promisify(execFile);
24
+ const realSpawn = (command, args, env) => spawn(command, args, { stdio: "inherit", env });
25
+ const FORWARDED = ["SIGINT", "SIGTERM", "SIGHUP"];
26
+ const UPDATE = "npm install -g directionallylocal";
27
+ class Interrupted extends Error {
28
+ }
29
+ /**
30
+ * Opens the session, or returns undefined when this isn't a project dl can open one for
31
+ * (not a Git repository, origin not on GitHub, nothing pushed yet, or no place to pick),
32
+ * after a one-line note for the last three, so the caller shows the overview instead.
33
+ */
34
+ export async function projectSession(input, io) {
35
+ const c = palette(io.color === true);
36
+ const note = (text) => io.err(` ${c.yellow("!")} ${text}\n`);
37
+ const root = await gitRoot(input.cwd);
38
+ if (!root)
39
+ return undefined;
40
+ let repository;
41
+ try {
42
+ repository = await detectGitHubRepository(root);
43
+ }
44
+ catch {
45
+ note(`${basename(root)}'s origin isn't on GitHub, so there's no agent conversation to open here.`);
46
+ return undefined;
47
+ }
48
+ let target;
49
+ try {
50
+ target = await resolveTarget(input.server);
51
+ }
52
+ catch (error) {
53
+ if (input.server !== undefined)
54
+ throw error;
55
+ note(describeError(error));
56
+ return undefined;
57
+ }
58
+ const { remote } = target;
59
+ const key = { place: remote.name, repository, root };
60
+ const file = input.sessionsFile ?? sessionsPath();
61
+ const autopilot = await (input.autopilot ?? findAutopilot)();
62
+ // Asked while the tunnel opens; it only matters right before launching.
63
+ const ready = autopilot ? (input.probe ?? supportsAttachRemote)(autopilot).catch(() => false) : Promise.resolve(false);
64
+ const missing = () => {
65
+ 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`);
66
+ return 1;
67
+ };
68
+ // Ctrl-C (or a hang-up) before Autopilot starts stops dl, closing the tunnel on the way out;
69
+ // once it runs, Autopilot handles Ctrl-C itself and dl only passes on SIGTERM/SIGHUP.
70
+ const signals = input.signals ?? process;
71
+ let child;
72
+ let interrupt = () => { };
73
+ const interrupted = new Promise((_, reject) => { interrupt = () => reject(new Interrupted()); });
74
+ interrupted.catch(() => { });
75
+ const handlers = FORWARDED.map((name) => {
76
+ const handler = () => {
77
+ if (!child)
78
+ interrupt();
79
+ else if (name !== "SIGINT")
80
+ child.kill(name);
81
+ };
82
+ signals.on(name, handler);
83
+ return [name, handler];
84
+ });
85
+ const step = (work) => Promise.race([work, interrupted]);
86
+ try {
87
+ return await withConnections(input.open ?? openRemoteForSession, async (connections) => {
88
+ const client = await step(connections.client(remote));
89
+ let remembered = input.fresh ? undefined : await findSession(key, file);
90
+ if (remembered) {
91
+ const alive = await step(client.getConversation(remembered.conversationId).then((conversation) => conversation.status !== "destroyed", (error) => {
92
+ if (error instanceof RemoteApiError && (error.status === 404 || error.status === 410))
93
+ return false;
94
+ throw error;
95
+ }));
96
+ if (!alive) {
97
+ await forgetSession(key, file);
98
+ note(`The last conversation for ${repository} on ${remote.name} is gone; starting a new one.`);
99
+ remembered = undefined;
100
+ }
101
+ }
102
+ const scratch = await mkdtemp(join(tmpdir(), "dl-session-"));
103
+ try {
104
+ const stateFile = join(scratch, "state.json");
105
+ const args = ["attach-remote", "--state-file", stateFile, "--title", `${remote.name} · ${repository}`];
106
+ let detail;
107
+ if (remembered) {
108
+ args.push("--conversation", remembered.conversationId);
109
+ detail = remembered.conversationId.slice(0, 8);
110
+ }
111
+ else {
112
+ let snapshot;
113
+ try {
114
+ snapshot = await step(snapshotWorkingTree(root));
115
+ }
116
+ catch (error) {
117
+ if (error instanceof Interrupted)
118
+ throw error;
119
+ note(`${describeError(error)}.`.replace(/\.\.$/, "."));
120
+ return undefined;
121
+ }
122
+ const [workspaces, models, preferred] = await step(Promise.all([
123
+ client.workspaces(),
124
+ client.models(),
125
+ input.model ? Promise.resolve(undefined) : (input.autopilotModel ?? laptopModel)(),
126
+ ]));
127
+ const workspace = workspaces[0];
128
+ if (!workspace)
129
+ throw new Error(`${remote.name} has no workspace this laptop can use`);
130
+ const model = pickModel(models, input.model, preferred);
131
+ args.push("--workspace", workspace.id, "--repo", repository, "--model", model, "--base-commit", snapshot.baseCommit);
132
+ if (snapshot.patch) {
133
+ const patchFile = join(scratch, "changes.patch");
134
+ await writeFile(patchFile, snapshot.patch, { mode: 0o600 });
135
+ args.push("--patch-file", patchFile);
136
+ }
137
+ const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
138
+ detail = `new conversation from ${snapshot.baseCommit.slice(0, 7)}${changes}, ${model}`;
139
+ }
140
+ if (!autopilot || !(await step(ready)))
141
+ return missing();
142
+ // `dl new`: the old conversation keeps running on the place (dl runs lists it).
143
+ if (input.fresh)
144
+ await forgetSession(key, file);
145
+ io.err(`\n ${c.cyan("·")} Connecting to ${c.bold(remote.name)} · ${repository} … ${c.dim(`(${detail})`)}\n`);
146
+ // The token goes in the environment only: argv is visible to every user on the machine.
147
+ const env = { ...process.env, AUTOPILOT_REMOTE_URL: client.baseUrl, AUTOPILOT_REMOTE_TOKEN: remote.token };
148
+ let saved = remembered?.conversationId;
149
+ const save = async () => {
150
+ const state = await readState(stateFile);
151
+ if (!state || state.conversationId === saved)
152
+ return;
153
+ saved = state.conversationId;
154
+ await rememberSession({ ...key, conversationId: state.conversationId, ...(state.branch ? { branch: state.branch } : {}) }, file);
155
+ };
156
+ // Remember it as soon as it exists, so a closed terminal doesn't lose it.
157
+ const poll = setInterval(() => { void save().catch(() => { }); }, 500);
158
+ poll.unref?.();
159
+ const outcome = await new Promise((resolve) => {
160
+ const started = (input.spawn ?? realSpawn)(autopilot.command, [...autopilot.args, ...args], env);
161
+ child = started;
162
+ started.on("exit", (code) => resolve({ code }));
163
+ started.on("error", (error) => resolve(error));
164
+ }).finally(() => clearInterval(poll));
165
+ await save().catch(() => { });
166
+ if (outcome instanceof Error)
167
+ return missing();
168
+ if (outcome.code === 2)
169
+ return missing();
170
+ if (outcome.code !== 0)
171
+ return 1;
172
+ await recordLastUsed(remote.name).catch(() => undefined);
173
+ 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`);
174
+ return 0;
175
+ }
176
+ finally {
177
+ await rm(scratch, { recursive: true, force: true });
178
+ }
179
+ });
180
+ }
181
+ catch (error) {
182
+ if (!(error instanceof Interrupted))
183
+ throw error;
184
+ io.err("\n");
185
+ return 130;
186
+ }
187
+ finally {
188
+ for (const [name, handler] of handlers)
189
+ signals.off(name, handler);
190
+ }
191
+ }
192
+ /** What `attach-remote --state-file` wrote, once the conversation exists. */
193
+ async function readState(path) {
194
+ const raw = await readFile(path, "utf8").catch(() => undefined);
195
+ if (!raw)
196
+ return undefined;
197
+ try {
198
+ const parsed = JSON.parse(raw);
199
+ if (typeof parsed.conversationId !== "string" || !parsed.conversationId)
200
+ return undefined;
201
+ return { conversationId: parsed.conversationId, ...(typeof parsed.branch === "string" && parsed.branch ? { branch: parsed.branch } : {}) };
202
+ }
203
+ catch {
204
+ return undefined; // still being written
205
+ }
206
+ }
207
+ async function gitRoot(cwd) {
208
+ try {
209
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
210
+ return stdout.trim() || undefined;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ }
216
+ /** The Autopilot installed with dl (run with this Node), else an `autopilot` on PATH. */
217
+ export async function findAutopilot() {
218
+ const bundled = bundledAutopilot();
219
+ if (bundled)
220
+ return { command: process.execPath, args: [bundled] };
221
+ const onPath = await autopilotOnPath();
222
+ return onPath ? { command: onPath, args: [] } : undefined;
223
+ }
224
+ /**
225
+ * autopilot-ai's bin script, found from where Node resolves the package for dl. Its exports
226
+ * don't include package.json, so the entry point is resolved and its package root found above it.
227
+ */
228
+ export function bundledAutopilot(resolve = (specifier) => import.meta.resolve(specifier)) {
229
+ for (const specifier of ["autopilot-ai/package.json", "autopilot-ai"]) {
230
+ let dir;
231
+ try {
232
+ dir = dirname(fileURLToPath(resolve(specifier)));
233
+ }
234
+ catch {
235
+ continue;
236
+ }
237
+ for (;;) {
238
+ const manifest = join(dir, "package.json");
239
+ if (existsSync(manifest)) {
240
+ try {
241
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
242
+ if (pkg.name === "autopilot-ai") {
243
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.autopilot;
244
+ const path = typeof bin === "string" ? resolvePath(dir, bin) : undefined;
245
+ return path && existsSync(path) ? path : undefined;
246
+ }
247
+ }
248
+ catch {
249
+ // not a readable manifest; keep looking up
250
+ }
251
+ }
252
+ const parent = dirname(dir);
253
+ if (parent === dir)
254
+ break;
255
+ dir = parent;
256
+ }
257
+ }
258
+ return undefined;
259
+ }
260
+ export async function autopilotOnPath(env = process.env) {
261
+ for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
262
+ const candidate = join(dir, "autopilot");
263
+ if (await access(candidate, constants.X_OK).then(() => true, () => false))
264
+ return candidate;
265
+ }
266
+ return undefined;
267
+ }
268
+ /** An Autopilot that knows `attach-remote` lists its options (an older one prints its general help). */
269
+ export async function supportsAttachRemote(autopilot) {
270
+ try {
271
+ const { stdout, stderr } = await execFileAsync(autopilot.command, [...autopilot.args, "attach-remote", "--help"], {
272
+ timeout: 30_000,
273
+ env: { ...process.env, NO_COLOR: "1" },
274
+ });
275
+ return `${stdout}${stderr}`.includes("--state-file");
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ }