directionallylocal 0.8.0 → 0.8.1

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/README.md CHANGED
@@ -11,7 +11,7 @@ dl
11
11
 
12
12
  `dl` asks where your agents should run:
13
13
 
14
- - **On this computer** — start now, nothing to rent, pauses when this computer sleeps.
14
+ - **On this computer** — start now, nothing to rent, pauses when this computer sleeps. macOS and Linux; not offered on Windows yet (connect a server, or run `dl` inside WSL 2, which takes the Linux path).
15
15
  - **On my own server** — keeps going when your laptop is closed; needs SSH access.
16
16
 
17
17
  For a server, `dl` lists the hosts in `~/.ssh/config` (or takes `user@host[:port]`), checks it over SSH, installs itself there, and pairs this laptop. Nothing is exposed on the server: `dl` reaches it through an SSH tunnel.
@@ -55,7 +55,7 @@ export async function projectSession(input, io) {
55
55
  note(describeError(error));
56
56
  return undefined;
57
57
  }
58
- const { remote } = target;
58
+ const { remote, reason } = target;
59
59
  const key = { place: remote.name, repository, root };
60
60
  const file = input.sessionsFile ?? sessionsPath();
61
61
  const autopilot = await (input.autopilot ?? findAutopilot)();
@@ -142,7 +142,12 @@ export async function projectSession(input, io) {
142
142
  // `dl new`: the old conversation keeps running on the place (dl runs lists it).
143
143
  if (input.fresh)
144
144
  await forgetSession(key, file);
145
- io.err(`\n ${c.cyan("·")} Connecting to ${c.bold(remote.name)} · ${repository} … ${c.dim(`(${detail})`)}\n`);
145
+ // The place is on the Connecting line already; with more than one set up, also why this
146
+ // one and how to pick another, as `dl run` does (a place named with --server needs neither).
147
+ const chosen = reason === "default" || reason === "last used";
148
+ io.err(`\n ${c.cyan("·")} Connecting to ${c.bold(remote.name)}${chosen ? ` ${c.dim(`(${reason})`)}` : ""} · ${repository} … ${c.dim(`(${detail})`)}\n`);
149
+ if (chosen)
150
+ io.err(` ${c.dim("dl use NAME to change where dl runs")}\n`);
146
151
  // The token goes in the environment only: argv is visible to every user on the machine.
147
152
  const env = { ...process.env, AUTOPILOT_REMOTE_URL: client.baseUrl, AUTOPILOT_REMOTE_TOKEN: remote.token };
148
153
  let saved = remembered?.conversationId;
@@ -37,7 +37,10 @@ export async function loadSettings(path = settingsPath()) {
37
37
  }
38
38
  try {
39
39
  const parsed = JSON.parse(raw);
40
- return typeof parsed.model === "string" && parsed.model.trim() ? { model: parsed.model.trim() } : {};
40
+ return {
41
+ ...(typeof parsed.model === "string" && parsed.model.trim() ? { model: parsed.model.trim() } : {}),
42
+ ...(parsed.askedLocalDefault === true ? { askedLocalDefault: true } : {}),
43
+ };
41
44
  }
42
45
  catch {
43
46
  // A damaged file only loses the saved model; the next save rewrites it.
@@ -45,7 +48,11 @@ export async function loadSettings(path = settingsPath()) {
45
48
  }
46
49
  }
47
50
  export async function saveModel(model, path = settingsPath()) {
48
- const settings = { ...(await loadSettings(path)), model };
51
+ await saveSettings({ model }, path);
52
+ }
53
+ /** Merges into dl's settings, keeping what's already there. */
54
+ export async function saveSettings(changes, path = settingsPath()) {
55
+ const settings = { ...(await loadSettings(path)), ...changes };
49
56
  await mkdir(dirname(path), { recursive: true });
50
57
  const temp = `${path}.${process.pid}.tmp`;
51
58
  await writeFile(temp, `${JSON.stringify({ version: 1, ...settings }, null, 2)}\n`, { mode: 0o600 });
@@ -5,11 +5,12 @@ import { join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { PromptCancelled, TtyPrompter } from "../prompt.js";
7
7
  import { palette, PlainUi, TerminalUi } from "../ui.js";
8
- import { loadRemotes, loadRemotesFile, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, saveRemote, setDefaultRemote } from "./config.js";
8
+ import { localUnsupported } from "../local/check.js";
9
+ import { loadRemotes, loadRemotesFile, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, resolveTarget, saveRemote, setDefaultRemote } from "./config.js";
9
10
  import { confirm, gatherKeys } from "./credentials.js";
10
11
  import { bootstrap, INSTALL_URL, installOnServer, pairLaptop, serverNeeds } from "./remote-install.js";
11
12
  import { checkServer, describeHost, discoverKeys, hostOf, parseSshConfig, parseTarget, realSshExec, SshAuthError } from "./ssh.js";
12
- import { askModel, DEFAULT_MODEL, loadSettings, modelPrices, readAutopilotModel, saveModel } from "./settings.js";
13
+ import { askModel, DEFAULT_MODEL, loadSettings, modelPrices, readAutopilotModel, saveModel, saveSettings } from "./settings.js";
13
14
  import { openRemote, withConnections } from "./tunnel.js";
14
15
  const RUN_EXAMPLE = 'dl run "fix the flaky checkout test"';
15
16
  const PLACES = [
@@ -20,6 +21,13 @@ export async function setupWizard(deps) {
20
21
  const c = palette(deps.color);
21
22
  deps.write(`\n ${c.bold("Directionally Local")}\n Background coding agents. Where should they run?\n\n`);
22
23
  const ui = deps.newUi();
24
+ // Where this computer can't run agents (Windows), the list would hold one real choice: say why, and go on.
25
+ const unsupported = localUnsupported(deps.platform ?? process.platform);
26
+ if (unsupported) {
27
+ ui.line("info", "On this computer", unsupported);
28
+ ui.answered("Where", "On my own server");
29
+ return setUpServer(deps);
30
+ }
23
31
  let where = (await deps.prompter.select(ui.prompt("Where"), PLACES, 0)).value;
24
32
  ui.answered("Where", where === "local" ? "On this computer" : "On my own server");
25
33
  if (where === "local" && !deps.localSetup) {
@@ -39,8 +47,7 @@ export async function setupWizard(deps) {
39
47
  const remotesFile = deps.remotesFile ?? remotesPath();
40
48
  const isNew = !(await loadRemotes(remotesFile)).some((entry) => entry.name === remote.name);
41
49
  await saveRemote(remote, remotesFile);
42
- if (isNew)
43
- await chooseDefault(deps, remote, remotesFile);
50
+ await offerThisComputer(deps, remote, remotesFile, isNew);
44
51
  await chooseModel(deps);
45
52
  finishScreen(deps.newUi(), capacity);
46
53
  return remote;
@@ -142,7 +149,7 @@ async function chooseServer(deps, hosts) {
142
149
  }
143
150
  /**
144
151
  * 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
152
+ * only when setup adds a server, with the new one preselected (this computer: offerThisComputer); the first place becomes the
146
153
  * default by itself (saveRemote).
147
154
  */
148
155
  async function chooseDefault(deps, added, remotesFile) {
@@ -158,6 +165,36 @@ async function chooseDefault(deps, added, remotesFile) {
158
165
  ui.answered("Default", value);
159
166
  ui.finish();
160
167
  }
168
+ /**
169
+ * After "on this computer", when commands would still go somewhere else (a server set up
170
+ * first stays the default, saveRemote): asks once, at a terminal, whether this computer
171
+ * should be the default instead. Asked again only when this computer is newly added.
172
+ * Otherwise (no terminal, already asked, or "no") it says where things go and how to change it.
173
+ */
174
+ async function offerThisComputer(deps, local, remotesFile, isNew) {
175
+ const { remotes } = await loadRemotesFile(remotesFile);
176
+ if (remotes.length < 2)
177
+ return;
178
+ const current = await resolveTarget(undefined, remotesFile).then((target) => target.remote.name, () => undefined);
179
+ if (current === local.name)
180
+ return;
181
+ const hint = `dl use ${local.name}`;
182
+ const ui = deps.newUi();
183
+ deps.write("\n");
184
+ if (deps.interactive !== false && (isNew || !(await loadSettings(deps.settingsFile)).askedLocalDefault)) {
185
+ const question = current ? `Make this computer the default? Tasks and sessions go to ${current} now` : "Make this computer the default for tasks and sessions?";
186
+ const yes = await confirm(ui, deps.prompter, question);
187
+ await saveSettings({ askedLocalDefault: true }, deps.settingsFile);
188
+ if (yes) {
189
+ await setDefaultRemote(local.name, remotesFile);
190
+ ui.answered("Default", "this computer");
191
+ ui.finish();
192
+ return;
193
+ }
194
+ }
195
+ ui.line("info", "Default", current ? `still ${current} · ${hint} to run here instead` : `none yet · ${hint} to run here`);
196
+ ui.finish();
197
+ }
161
198
  /**
162
199
  * Which model tasks and sessions use, asked once: setup skips it when dl already has one
163
200
  * (dl model changes it). Autopilot's own default is preselected for people who have one.
@@ -335,6 +372,7 @@ export async function setup(options) {
335
372
  installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
336
373
  ...(options.localSetup ? { localSetup: options.localSetup } : {}),
337
374
  ...(identityFile ? { identityFile } : {}),
375
+ interactive: terminal && process.stdin.isTTY === true,
338
376
  });
339
377
  }
340
378
  catch (error) {
@@ -41,9 +41,9 @@ export async function checkComputer(system, options) {
41
41
  /** What stops setup before it changes anything, each with how to fix it. */
42
42
  export function computerProblems(facts) {
43
43
  const problems = [];
44
- if (facts.platform !== "darwin" && facts.platform !== "linux") {
45
- return [`Running agents on this computer works on macOS and Linux, not ${facts.platform}. Set up a server instead: curl -fsSL https://directionallylocal.run | sh`];
46
- }
44
+ const unsupported = localUnsupported(facts.platform);
45
+ if (unsupported)
46
+ return [unsupported];
47
47
  if (facts.platform === "linux" && !facts.systemdUser) {
48
48
  problems.push("A systemd user session is needed to keep the agents running (systemctl --user). Log in to a desktop session, or set up a server instead.");
49
49
  }
@@ -79,6 +79,20 @@ function dockerFix(facts) {
79
79
  export function localCapacity(docker) {
80
80
  return agentCapacity({ memoryMb: docker.memoryMb, cpus: docker.cpus }, LOCAL_CELL_MEMORY_MB);
81
81
  }
82
+ /**
83
+ * Why agents can't run on this computer's platform, or undefined on macOS and Linux. The
84
+ * local setup is POSIX throughout (sh, launchd or systemd --user, unix sockets), so other
85
+ * platforms are refused before anything runs. WSL reports "linux" and takes the Linux path.
86
+ */
87
+ export function localUnsupported(platform) {
88
+ if (supportedPlatform(platform))
89
+ return undefined;
90
+ if (platform === "win32") {
91
+ return "Running on this computer isn't supported on Windows yet; connect a server instead (dl setup, then \"On my own server\"). "
92
+ + "Inside WSL 2, dl runs as on Linux (it needs systemd and Docker there).";
93
+ }
94
+ return `Running agents on this computer works on macOS and Linux, not ${platform}. Connect a server instead: dl setup, then "On my own server".`;
95
+ }
82
96
  export function supportedPlatform(platform) {
83
97
  return platform === "darwin" || platform === "linux" ? platform : undefined;
84
98
  }
@@ -8,7 +8,7 @@ import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { checkKey, githubListing, hotcellKeyListed, KeyRejectedError } from "../keys.js";
10
10
  import { RealSystem } from "../system.js";
11
- import { supportedPlatform } from "./check.js";
11
+ import { localUnsupported, supportedPlatform } from "./check.js";
12
12
  import { LOCAL_SERVICES, localGithubEnv, localHotcellBin, localPaths, parseLocalEnvFile, serviceFile } from "./layout.js";
13
13
  import { httpStatus } from "./probes.js";
14
14
  import { userId } from "./services.js";
@@ -16,9 +16,10 @@ import { hotcellCliEnv } from "./setup.js";
16
16
  import { readLocalState } from "./state.js";
17
17
  async function installed(options) {
18
18
  const home = options.home ?? homedir();
19
- const platform = supportedPlatform(options.platform ?? process.platform);
19
+ const raw = options.platform ?? process.platform;
20
+ const platform = supportedPlatform(raw);
20
21
  if (!platform)
21
- throw new Error("Agents run on this computer only on macOS and Linux.");
22
+ throw new Error(localUnsupported(raw));
22
23
  const system = options.system ?? new RealSystem(() => { });
23
24
  const paths = localPaths(home, platform);
24
25
  const state = await readLocalState(system, paths);
@@ -26,7 +26,7 @@ import { join } from "node:path";
26
26
  import { cellDockerfile, cellImage } from "../layout.js";
27
27
  import { removeRemote } from "../laptop/config.js";
28
28
  import { RealSystem } from "../system.js";
29
- import { checkComputer, computerProblems, localCapacity, servicePath, supportedPlatform } from "./check.js";
29
+ import { checkComputer, computerProblems, localCapacity, localUnsupported, servicePath, supportedPlatform } from "./check.js";
30
30
  import { LOCAL_SERVICES, PAIRED_NAME, PREFERRED_PORTS, autopilotXdg, localAsterConfig, localAutopilotBin, localAutopilotEnv, localGithubEnv, localHotcellBin, localHotcellEnv, localPaths, parseLocalEnvFile, serviceSpecs, } from "./layout.js";
31
31
  import { fileMtime, modelPricesHealth, refreshModelPrices } from "../model-prices.js";
32
32
  import { httpStatus, portFree } from "./probes.js";
@@ -37,10 +37,16 @@ const SETUP_LOG = "setup.log";
37
37
  export function localSetupLog(home = homedir()) {
38
38
  return join(home, ".directionallylocal", "logs", SETUP_LOG);
39
39
  }
40
+ /** Stops on a platform the local setup can't run on (Windows), before any command runs. */
41
+ export function assertLocalSupported(platform = process.platform) {
42
+ const unsupported = localUnsupported(platform);
43
+ if (unsupported)
44
+ throw new Error(unsupported);
45
+ }
40
46
  function context(options, logName) {
41
47
  const home = options.home ?? homedir();
42
48
  const rawPlatform = options.platform ?? process.platform;
43
- // Unsupported platforms still get paths (for a clear refusal); nothing is written there.
49
+ // Callers refuse unsupported platforms first (assertLocalSupported); this only keeps types simple.
44
50
  const platform = supportedPlatform(rawPlatform) ?? "linux";
45
51
  return {
46
52
  // Other tools' output goes to a log file (or nowhere), never the screen.
@@ -54,12 +60,15 @@ function context(options, logName) {
54
60
  }
55
61
  /** Checks this computer without changing anything, e.g. before the wizard asks for keys. */
56
62
  export async function checkThisComputer(options = {}) {
63
+ assertLocalSupported(options.platform);
57
64
  const ctx = context(options);
58
65
  const facts = await checkComputer(ctx.system, checkOptions(ctx, options));
59
66
  return { facts, problems: computerProblems(facts) };
60
67
  }
61
68
  export async function setUpOnThisComputer(input) {
62
69
  const { ui } = input;
70
+ // Before the checklist: on Windows nothing here (sh, launchd/systemd, unix sockets) works.
71
+ assertLocalSupported(input.platform);
63
72
  const ctx = context(input, SETUP_LOG);
64
73
  const { system, paths, platform } = ctx;
65
74
  const now = () => (input.now ?? (() => new Date()))().toISOString();
@@ -84,9 +93,6 @@ export async function setUpOnThisComputer(input) {
84
93
  };
85
94
  const { facts, uid } = await step("check", async () => {
86
95
  let facts = await checkComputer(system, checkOptions(ctx, input));
87
- const unsupported = computerProblems(facts);
88
- if (ctx.rawPlatform !== "darwin" && ctx.rawPlatform !== "linux")
89
- throw new Error(unsupported[0]);
90
96
  const uid = await userId(system);
91
97
  if (!facts.docker && facts.dockerApp && platform === "darwin") {
92
98
  ui.start("check", `opening ${facts.dockerApp}`);
@@ -231,6 +237,7 @@ export async function setUpOnThisComputer(input) {
231
237
  */
232
238
  export async function uninstallFromThisComputer(input) {
233
239
  const { ui } = input;
240
+ assertLocalSupported(input.platform);
234
241
  const ctx = context(input, "uninstall.log");
235
242
  const { system, paths, platform } = ctx;
236
243
  const state = await readLocalState(system, paths);
@@ -284,6 +291,7 @@ export async function uninstallFromThisComputer(input) {
284
291
  }
285
292
  /** `dl local status`: is it running, where, and how much fits. Read-only. */
286
293
  export async function localStatus(options = {}) {
294
+ assertLocalSupported(options.platform);
287
295
  const ctx = context(options);
288
296
  const { system, paths, platform } = ctx;
289
297
  const state = await readLocalState(system, paths);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",