directionallylocal 0.0.0-stage → 0.1.2

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,223 @@
1
+ /**
2
+ * Client for a paired server's Aster API (/api/v1). Same contract the iOS app and a
3
+ * future desktop app use: device pairing, code conversations as runs, resumable SSE.
4
+ */
5
+ export const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "interrupted", "merged", "destroyed"]);
6
+ export class RemoteApiError extends Error {
7
+ status;
8
+ code;
9
+ constructor(status, code, message) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.name = "RemoteApiError";
14
+ }
15
+ }
16
+ export class RemoteClient {
17
+ baseUrl;
18
+ token;
19
+ fetchImpl;
20
+ constructor(baseUrl, token, fetchImpl = fetch) {
21
+ this.baseUrl = baseUrl;
22
+ this.token = token;
23
+ this.fetchImpl = fetchImpl;
24
+ }
25
+ startDeviceAuthorization(clientName) {
26
+ return this.request("POST", "/auth/device", { body: { clientName }, auth: false });
27
+ }
28
+ /** Polls until the owner decides; resolves with the credential, rejects on denial or expiry. */
29
+ async waitForCredential(device, options = {}) {
30
+ const sleep = options.sleep ?? ((ms) => delay(ms, options.signal));
31
+ let intervalMs = Math.max(1, device.interval) * 1000;
32
+ const deadline = Date.now() + device.expiresIn * 1000;
33
+ while (Date.now() < deadline) {
34
+ await sleep(intervalMs);
35
+ try {
36
+ return await this.request("POST", "/auth/device/token", { body: { deviceCode: device.deviceCode }, auth: false, signal: options.signal });
37
+ }
38
+ catch (error) {
39
+ if (!(error instanceof RemoteApiError))
40
+ throw error;
41
+ if (error.code === "authorization_pending")
42
+ continue;
43
+ if (error.code === "slow_down") {
44
+ intervalMs += 5_000;
45
+ continue;
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+ throw new RemoteApiError(400, "expired_token", "The pairing code expired before it was approved; run the command again");
51
+ }
52
+ health() {
53
+ return this.request("GET", "/health");
54
+ }
55
+ async workspaces() {
56
+ return (await this.request("GET", "/workspaces")).workspaces;
57
+ }
58
+ async models() {
59
+ return (await this.request("GET", "/models")).models;
60
+ }
61
+ createCodeConversation(input) {
62
+ const { idempotencyKey, ...body } = input;
63
+ return this.withRetries(() => this.request("POST", "/conversations", {
64
+ body: { ...body, kind: "code" },
65
+ headers: { "Idempotency-Key": idempotencyKey },
66
+ }));
67
+ }
68
+ submitTurn(conversationId, text, clientTurnId) {
69
+ return this.withRetries(() => this.request("POST", `/conversations/${encodeURIComponent(conversationId)}/turns`, { body: { clientTurnId, text } }));
70
+ }
71
+ getConversation(conversationId) {
72
+ return this.request("GET", `/conversations/${encodeURIComponent(conversationId)}`);
73
+ }
74
+ async listConversations(limit = 50) {
75
+ return (await this.request("GET", `/conversations?limit=${limit}`)).conversations;
76
+ }
77
+ cancel(conversationId) {
78
+ return this.request("POST", `/conversations/${encodeURIComponent(conversationId)}/cancel`);
79
+ }
80
+ /**
81
+ * Events after `cursor`, reconnecting with the last seen ID when the stream drops,
82
+ * until the caller stops iterating or the signal aborts.
83
+ */
84
+ async *events(conversationId, cursor, signal) {
85
+ let last = cursor;
86
+ let failures = 0;
87
+ while (!signal?.aborted) {
88
+ try {
89
+ const response = await this.fetchImpl(this.url(`/conversations/${encodeURIComponent(conversationId)}/events`), {
90
+ headers: { ...this.authHeaders(), Accept: "text/event-stream", "Last-Event-ID": String(last) },
91
+ signal,
92
+ });
93
+ if (!response.ok || !response.body)
94
+ throw await toApiError(response);
95
+ failures = 0;
96
+ for await (const event of parseEventStream(response.body)) {
97
+ if (event.id <= last)
98
+ continue;
99
+ last = event.id;
100
+ yield event;
101
+ }
102
+ }
103
+ catch (error) {
104
+ if (signal?.aborted)
105
+ return;
106
+ if (error instanceof RemoteApiError && error.status < 500)
107
+ throw error;
108
+ if (++failures > 30)
109
+ throw error;
110
+ }
111
+ await delay(Math.min(1_000 * failures, 10_000), signal).catch(() => { });
112
+ }
113
+ }
114
+ async request(method, path, options = {}) {
115
+ const response = await this.fetchImpl(this.url(path), {
116
+ method,
117
+ headers: {
118
+ ...(options.auth === false ? {} : this.authHeaders()),
119
+ ...(options.body === undefined ? {} : { "Content-Type": "application/json" }),
120
+ ...options.headers,
121
+ },
122
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
123
+ ...(options.signal ? { signal: options.signal } : {}),
124
+ });
125
+ if (!response.ok)
126
+ throw await toApiError(response);
127
+ return await response.json();
128
+ }
129
+ /** Retries network failures and 5xx; safe only for requests carrying an idempotency key. */
130
+ async withRetries(attempt) {
131
+ for (let tries = 1;; tries++) {
132
+ try {
133
+ return await attempt();
134
+ }
135
+ catch (error) {
136
+ const retryable = !(error instanceof RemoteApiError) || error.status >= 500;
137
+ if (!retryable || tries >= 3)
138
+ throw error;
139
+ await delay(1_000 * tries);
140
+ }
141
+ }
142
+ }
143
+ url(path) {
144
+ return `${this.baseUrl}/api/v1${path}`;
145
+ }
146
+ authHeaders() {
147
+ return this.token ? { Authorization: `Bearer ${this.token}` } : {};
148
+ }
149
+ }
150
+ /** Minimal text/event-stream parser for the server's `id:`/`event:`/`data:` frames. */
151
+ export async function* parseEventStream(body) {
152
+ const decoder = new TextDecoder();
153
+ let buffer = "";
154
+ for await (const chunk of body) {
155
+ buffer += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, "\n");
156
+ let boundary;
157
+ while ((boundary = buffer.indexOf("\n\n")) >= 0) {
158
+ const frame = buffer.slice(0, boundary);
159
+ buffer = buffer.slice(boundary + 2);
160
+ const event = parseFrame(frame);
161
+ if (event)
162
+ yield event;
163
+ }
164
+ }
165
+ }
166
+ function parseFrame(frame) {
167
+ let id;
168
+ let type = "message";
169
+ const data = [];
170
+ for (const line of frame.split("\n")) {
171
+ if (!line || line.startsWith(":"))
172
+ continue;
173
+ const colon = line.indexOf(":");
174
+ const field = colon < 0 ? line : line.slice(0, colon);
175
+ const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
176
+ if (field === "id" && /^\d+$/.test(value))
177
+ id = Number(value);
178
+ else if (field === "event")
179
+ type = value;
180
+ else if (field === "data")
181
+ data.push(value);
182
+ }
183
+ if (id === undefined)
184
+ return undefined;
185
+ let parsed = {};
186
+ try {
187
+ parsed = data.length ? JSON.parse(data.join("\n")) : {};
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ return { id, type, data: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {} };
193
+ }
194
+ async function toApiError(response) {
195
+ let code = `http_${response.status}`;
196
+ let message = `Server returned ${response.status}`;
197
+ try {
198
+ const body = await response.json();
199
+ if (body.error?.code)
200
+ code = body.error.code;
201
+ if (body.error?.message)
202
+ message = body.error.message;
203
+ }
204
+ catch {
205
+ // not a JSON error body
206
+ }
207
+ return new RemoteApiError(response.status, code, message);
208
+ }
209
+ function delay(ms, signal) {
210
+ return new Promise((resolve, reject) => {
211
+ if (signal?.aborted)
212
+ return reject(signal.reason);
213
+ const timer = setTimeout(() => {
214
+ signal?.removeEventListener("abort", onAbort);
215
+ resolve();
216
+ }, ms);
217
+ const onAbort = () => {
218
+ clearTimeout(timer);
219
+ reject(signal?.reason);
220
+ };
221
+ signal?.addEventListener("abort", onAbort, { once: true });
222
+ });
223
+ }
@@ -0,0 +1,335 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { hostname } from "node:os";
4
+ import { promisify } from "node:util";
5
+ import { palette } from "../ui.js";
6
+ import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
7
+ import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
8
+ import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
9
+ import { openBrowser } from "./open-browser.js";
10
+ /**
11
+ * `dl` on a laptop: connect to a server, send it tasks, follow them, and check out the
12
+ * pull requests. It talks to the server's API directly (the same one Autopilot's
13
+ * `--on` uses, and shares its list of servers), so the laptop needs nothing else.
14
+ */
15
+ const execFileAsync = promisify(execFile);
16
+ const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
17
+ const processIO = {
18
+ out: (text) => process.stdout.write(text),
19
+ err: (text) => process.stderr.write(text),
20
+ color: process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== "dumb",
21
+ };
22
+ /** `dl connect <url>`: pair this laptop with a server. No keys are copied. */
23
+ export async function connect(input, io = processIO) {
24
+ const c = palette(io.color === true);
25
+ const url = normalizeServerUrl(input.url);
26
+ const name = input.name ?? defaultRemoteName(url);
27
+ if (!REMOTE_NAME_RE.test(name))
28
+ throw new Error("Server names are 1-40 lowercase letters, digits, or dashes");
29
+ const anonymous = new RemoteClient(url, undefined);
30
+ const device = await anonymous.startDeviceAuthorization(clientName());
31
+ io.out(`\n ${c.bold("Connect this laptop")} ${c.dim(`to ${url}`)}\n\n`);
32
+ io.out(` Approve code ${c.bold(device.userCode)} in the browser you approve laptops with:\n ${device.verificationUriComplete}\n`);
33
+ if (input.open !== false && openBrowser(device.verificationUriComplete))
34
+ io.out(` ${c.dim("(opened it for you)")}\n`);
35
+ io.out(` ${c.dim(`Or on the server: dl connect-laptop ${device.userCode}`)}\n\n ${c.cyan("·")} Waiting for approval…\n`);
36
+ const credential = await anonymous.waitForCredential(device, {
37
+ ...(input.signal ? { signal: input.signal } : {}),
38
+ ...(input.sleep ? { sleep: input.sleep } : {}),
39
+ });
40
+ const remote = {
41
+ name,
42
+ url,
43
+ token: credential.token,
44
+ credentialId: credential.credentialId,
45
+ expiresAt: credential.expiresAt,
46
+ addedAt: new Date().toISOString(),
47
+ };
48
+ await new RemoteClient(url, remote.token).health();
49
+ await saveRemote(remote);
50
+ io.out(`${rewrite(io)} ${c.green("✓")} Connected as ${c.bold(name)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n\n`);
51
+ io.out(` ${c.dim("Next, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
52
+ return remote;
53
+ }
54
+ /**
55
+ * `dl run "task"`: the task runs in its own sandbox on the server, starting from this
56
+ * checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
57
+ */
58
+ export async function run(input, io = processIO) {
59
+ const c = palette(io.color === true);
60
+ const remote = await resolveRemote(input.server);
61
+ const client = new RemoteClient(remote.url, remote.token);
62
+ const repository = input.repository ?? await detectGitHubRepository(input.cwd);
63
+ const snapshot = await snapshotWorkingTree(input.cwd);
64
+ const [workspaces, models] = await Promise.all([client.workspaces(), client.models()]);
65
+ const workspace = workspaces[0];
66
+ if (!workspace)
67
+ throw new Error(`${remote.name} has no workspace this laptop can use`);
68
+ const model = pickModel(models, input.model, input.preferredModel);
69
+ const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
70
+ io.err(`\n ${c.cyan("·")} Sending to ${c.bold(remote.name)}: ${repository} at ${snapshot.baseCommit.slice(0, 7)}${changes} ${c.dim(model)}\n`);
71
+ const conversation = await client.createCodeConversation({
72
+ workspaceId: workspace.id,
73
+ model,
74
+ repository,
75
+ baseCommit: snapshot.baseCommit,
76
+ ...(snapshot.patch ? { patch: snapshot.patch } : {}),
77
+ idempotencyKey: `dl-${randomUUID()}`,
78
+ });
79
+ await client.submitTurn(conversation.conversationId, input.task, `dl-${randomUUID()}`);
80
+ const id = conversation.conversationId.slice(0, 8);
81
+ io.err(`${rewrite(io)} ${c.green("✓")} Started ${c.bold(id)} on ${remote.name} ${c.dim(`branch ${conversation.branch ?? "?"}`)}\n`);
82
+ if (input.detach) {
83
+ io.err(`\n ${c.dim("It runs on its own. Follow it:")} dl watch ${id}\n\n`);
84
+ return 0;
85
+ }
86
+ io.err(` ${c.dim(`Ctrl-C stops watching; the task keeps running (dl watch ${id}).`)}\n\n`);
87
+ return follow(client, remote, conversation.conversationId, conversation.lastEventId, input.signal, io);
88
+ }
89
+ /** `dl runs`: tasks on every connected server, newest first. */
90
+ export async function runs(input, io = processIO) {
91
+ const c = palette(io.color === true);
92
+ const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
93
+ if (remotes.length === 0)
94
+ throw new Error("This laptop isn't connected to a server yet. Run: dl connect https://your-server");
95
+ const rows = [];
96
+ for (const remote of remotes) {
97
+ try {
98
+ const list = await new RemoteClient(remote.url, remote.token).listConversations();
99
+ rows.push(...list.filter((entry) => entry.kind === "code").map((entry) => ({ ...entry, server: remote.name })));
100
+ }
101
+ catch (error) {
102
+ io.err(` ${c.red("✗")} ${remote.name}: ${describeError(error)}\n`);
103
+ }
104
+ }
105
+ rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
106
+ if (input.json) {
107
+ io.out(`${JSON.stringify(rows, null, 2)}\n`);
108
+ return;
109
+ }
110
+ if (rows.length === 0) {
111
+ io.out(`\n No tasks yet. From inside a GitHub repository: ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
112
+ return;
113
+ }
114
+ io.out("\n");
115
+ for (const entry of rows) {
116
+ const running = entry.activeRunId !== null;
117
+ const glyph = running ? c.cyan("◐") : entry.status === "completed" || entry.status === "merged" ? c.green("✓") : ["failed", "interrupted"].includes(entry.status) ? c.red("✗") : c.dim("○");
118
+ const multi = remotes.length > 1 ? `${entry.server} ` : "";
119
+ io.out(` ${glyph} ${entry.conversationId.slice(0, 8)} ${multi}${(running ? "running" : entry.status).padEnd(11)} ${(entry.repository ?? "").padEnd(26)} ${c.dim(entry.pullRequestURL ?? entry.branch ?? "")}\n`);
120
+ }
121
+ io.out(`\n ${c.dim("Follow one:")} dl watch <id> ${c.dim("Check one out:")} dl pull <id>\n\n`);
122
+ }
123
+ /** `dl watch <id>`: replay and follow a task's output. */
124
+ export async function watch(input, io = processIO) {
125
+ const { remote, run: found } = await findRun(input.id, input.server);
126
+ const client = new RemoteClient(remote.url, remote.token);
127
+ const finished = TERMINAL_STATUSES.has(found.status) && !found.activeRunId;
128
+ return follow(client, remote, found.conversationId, 0, input.signal, io, finished ? found.lastEventId : undefined);
129
+ }
130
+ export async function cancel(input, io = processIO) {
131
+ const c = palette(io.color === true);
132
+ const { remote, run: found } = await findRun(input.id, input.server);
133
+ await new RemoteClient(remote.url, remote.token).cancel(found.conversationId);
134
+ io.out(` ${c.green("✓")} Stopped ${found.conversationId.slice(0, 8)}\n`);
135
+ }
136
+ /** `dl pull <id>`: check out a task's branch here. */
137
+ export async function pull(input, io = processIO) {
138
+ const c = palette(io.color === true);
139
+ const { run: found } = await findRun(input.id, input.server);
140
+ const branch = found.branch;
141
+ if (!branch)
142
+ throw new Error("That task has no branch");
143
+ if (found.activeRunId)
144
+ io.err(` ${c.yellow("!")} It's still running; you'll get what it has pushed so far.\n`);
145
+ const git = (args) => execFileAsync("git", ["-C", input.cwd, ...args]);
146
+ try {
147
+ await git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
148
+ }
149
+ catch {
150
+ throw new Error(`Branch ${branch} isn't on GitHub yet (a task pushes it when it finishes a step)`);
151
+ }
152
+ const exists = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).then(() => true, () => false);
153
+ if (exists) {
154
+ await git(["switch", branch]);
155
+ await git(["merge", "--ff-only", `origin/${branch}`]);
156
+ }
157
+ else {
158
+ await git(["switch", "-c", branch, "--track", `origin/${branch}`]);
159
+ }
160
+ io.out(` ${c.green("✓")} On ${c.bold(branch)}${found.pullRequestURL ? ` ${c.dim(found.pullRequestURL)}` : ""}\n`);
161
+ }
162
+ /** `dl servers [remove NAME]`: the servers this laptop is connected to. */
163
+ export async function servers(args, io = processIO) {
164
+ const c = palette(io.color === true);
165
+ if (args[0] === "remove") {
166
+ const name = args[1] ?? "";
167
+ if (!(await removeRemote(name))) {
168
+ io.err(` ${c.red("✗")} Not connected to "${name}".\n`);
169
+ return 1;
170
+ }
171
+ io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: sudo dl laptops remove "${clientName()}"\n`);
172
+ return 0;
173
+ }
174
+ const remotes = await loadRemotes();
175
+ if (remotes.length === 0) {
176
+ io.out(`\n Not connected to a server yet. ${c.bold("dl connect https://your-server")}\n\n`);
177
+ return 0;
178
+ }
179
+ io.out("\n");
180
+ for (const remote of remotes)
181
+ io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${remote.url} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
182
+ io.out("\n");
183
+ return 0;
184
+ }
185
+ /** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
186
+ export async function laptopHome(io = processIO) {
187
+ const c = palette(io.color === true);
188
+ const remotes = await loadRemotes();
189
+ if (remotes.length === 0)
190
+ return false;
191
+ let running = 0;
192
+ let total = 0;
193
+ for (const remote of remotes) {
194
+ const list = await new RemoteClient(remote.url, remote.token).listConversations().catch(() => undefined);
195
+ running += list?.filter((entry) => entry.activeRunId !== null).length ?? 0;
196
+ total += list?.length ?? 0;
197
+ }
198
+ io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
199
+ io.out(" Background coding agents on your own server: send a task, get a pull request back.\n\n");
200
+ io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(remote.url)}`).join(", ")} ${c.dim("dl servers")}\n`);
201
+ io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
202
+ io.out(` ${c.dim("Send a task, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
203
+ return true;
204
+ }
205
+ async function follow(client, remote, conversationId, cursor, signal, io, replayUntil) {
206
+ const c = palette(io.color === true);
207
+ const controller = new AbortController();
208
+ const stop = () => controller.abort();
209
+ signal?.addEventListener("abort", stop, { once: true });
210
+ const renderer = new EventRenderer(io, c);
211
+ let outcome;
212
+ try {
213
+ if (replayUntil === undefined || replayUntil > cursor) {
214
+ for await (const event of client.events(conversationId, cursor, controller.signal)) {
215
+ renderer.render(event);
216
+ if (RUN_END_EVENTS.has(event.type))
217
+ outcome = event.type;
218
+ if (replayUntil === undefined ? RUN_END_EVENTS.has(event.type) : event.id >= replayUntil)
219
+ break;
220
+ }
221
+ }
222
+ if (replayUntil !== undefined && !controller.signal.aborted)
223
+ outcome ??= "completed";
224
+ }
225
+ finally {
226
+ signal?.removeEventListener("abort", stop);
227
+ controller.abort();
228
+ }
229
+ renderer.finish();
230
+ const id = conversationId.slice(0, 8);
231
+ if (!outcome) {
232
+ io.err(`\n ${c.dim(`Stopped watching ${id}; it keeps running on ${remote.name}.`)} dl watch ${id}\n`);
233
+ return 0;
234
+ }
235
+ const final = await client.getConversation(conversationId);
236
+ if (outcome === "completed") {
237
+ io.err(`\n ${c.green("✓")} ${c.bold(id)} done${final.pullRequestURL ? ` ${final.pullRequestURL}` : c.dim(" (no changes, so no pull request)")}\n`);
238
+ if (final.pullRequestURL)
239
+ io.err(` ${c.dim("Check it out here:")} dl pull ${id}\n`);
240
+ io.err("\n");
241
+ return 0;
242
+ }
243
+ io.err(`\n ${c.red("✗")} ${id} ${outcome}\n\n`);
244
+ return 1;
245
+ }
246
+ /** Writes the agent's text as it streams and one line per tool call. */
247
+ class EventRenderer {
248
+ io;
249
+ c;
250
+ midLine = false;
251
+ constructor(io, c) {
252
+ this.io = io;
253
+ this.c = c;
254
+ }
255
+ render(event) {
256
+ const data = event.data;
257
+ if (event.type === "assistant.delta") {
258
+ const text = typeof data.text === "string" ? data.text : typeof data.delta === "string" ? data.delta : "";
259
+ if (!text)
260
+ return;
261
+ this.io.out(text);
262
+ this.midLine = !text.endsWith("\n");
263
+ return;
264
+ }
265
+ if (event.type === "tool.activity" && data.activity === "started") {
266
+ const input = typeof data.input === "string" ? ` ${oneLine(data.input, 110)}` : "";
267
+ this.line(` ${this.c.dim(`› ${String(data.tool ?? "tool")}${input}`)}`);
268
+ return;
269
+ }
270
+ if (event.type === "tool.activity" && data.activity === "failed") {
271
+ this.line(` ${this.c.red(`✗ ${String(data.tool ?? "tool")} failed`)}`);
272
+ return;
273
+ }
274
+ if (event.type === "checkpoint.created" && typeof data.message === "string") {
275
+ this.line(` ${this.c.dim(`● ${oneLine(data.message, 100)}`)}`);
276
+ }
277
+ }
278
+ finish() {
279
+ if (this.midLine)
280
+ this.io.out("\n");
281
+ this.midLine = false;
282
+ }
283
+ line(text) {
284
+ this.finish();
285
+ this.io.out(`${text}\n`);
286
+ }
287
+ }
288
+ async function findRun(id, server) {
289
+ const needle = id.trim().toLowerCase();
290
+ if (!/^[0-9a-f-]{4,36}$/.test(needle))
291
+ throw new Error("Task IDs are the short hex IDs dl runs shows");
292
+ const remotes = server ? [await resolveRemote(server)] : await loadRemotes();
293
+ const matches = [];
294
+ for (const remote of remotes) {
295
+ const list = await new RemoteClient(remote.url, remote.token).listConversations(200).catch(() => []);
296
+ for (const entry of list)
297
+ if (entry.conversationId.startsWith(needle))
298
+ matches.push({ remote, run: entry });
299
+ }
300
+ if (matches.length === 0)
301
+ throw new Error(`No task ${id} on ${server ?? "any connected server"}`);
302
+ if (matches.length > 1)
303
+ throw new Error(`${id} matches several tasks; use more characters`);
304
+ return matches[0];
305
+ }
306
+ function pickModel(models, requested, preferred) {
307
+ if (requested) {
308
+ if (!models.includes(requested))
309
+ throw new Error(`The server doesn't offer ${requested}`);
310
+ return requested;
311
+ }
312
+ if (preferred && models.includes(preferred))
313
+ return preferred;
314
+ const first = models[0];
315
+ if (!first)
316
+ throw new Error("The server offers no models");
317
+ return first;
318
+ }
319
+ function clientName() {
320
+ const host = hostname().replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
321
+ return `${host} (dl)`;
322
+ }
323
+ /** On a terminal, replace the previous "·" line with its outcome; elsewhere just add a line. */
324
+ function rewrite(io) {
325
+ return io.color === true ? "\x1b[1F\x1b[2K" : "";
326
+ }
327
+ function oneLine(text, max) {
328
+ const flat = text.replace(/\s+/g, " ").trim();
329
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
330
+ }
331
+ export function describeError(error) {
332
+ if (error instanceof RemoteApiError)
333
+ return `${error.message} (${error.code})`;
334
+ return error instanceof Error ? error.message : String(error);
335
+ }
@@ -0,0 +1,89 @@
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 const REMOTE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
5
+ export function remotesPath(env = process.env) {
6
+ if (env.AUTOPILOT_REMOTES_FILE)
7
+ return env.AUTOPILOT_REMOTES_FILE;
8
+ const configHome = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
9
+ return join(configHome, "autopilot", "remotes.json");
10
+ }
11
+ export async function loadRemotes(path = remotesPath()) {
12
+ let raw;
13
+ try {
14
+ raw = await readFile(path, "utf8");
15
+ }
16
+ catch (error) {
17
+ if (error.code === "ENOENT")
18
+ return [];
19
+ throw error;
20
+ }
21
+ const parsed = JSON.parse(raw);
22
+ return Array.isArray(parsed.remotes) ? parsed.remotes.filter(isRemote) : [];
23
+ }
24
+ /** Adds or replaces a remote by name. The file holds bearer tokens, so it is 0600 in a 0700 directory. */
25
+ export async function saveRemote(remote, path = remotesPath()) {
26
+ const remotes = (await loadRemotes(path)).filter((entry) => entry.name !== remote.name);
27
+ remotes.push(remote);
28
+ await writeRemotes(remotes, path);
29
+ }
30
+ export async function removeRemote(name, path = remotesPath()) {
31
+ const remotes = await loadRemotes(path);
32
+ const kept = remotes.filter((entry) => entry.name !== name);
33
+ if (kept.length === remotes.length)
34
+ return false;
35
+ await writeRemotes(kept, path);
36
+ return true;
37
+ }
38
+ /** The named remote, or the only one when no name is given. */
39
+ export async function resolveRemote(name, path = remotesPath()) {
40
+ const remotes = await loadRemotes(path);
41
+ if (remotes.length === 0)
42
+ throw new Error("No servers paired yet. Run: autopilot remote add https://your-server");
43
+ if (name === undefined) {
44
+ if (remotes.length === 1)
45
+ return remotes[0];
46
+ throw new Error(`Several servers are paired; pick one with --on (${remotes.map((entry) => entry.name).join(", ")})`);
47
+ }
48
+ const remote = remotes.find((entry) => entry.name === name);
49
+ if (!remote)
50
+ throw new Error(`No server named "${name}". Paired: ${remotes.map((entry) => entry.name).join(", ")}`);
51
+ return remote;
52
+ }
53
+ /** A short default name from the URL's host: https://acme.example.com -> acme. */
54
+ export function defaultRemoteName(url) {
55
+ const host = new URL(url).hostname.toLowerCase();
56
+ const label = /^\d+(\.\d+){3}$/.test(host) ? host.replace(/\./g, "-") : host.split(".")[0] ?? "server";
57
+ const name = label.replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").slice(0, 40);
58
+ return REMOTE_NAME_RE.test(name) ? name : "server";
59
+ }
60
+ /** Server URLs must be HTTPS, except loopback (an SSH tunnel or local testing). */
61
+ export function normalizeServerUrl(input) {
62
+ let url;
63
+ try {
64
+ url = new URL(/^[a-z]+:\/\//i.test(input) ? input : `https://${input}`);
65
+ }
66
+ catch {
67
+ throw new Error(`Not a URL: ${input}`);
68
+ }
69
+ const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || url.hostname.startsWith("127.");
70
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
71
+ throw new Error("Use an https:// server URL (http:// is only allowed for localhost)");
72
+ }
73
+ if (url.username || url.password || url.search || url.hash)
74
+ throw new Error("The server URL must not include credentials, a query, or a fragment");
75
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
76
+ }
77
+ async function writeRemotes(remotes, path) {
78
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
79
+ const temp = `${path}.${process.pid}.tmp`;
80
+ await writeFile(temp, `${JSON.stringify({ version: 1, remotes }, null, 2)}\n`, { mode: 0o600 });
81
+ await chmod(temp, 0o600);
82
+ await rename(temp, path);
83
+ }
84
+ function isRemote(value) {
85
+ if (!value || typeof value !== "object")
86
+ return false;
87
+ const entry = value;
88
+ return ["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string");
89
+ }