@cabane/companion 0.6.214 → 0.6.215

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
@@ -10,24 +10,40 @@ Pairing supplies a device token, not your Cabane password or a full-account toke
10
10
 
11
11
  ## Install
12
12
 
13
- In a workspace, open **Settings → Devices** and choose **Set up a new device**. The page mints a setup line with a code in it, good for 15 minutes. Paste it in a terminal on the device. One of:
13
+ On the device, in a terminal. One of:
14
14
 
15
15
  ```sh
16
- curl -fsSL https://cabane.ai/install.sh | bash -s -- CODE
16
+ curl -fsSL https://cabane.ai/install.sh | bash
17
17
  ```
18
18
 
19
19
  The script brings its own Node (checksum-checked, into `~/.cabane`) and needs no sudo. Or, with Node 22 or newer already there (the only route on Windows):
20
20
 
21
21
  ```sh
22
- npm i -g @cabane/companion && cabane setup CODE
22
+ npm i -g @cabane/companion && cabane setup
23
23
  ```
24
24
 
25
- `cabane setup CODE` pairs the device to your account and joins it to that workspace. From an interactive terminal, setup starts the Companion in the background, waits for its first check-in and reports the device's state. Without an interactive terminal, setup can stay attached to the running Companion. The same line on a device that is already paired joins it to the workspace and mints nothing.
25
+ `cabane setup` on an unpaired device prints a code and a link:
26
+
27
+ ```
28
+ Pair this device
29
+ Enter this code in Cabane, or press Return to open the page:
30
+
31
+ WXYZ-2345
32
+ https://app.cabane.ai/pair/enter-code?code=WXYZ-2345
33
+
34
+ ⠋ Waiting for you to confirm in Cabane… (the code is good for 15 minutes)
35
+ ```
36
+
37
+ Enter the code in Cabane, signed in as yourself: from your own Settings → Devices → Add a device, from a workspace's Settings → Devices → Set up a new device, or on the page the link opens. Cabane shows which device asked; confirming pairs it to your account and nothing else. A workspace's dialog then adds the device to that workspace; from anywhere else, add it to a workspace from that workspace's Settings → Devices. Pairing from a workspace you're setting up places its default agent on the device once a harness there is usable.
38
+
39
+ Without a terminal, setup prints the same lines once and waits, so a container's logs carry the code. An expired code is replaced by a fresh one, up to three; Ctrl-C stops cleanly. The link is on the server the device pairs with: pass `--server <url>` for your own Cabane.
40
+
41
+ From an interactive terminal, setup then starts the Companion in the background, waits for its first check-in and reports the device's state. Without an interactive terminal, setup stays attached to the running Companion.
26
42
 
27
43
  If no harness is usable, interactive setup asks which one you want to set up. Follow the complete CLI installation and sign-in steps for [Mac setup](https://cabane.ai/docs/mac-setup#2-sign-in), [Windows setup](https://cabane.ai/docs/windows-setup#2-sign-in) or [Server and VPS setup](https://cabane.ai/docs/server-and-vps-setup#2-sign-in-without-a-browser), then check `cabane status`.
28
44
 
29
45
  ```
30
- ✓ Paired as "maya-mbp" with Acme.
46
+ ✓ Paired as "maya-mbp".
31
47
  Cabane is running in the background. This device is ready — checked in 2s ago.
32
48
 
33
49
  Harnesses on this device
@@ -38,7 +54,7 @@ If no harness is usable, interactive setup asks which one you want to set up. Fo
38
54
  Status cabane status Stop cabane stop Log cabane logs
39
55
  Start at login and keep Cabane updating itself: cabane service install
40
56
 
41
- In Cabane: https://app.cabane.ai/acme/settings/devices
57
+ In Cabane: https://app.cabane.ai/settings/devices
42
58
  ```
43
59
 
44
60
  Setup never installs the service. On macOS or Linux, run `cabane service install` when you want the Companion to start at login and keep itself updated; the closing block names it. Native Windows uses the background process and has no service installer.
@@ -51,7 +67,7 @@ The [server guide](https://cabane.ai/docs/server-and-vps-setup) provides a Docke
51
67
 
52
68
  | Command | What it does |
53
69
  | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
- | `setup [CODE]` | With a code: pair or join, detect harnesses, start. Without: a re-runnable checklist that reports harnesses and starts what isn't running. |
70
+ | `setup` | Unpaired: print a code to enter in Cabane, wait for the confirm, then detect harnesses and start. Paired: a re-runnable checklist that reports harnesses and starts what isn't running. |
55
71
  | `start [--foreground]` | Start the Companion without prompts. From an interactive terminal, run in the background; noninteractive use may stay attached. Unpaired, it names `setup`. `--foreground` stays attached for your supervisor. |
56
72
  | `stop` | Stop the Companion cleanly. Ends any turn in progress. |
57
73
  | `status` | Version and install, the paired device by its Cabane name, its state in Cabane's words and when it last checked in, each harness's state, where agents work, declared secret names, and local overrides. |
package/dist/cli.js CHANGED
@@ -1418,7 +1418,7 @@ function pathForLog(path) {
1418
1418
  const q = path.indexOf("?");
1419
1419
  return q === -1 ? path : path.slice(0, q);
1420
1420
  }
1421
- function sleep(ms, signal) {
1421
+ function sleep2(ms, signal) {
1422
1422
  return new Promise((resolve2) => {
1423
1423
  if (signal?.aborted) return resolve2();
1424
1424
  const timer = setTimeout(() => {
@@ -1521,7 +1521,7 @@ var init_api = __esm({
1521
1521
  },
1522
1522
  "companion: API call failed transiently; retrying"
1523
1523
  );
1524
- await sleep(delayMs, signal);
1524
+ await sleep2(delayMs, signal);
1525
1525
  if (signal?.aborted) throw err;
1526
1526
  }
1527
1527
  }
@@ -7510,7 +7510,7 @@ var init_opencode_lock = __esm({
7510
7510
  });
7511
7511
 
7512
7512
  // src/turn-containment.ts
7513
- import { execFile, execFileSync, spawn as spawn3 } from "child_process";
7513
+ import { execFile, execFileSync, spawn as spawn4 } from "child_process";
7514
7514
  import { readFileSync as readFileSync6 } from "fs";
7515
7515
  import { promisify as promisify2 } from "util";
7516
7516
  function turnScopeUnit(turnId, attempt) {
@@ -7639,7 +7639,7 @@ function createTurnContainment(turnId, deps = {}) {
7639
7639
  command: options.command
7640
7640
  });
7641
7641
  return asContained(
7642
- spawn3("systemd-run", [...scopeArgs(unit), options.command, ...options.args], {
7642
+ spawn4("systemd-run", [...scopeArgs(unit), options.command, ...options.args], {
7643
7643
  ...options.cwd ? { cwd: options.cwd } : {},
7644
7644
  env: options.env,
7645
7645
  stdio: ["pipe", "pipe", "pipe"]
@@ -7647,7 +7647,7 @@ function createTurnContainment(turnId, deps = {}) {
7647
7647
  );
7648
7648
  }
7649
7649
  function spawnInGroup(options) {
7650
- const child = spawn3(options.command, options.args, {
7650
+ const child = spawn4(options.command, options.args, {
7651
7651
  ...options.cwd ? { cwd: options.cwd } : {},
7652
7652
  env: options.env,
7653
7653
  stdio: ["pipe", "pipe", "pipe"],
@@ -7729,7 +7729,7 @@ function createTurnContainment(turnId, deps = {}) {
7729
7729
  } catch {
7730
7730
  }
7731
7731
  }
7732
- await sleep2(fallbackGraceMs);
7732
+ await sleep3(fallbackGraceMs);
7733
7733
  const afterTerm = processGroupMembers(pgid);
7734
7734
  if (!afterTerm.ok) {
7735
7735
  result.failures.push({
@@ -7792,7 +7792,7 @@ function createTurnContainment(turnId, deps = {}) {
7792
7792
  }
7793
7793
  };
7794
7794
  }
7795
- var execFileAsync, asContained, CONTAINMENT_STOP_GRACE_SEC, FALLBACK_GRACE_MS, CAPABILITY_PROBE_TIMEOUT_MS, OOM_READ_TIMEOUT_MS, sleep2, MANAGER_ENV_KEYS, managerEnvKey;
7795
+ var execFileAsync, asContained, CONTAINMENT_STOP_GRACE_SEC, FALLBACK_GRACE_MS, CAPABILITY_PROBE_TIMEOUT_MS, OOM_READ_TIMEOUT_MS, sleep3, MANAGER_ENV_KEYS, managerEnvKey;
7796
7796
  var init_turn_containment = __esm({
7797
7797
  "src/turn-containment.ts"() {
7798
7798
  "use strict";
@@ -7802,7 +7802,7 @@ var init_turn_containment = __esm({
7802
7802
  FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
7803
7803
  CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
7804
7804
  OOM_READ_TIMEOUT_MS = 1500;
7805
- sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
7805
+ sleep3 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
7806
7806
  MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
7807
7807
  managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
7808
7808
  }
@@ -11729,7 +11729,6 @@ async function liveCompanionState() {
11729
11729
  // src/commands/setup.ts
11730
11730
  init_cabane_command();
11731
11731
  init_config();
11732
- import { hostname as hostname2 } from "os";
11733
11732
 
11734
11733
  // src/logger.ts
11735
11734
  init_config();
@@ -11904,61 +11903,346 @@ function getLogger() {
11904
11903
  return cached;
11905
11904
  }
11906
11905
 
11906
+ // src/pair-flow.ts
11907
+ init_cabane_command();
11908
+ import { spawn as spawn3 } from "child_process";
11909
+ import { hostname } from "os";
11910
+
11907
11911
  // src/enrollment.ts
11908
11912
  init_errors();
11909
- async function claimSetupCode(baseUrl, input) {
11913
+ var EnrollmentExpiredError = class extends CompanionError {
11914
+ constructor() {
11915
+ super("this pairing code expired before it was confirmed.");
11916
+ this.name = "EnrollmentExpiredError";
11917
+ }
11918
+ };
11919
+ var EnrollmentCancelledError = class extends CompanionError {
11920
+ constructor() {
11921
+ super("pairing was cancelled.");
11922
+ this.name = "EnrollmentCancelledError";
11923
+ }
11924
+ };
11925
+ function trimBase(baseUrl) {
11926
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
11927
+ }
11928
+ async function postJson(baseUrl, path, body, signal) {
11910
11929
  let res;
11911
11930
  try {
11912
- res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/device-enrollment/claim`, {
11931
+ res = await fetch(`${trimBase(baseUrl)}${path}`, {
11913
11932
  method: "POST",
11914
- headers: {
11915
- "content-type": "application/json",
11916
- ...input.deviceToken ? { authorization: `Bearer ${input.deviceToken}` } : {}
11917
- },
11918
- body: JSON.stringify({
11919
- userCode: input.userCode,
11920
- label: input.label,
11921
- platform: process.platform,
11922
- arch: process.arch,
11923
- nodeMajor: Number.parseInt(process.versions.node.split(".")[0], 10)
11924
- })
11933
+ headers: { "content-type": "application/json", accept: "application/json" },
11934
+ body: JSON.stringify(body),
11935
+ ...signal ? { signal } : {}
11925
11936
  });
11926
11937
  } catch {
11938
+ if (signal?.aborted) throw new EnrollmentCancelledError();
11927
11939
  throw new CompanionError(
11928
11940
  `Couldn't reach Cabane at ${baseUrl}. Check the server URL and your connection.`
11929
11941
  );
11930
11942
  }
11931
- if (res.status === 429)
11932
- throw new CompanionError(
11933
- "Too many attempts from this network. Wait a few minutes and paste the setup line again."
11934
- );
11935
- if (res.status === 404 && input.deviceToken)
11936
- throw new CompanionError(
11937
- "The setup code or saved device credential is unavailable. If this device was removed, run cabane logout, then take a fresh setup line from your workspace\u2019s Settings \u2192 Devices."
11938
- );
11939
- if (res.status === 404 || res.status === 410)
11943
+ let raw;
11944
+ try {
11945
+ raw = await res.text();
11946
+ } catch {
11947
+ if (signal?.aborted) throw new EnrollmentCancelledError();
11940
11948
  throw new CompanionError(
11941
- "That setup code is unavailable. Take a fresh setup line from your workspace\u2019s Settings \u2192 Devices."
11949
+ `Couldn't reach Cabane at ${baseUrl}. Check the server URL and your connection.`
11942
11950
  );
11943
- if (!res.ok) throw new ApiError(res.status, `Setup failed (${res.status}).`);
11944
- const body = await res.json();
11945
- if (!body || typeof body !== "object" || !("baseUrl" in body) || typeof body.baseUrl !== "string" || !("deviceId" in body) || typeof body.deviceId !== "string")
11946
- throw new CompanionError("Cabane returned an incomplete setup response.");
11947
- const token = "deviceToken" in body && typeof body.deviceToken === "string" ? body.deviceToken : input.deviceToken;
11948
- if (!token) throw new CompanionError("Cabane returned no device credential.");
11951
+ }
11952
+ let parsed = void 0;
11953
+ if (raw.length > 0) {
11954
+ try {
11955
+ parsed = JSON.parse(raw);
11956
+ } catch {
11957
+ parsed = raw;
11958
+ }
11959
+ }
11960
+ if (res.status >= 400) {
11961
+ if (res.status === 429)
11962
+ throw new CompanionError(
11963
+ "Too many pairing requests from this network. Wait a few minutes and run cabane setup again."
11964
+ );
11965
+ if (res.status === 404 && path.endsWith("/code"))
11966
+ throw new CompanionError(
11967
+ `No pairing endpoint at ${baseUrl}. Either that server is too old for this companion, or it isn't a Cabane API origin: the hosted app is https://app.cabane.ai (pass \`--server <url>\` for your own instance).`
11968
+ );
11969
+ const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
11970
+ throw new ApiError(res.status, `${res.status} ${msg}`, parsed);
11971
+ }
11972
+ return parsed;
11973
+ }
11974
+ function requestEnrollmentCode(baseUrl, opts = {}) {
11975
+ return postJson(
11976
+ baseUrl,
11977
+ "/api/device-enrollment/code",
11978
+ { ...opts.label ? { label: opts.label } : {}, ...hostFacts() },
11979
+ opts.signal
11980
+ );
11981
+ }
11982
+ function hostFacts() {
11983
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10);
11949
11984
  return {
11950
- baseUrl: body.baseUrl,
11951
- deviceToken: token,
11952
- deviceId: body.deviceId,
11953
- ..."workspace" in body && body.workspace && typeof body.workspace === "object" && "name" in body.workspace && typeof body.workspace.name === "string" && "slug" in body.workspace && typeof body.workspace.slug === "string" ? { workspace: { name: body.workspace.name, slug: body.workspace.slug } } : {},
11954
- ..."devicesUrl" in body && typeof body.devicesUrl === "string" ? { devicesUrl: body.devicesUrl } : {},
11955
- ..."deviceLabel" in body && typeof body.deviceLabel === "string" ? { deviceLabel: body.deviceLabel } : {}
11985
+ platform: process.platform,
11986
+ arch: process.arch,
11987
+ ...Number.isInteger(nodeMajor) ? { nodeMajor } : {}
11956
11988
  };
11957
11989
  }
11958
11990
  function deviceLabelFromHostname(hostname3) {
11959
11991
  const trimmed = hostname3.trim().replace(/\.local$/i, "");
11960
11992
  return trimmed.length ? trimmed.slice(0, 120) : void 0;
11961
11993
  }
11994
+ function pollOnce(baseUrl, deviceCode, signal) {
11995
+ return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode }, signal);
11996
+ }
11997
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
11998
+ async function awaitEnrollment(baseUrl, code, opts = {}) {
11999
+ const now = opts.now ?? (() => Date.now());
12000
+ const wait = opts.sleepMs ?? sleep;
12001
+ const deadline = now() + code.expiresIn * 1e3;
12002
+ let intervalMs = Math.max(1, code.interval) * 1e3;
12003
+ while (now() < deadline) {
12004
+ await wait(intervalMs);
12005
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
12006
+ const res = await pollOnce(baseUrl, code.deviceCode, opts.signal);
12007
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
12008
+ if (res.status === "complete") {
12009
+ if (!res.deviceToken || !res.baseUrl)
12010
+ throw new CompanionError("Cabane reported the pairing complete but returned no token.");
12011
+ return {
12012
+ baseUrl: res.baseUrl,
12013
+ deviceToken: res.deviceToken,
12014
+ ...res.deviceId ? { deviceId: res.deviceId } : {},
12015
+ ...res.deviceLabel ? { deviceLabel: res.deviceLabel } : {}
12016
+ };
12017
+ }
12018
+ if (res.status === "expired") throw new EnrollmentExpiredError();
12019
+ if (res.status === "slow_down") intervalMs += 1e3;
12020
+ }
12021
+ throw new EnrollmentExpiredError();
12022
+ }
12023
+
12024
+ // src/terminal-copy.ts
12025
+ init_cabane_command();
12026
+ var UNPAIRED = `This device isn't paired yet. Run ${cabane("setup")} on it.`;
12027
+ var lease = DEFAULT_DEVICE_OFFLINE_THRESHOLD_MS;
12028
+ function age(at, now) {
12029
+ const seconds = Math.max(0, Math.floor((now - Date.parse(at)) / 1e3));
12030
+ return seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m` : `${Math.floor(seconds / 3600)}h`;
12031
+ }
12032
+ function deviceLine(f, now = Date.now()) {
12033
+ if (!f.running) return `not running \u2014 start it with ${cabane("start")}`;
12034
+ if (f.lastHeartbeatRefused)
12035
+ return `refused by the server \u2014 it may be deactivated; see ${f.devicesUrl}`;
12036
+ if (!f.lastHeartbeatAckAt) return "starting \u2014 hasn't checked in yet";
12037
+ const since = age(f.lastHeartbeatAckAt, now);
12038
+ if (now - Date.parse(f.lastHeartbeatAckAt) > lease)
12039
+ return `can't reach the server \u2014 last checked in ${since} ago`;
12040
+ const view = f.lastAcknowledgedDevice;
12041
+ if (!view) return `checked in ${since} ago`;
12042
+ const word = view.state === "online" && view.health ? view.health === "checking" ? "online, checking harnesses\u2026" : DEVICE_HEALTH_LABEL[view.health] : DEVICE_STATE_LABEL[view.state];
12043
+ return `${word.toLowerCase()} \u2014 checked in ${since} ago`;
12044
+ }
12045
+ function pairingLine(pair) {
12046
+ return `\u2713 Paired as "${pair.deviceLabel ?? "Computer"}".`;
12047
+ }
12048
+ function pathLines(reload) {
12049
+ return reload ? [` To use cabane in this window: ${reload}`, ""] : [];
12050
+ }
12051
+ function agentLines(detection, opts) {
12052
+ const rows = agentRows(detection, opts);
12053
+ const names = rows.map((row3) => row3.usable ? `\u2713 ${row3.label}` : row3.label);
12054
+ const width = Math.max(...names.map((name) => name.length)) + 3;
12055
+ return rows.map((row3, i) => `${names[i].padEnd(width)}${row3.text}`);
12056
+ }
12057
+ var modeLines = {
12058
+ background: "Cabane is running in the background",
12059
+ service: "Cabane is running as a service",
12060
+ attached: "Cabane is running in this process",
12061
+ existing: "Cabane was already running"
12062
+ };
12063
+ function closingBlock(input, now = Date.now()) {
12064
+ const { mode, facts } = input;
12065
+ const device = deviceLine(facts, now);
12066
+ const noAck = !facts.lastHeartbeatAckAt && !facts.lastHeartbeatRefused;
12067
+ const stale = facts.lastHeartbeatAckAt !== null && now - Date.parse(facts.lastHeartbeatAckAt) > lease;
12068
+ const modeLine = modeLines[mode];
12069
+ const sentence = noAck ? `${mode === "background" ? "Cabane started in the background" : modeLine} but hasn't checked in yet.` : `${modeLine}. ${facts.lastHeartbeatRefused || stale ? device[0].toUpperCase() + device.slice(1) : `This device ${device.startsWith("checked in") || device.startsWith("needs attention") ? "" : "is "}${device}`}.`;
12070
+ const lines2 = [` ${sentence}`, "", ...pathLines(input.reload)];
12071
+ if (noAck || stale || facts.lastHeartbeatRefused) {
12072
+ lines2.push(
12073
+ ` See what it's doing: ${cabane("status")}${facts.lastHeartbeatRefused ? "" : ` Log: ${cabane("logs")}`}`
12074
+ );
12075
+ } else {
12076
+ lines2.push(" Harnesses on this device");
12077
+ lines2.push(
12078
+ ...(input.detection ? agentLines(input.detection, { signInHint: true }) : ["Claude Code checking", "Codex checking", "OpenCode checking"]).map((line) => ` ${line}`)
12079
+ );
12080
+ lines2.push(
12081
+ "",
12082
+ ` Status ${cabane("status")}${mode === "attached" ? "" : ` Stop ${cabane("stop")}`} Log ${cabane("logs")}`
12083
+ );
12084
+ if (!input.service && input.servicePlatform) {
12085
+ lines2.push(
12086
+ ` Start at login and keep Cabane updating itself: ${cabane("service install")}${input.servicePlatform === "launchd" ? " (macOS will ask you to allow a background item)" : ""}`
12087
+ );
12088
+ lines2.push(
12089
+ ` Updates itself only as a service; to turn that off: ${cabane("update --auto off")}`
12090
+ );
12091
+ } else if (input.service && input.autoUpdate)
12092
+ lines2.push(` Updating itself; to turn that off: ${cabane("update --auto off")}`);
12093
+ }
12094
+ if (input.address) lines2.push("", ` In Cabane: ${input.address}`);
12095
+ if (mode === "attached") lines2.push("", " Listening for messages\u2026");
12096
+ return lines2;
12097
+ }
12098
+ function startFailure(message2, reload) {
12099
+ return [
12100
+ ...pathLines(reload),
12101
+ `\u2717 Cabane couldn't start: ${message2}`,
12102
+ "",
12103
+ ` Log: ${cabane("logs")} Try again: ${cabane("start")}`
12104
+ ];
12105
+ }
12106
+
12107
+ // src/pair-flow.ts
12108
+ var MAX_CODES = 3;
12109
+ async function pairDevice(opts, deps = {}) {
12110
+ const requestCode = deps.requestCode ?? requestEnrollmentCode;
12111
+ const wait = deps.await ?? awaitEnrollment;
12112
+ const open2 = deps.open ?? openLink;
12113
+ const { ui, interactive } = opts;
12114
+ const label = deviceLabelFromHostname(hostname());
12115
+ const aborter = new AbortController();
12116
+ const onSigint = () => aborter.abort();
12117
+ process.on("SIGINT", onSigint);
12118
+ const spinner = ui.spinner();
12119
+ let current = null;
12120
+ const keys = interactive ? watchReturn({
12121
+ onReturn: () => {
12122
+ if (!current) return;
12123
+ const link = current.verificationUriComplete;
12124
+ void open2(link).then((opened) => {
12125
+ if (!opened)
12126
+ spinner.message(
12127
+ `Couldn't open a browser here. Open this page on any device: ${link}`
12128
+ );
12129
+ });
12130
+ },
12131
+ onInterrupt: () => aborter.abort(),
12132
+ ...deps.input ? { input: deps.input } : {}
12133
+ }) : null;
12134
+ let minutesWaited = 0;
12135
+ let waiting = false;
12136
+ try {
12137
+ for (let attempt = 1; attempt <= MAX_CODES; attempt += 1) {
12138
+ let code;
12139
+ try {
12140
+ code = await requestCode(opts.baseUrl, {
12141
+ ...label ? { label } : {},
12142
+ signal: aborter.signal
12143
+ });
12144
+ } catch (err) {
12145
+ if (!(err instanceof EnrollmentCancelledError)) throw err;
12146
+ ui.cancel(`Pairing cancelled. Run ${cabane("setup")} when you're ready.`);
12147
+ process.exitCode = 130;
12148
+ return null;
12149
+ }
12150
+ current = code;
12151
+ const minutes = Math.max(1, Math.round(code.expiresIn / 60));
12152
+ const codeLines = ["", ` ${code.userCode}`, ` ${code.verificationUriComplete}`, ""];
12153
+ if (attempt === 1) {
12154
+ ui.step([
12155
+ "Pair this device",
12156
+ interactive ? "Enter this code in Cabane, or press Return to open the page:" : "Enter this code in Cabane:",
12157
+ ...codeLines
12158
+ ]);
12159
+ } else {
12160
+ ui.step(codeLines.slice(1));
12161
+ }
12162
+ spinner.start(
12163
+ `Waiting for you to confirm in Cabane\u2026 (the code is good for ${minutes} minutes)`
12164
+ );
12165
+ waiting = true;
12166
+ try {
12167
+ const device = await wait(opts.baseUrl, code, { signal: aborter.signal });
12168
+ waiting = false;
12169
+ spinner.stop(pairingLine(device));
12170
+ return device;
12171
+ } catch (err) {
12172
+ waiting = false;
12173
+ if (err instanceof EnrollmentCancelledError) {
12174
+ spinner.stop("Pairing stopped.");
12175
+ ui.cancel(`Pairing cancelled. Run ${cabane("setup")} when you're ready.`);
12176
+ process.exitCode = 130;
12177
+ return null;
12178
+ }
12179
+ if (!(err instanceof EnrollmentExpiredError)) {
12180
+ spinner.stop("Pairing stopped.");
12181
+ throw err;
12182
+ }
12183
+ minutesWaited += minutes;
12184
+ if (attempt < MAX_CODES) {
12185
+ spinner.stop("That code expired. Here's a fresh one:");
12186
+ continue;
12187
+ }
12188
+ spinner.stop(
12189
+ `Nothing confirmed in ${minutesWaited} minutes. Run ${cabane("setup")} when you're ready.`
12190
+ );
12191
+ process.exitCode = 1;
12192
+ return null;
12193
+ }
12194
+ }
12195
+ return null;
12196
+ } finally {
12197
+ if (waiting) spinner.stop("Pairing stopped.");
12198
+ keys?.release();
12199
+ process.removeListener("SIGINT", onSigint);
12200
+ }
12201
+ }
12202
+ function watchReturn(handlers) {
12203
+ const input = handlers.input ?? process.stdin;
12204
+ if (!input.isTTY || typeof input.setRawMode !== "function") return { release: () => {
12205
+ } };
12206
+ const wasRaw = input.isRaw;
12207
+ const onData = (chunk) => {
12208
+ const text = chunk.toString();
12209
+ if (text.includes("")) handlers.onInterrupt();
12210
+ else if (text.includes("\r") || text.includes("\n")) handlers.onReturn();
12211
+ };
12212
+ input.setRawMode(true);
12213
+ input.on("data", onData);
12214
+ input.resume();
12215
+ return {
12216
+ release: () => {
12217
+ input.removeListener("data", onData);
12218
+ input.setRawMode(wasRaw);
12219
+ input.pause();
12220
+ }
12221
+ };
12222
+ }
12223
+ function pageOpener(platform, link) {
12224
+ if (platform === "darwin") return { command: "open", args: [link] };
12225
+ if (platform === "win32") return { command: "cmd", args: ["/c", "start", '""', `"${link}"`] };
12226
+ return { command: "xdg-open", args: [link] };
12227
+ }
12228
+ function openLink(link) {
12229
+ return new Promise((resolve2) => {
12230
+ const { command, args } = pageOpener(process.platform, link);
12231
+ try {
12232
+ const child = spawn3(command, args, {
12233
+ stdio: "ignore",
12234
+ detached: true,
12235
+ // `cmd` parses its own line: the quoting above is the one it needs, as written.
12236
+ ...process.platform === "win32" ? { windowsVerbatimArguments: true } : {}
12237
+ });
12238
+ child.once("error", () => resolve2(false));
12239
+ child.once("exit", (code) => resolve2(code === 0));
12240
+ child.unref();
12241
+ } catch {
12242
+ resolve2(false);
12243
+ }
12244
+ });
12245
+ }
11962
12246
 
11963
12247
  // src/commands/start.ts
11964
12248
  init_cabane_command();
@@ -12501,7 +12785,7 @@ var emptyHeartbeatStatus = () => ({
12501
12785
 
12502
12786
  // src/supervisor.ts
12503
12787
  init_cabane_command();
12504
- import { hostname } from "os";
12788
+ import { hostname as hostname2 } from "os";
12505
12789
 
12506
12790
  // src/log-copy.ts
12507
12791
  init_failure();
@@ -13541,7 +13825,7 @@ function hostReport() {
13541
13825
  }
13542
13826
 
13543
13827
  // src/providers/local-process.ts
13544
- import { spawn as spawn4 } from "child_process";
13828
+ import { spawn as spawn5 } from "child_process";
13545
13829
  import { constants as constants2 } from "os";
13546
13830
  var DEFAULT_KILL_GRACE_MS = 3e4;
13547
13831
  var DEFAULT_REAP_GRACE_MS = 3e3;
@@ -13552,7 +13836,7 @@ function exitCode(code, signal) {
13552
13836
  }
13553
13837
  function createLocalProcessProvider(opts = {}) {
13554
13838
  const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
13555
- const spawnFn = opts.spawnFn ?? spawn4;
13839
+ const spawnFn = opts.spawnFn ?? spawn5;
13556
13840
  const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
13557
13841
  const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
13558
13842
  const groupAlive = (pgid) => {
@@ -13928,7 +14212,7 @@ var SseSubscriber = class {
13928
14212
  init_transcript_writer();
13929
14213
 
13930
14214
  // src/updater.ts
13931
- import { spawn as spawn5 } from "child_process";
14215
+ import { spawn as spawn6 } from "child_process";
13932
14216
  import { copyFileSync as copyFileSync2, existsSync as existsSync16, mkdirSync as mkdirSync14, renameSync as renameSync9, rmSync as rmSync10 } from "fs";
13933
14217
  import { dirname as dirname11, join as join26 } from "path";
13934
14218
 
@@ -14143,7 +14427,7 @@ var runProcess = (command, args, opts = {}) => new Promise((resolve2) => {
14143
14427
  let stderr = "";
14144
14428
  let child;
14145
14429
  try {
14146
- child = spawn5(command, [...args], {
14430
+ child = spawn6(command, [...args], {
14147
14431
  stdio: ["ignore", "pipe", "pipe"],
14148
14432
  env: opts.env ?? process.env
14149
14433
  });
@@ -14647,7 +14931,7 @@ var CompanionSupervisor = class {
14647
14931
  if (this.stopped) return;
14648
14932
  this.log.info(
14649
14933
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
14650
- `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname()}`
14934
+ `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
14651
14935
  );
14652
14936
  const retired = retiredConfigKeysOnDisk();
14653
14937
  if (retired.length > 0) {
@@ -14682,9 +14966,7 @@ var CompanionSupervisor = class {
14682
14966
  this.detectTimer = setInterval(() => void this.detect(), DETECT_INTERVAL_MS);
14683
14967
  this.detectTimer.unref?.();
14684
14968
  if (!this.deviceApi || !this.config.deviceToken) {
14685
- this.log.warn(
14686
- "This device isn't paired. Get a setup line from a workspace's Settings \u2192 Devices."
14687
- );
14969
+ this.log.warn(`This device isn't paired. Run ${cabane("setup")} on it.`);
14688
14970
  return;
14689
14971
  }
14690
14972
  this.deviceSub = new SseSubscriber({
@@ -14695,7 +14977,7 @@ var CompanionSupervisor = class {
14695
14977
  log: this.log,
14696
14978
  lastEventId: null,
14697
14979
  onOpen: () => {
14698
- this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname()}`);
14980
+ this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`);
14699
14981
  this.deviceConnected = true;
14700
14982
  for (const wr of this.workspaces.values()) {
14701
14983
  this.hub.setConnected(wr.workspaceId, true);
@@ -15928,9 +16210,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
15928
16210
  }
15929
16211
  function defaultReexec() {
15930
16212
  clearRuntimeState();
15931
- void import("child_process").then(({ spawn: spawn7 }) => {
16213
+ void import("child_process").then(({ spawn: spawn8 }) => {
15932
16214
  try {
15933
- const child = spawn7(process.execPath, process.argv.slice(1), {
16215
+ const child = spawn8(process.execPath, process.argv.slice(1), {
15934
16216
  stdio: "inherit",
15935
16217
  detached: false
15936
16218
  });
@@ -16279,13 +16561,13 @@ async function observeStart(deps = {}) {
16279
16561
  const read2 = deps.readState ?? readLiveRuntimeState;
16280
16562
  const verify = deps.verify ?? verifyRuntime;
16281
16563
  const now = deps.now ?? Date.now;
16282
- const sleep3 = deps.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
16564
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
16283
16565
  const deadline = now() + 8e3;
16284
16566
  for (; ; ) {
16285
16567
  const state = await read2();
16286
16568
  if (state?.socket && await verify(state) === "ours") return state;
16287
16569
  if (now() >= deadline) return null;
16288
- await sleep3(Math.min(150, Math.max(0, deadline - now())));
16570
+ await sleep4(Math.min(150, Math.max(0, deadline - now())));
16289
16571
  }
16290
16572
  }
16291
16573
  function markLaunchRequester() {
@@ -16703,7 +16985,7 @@ async function controlService(action, deps) {
16703
16985
  init_cabane_command();
16704
16986
  init_cli_entry();
16705
16987
  init_config();
16706
- import { spawn as spawn6 } from "child_process";
16988
+ import { spawn as spawn7 } from "child_process";
16707
16989
  import { closeSync as closeSync3, mkdirSync as mkdirSync17, openSync as openSync4 } from "fs";
16708
16990
  init_config();
16709
16991
  async function startDaemon(opts = {}, deps = {}) {
@@ -16785,7 +17067,7 @@ function defaultSpawnDetached(args) {
16785
17067
  mkdirSync17(cabaneDir(), { recursive: true });
16786
17068
  const logFd = openSync4(companionLogPath(), "a");
16787
17069
  try {
16788
- const child = spawn6(process.execPath, [cliPath, ...args], {
17070
+ const child = spawn7(process.execPath, [cliPath, ...args], {
16789
17071
  detached: true,
16790
17072
  stdio: ["ignore", logFd, logFd],
16791
17073
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -16803,89 +17085,6 @@ function defaultSpawnDetached(args) {
16803
17085
  }
16804
17086
  }
16805
17087
 
16806
- // src/terminal-copy.ts
16807
- init_cabane_command();
16808
- var UNPAIRED = "This device isn't paired yet. Get a setup line from a workspace's Settings \u2192 Devices and paste it here.";
16809
- var lease = DEFAULT_DEVICE_OFFLINE_THRESHOLD_MS;
16810
- function age(at, now) {
16811
- const seconds = Math.max(0, Math.floor((now - Date.parse(at)) / 1e3));
16812
- return seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m` : `${Math.floor(seconds / 3600)}h`;
16813
- }
16814
- function deviceLine(f, now = Date.now()) {
16815
- if (!f.running) return `not running \u2014 start it with ${cabane("start")}`;
16816
- if (f.lastHeartbeatRefused)
16817
- return `refused by the server \u2014 it may be deactivated; see ${f.devicesUrl}`;
16818
- if (!f.lastHeartbeatAckAt) return "starting \u2014 hasn't checked in yet";
16819
- const since = age(f.lastHeartbeatAckAt, now);
16820
- if (now - Date.parse(f.lastHeartbeatAckAt) > lease)
16821
- return `can't reach the server \u2014 last checked in ${since} ago`;
16822
- const view = f.lastAcknowledgedDevice;
16823
- if (!view) return `checked in ${since} ago`;
16824
- const word = view.state === "online" && view.health ? view.health === "checking" ? "online, checking harnesses\u2026" : DEVICE_HEALTH_LABEL[view.health] : DEVICE_STATE_LABEL[view.state];
16825
- return `${word.toLowerCase()} \u2014 checked in ${since} ago`;
16826
- }
16827
- function pairingLine(pair) {
16828
- return `\u2713 Paired as "${pair.deviceLabel ?? "Computer"}" with ${pair.workspace?.name ?? "the workspace"}.`;
16829
- }
16830
- function pathLines(reload) {
16831
- return reload ? [` To use cabane in this window: ${reload}`, ""] : [];
16832
- }
16833
- function agentLines(detection, opts) {
16834
- const rows = agentRows(detection, opts);
16835
- const names = rows.map((row3) => row3.usable ? `\u2713 ${row3.label}` : row3.label);
16836
- const width = Math.max(...names.map((name) => name.length)) + 3;
16837
- return rows.map((row3, i) => `${names[i].padEnd(width)}${row3.text}`);
16838
- }
16839
- var modeLines = {
16840
- background: "Cabane is running in the background",
16841
- service: "Cabane is running as a service",
16842
- attached: "Cabane is running in this process",
16843
- existing: "Cabane was already running"
16844
- };
16845
- function closingBlock(input, now = Date.now()) {
16846
- const { mode, facts } = input;
16847
- const device = deviceLine(facts, now);
16848
- const noAck = !facts.lastHeartbeatAckAt && !facts.lastHeartbeatRefused;
16849
- const stale = facts.lastHeartbeatAckAt !== null && now - Date.parse(facts.lastHeartbeatAckAt) > lease;
16850
- const modeLine = modeLines[mode];
16851
- const sentence = noAck ? `${mode === "background" ? "Cabane started in the background" : modeLine} but hasn't checked in yet.` : `${modeLine}. ${facts.lastHeartbeatRefused || stale ? device[0].toUpperCase() + device.slice(1) : `This device ${device.startsWith("checked in") || device.startsWith("needs attention") ? "" : "is "}${device}`}.`;
16852
- const lines2 = [` ${sentence}`, "", ...pathLines(input.reload)];
16853
- if (noAck || stale || facts.lastHeartbeatRefused) {
16854
- lines2.push(
16855
- ` See what it's doing: ${cabane("status")}${facts.lastHeartbeatRefused ? "" : ` Log: ${cabane("logs")}`}`
16856
- );
16857
- } else {
16858
- lines2.push(" Harnesses on this device");
16859
- lines2.push(
16860
- ...(input.detection ? agentLines(input.detection, { signInHint: true }) : ["Claude Code checking", "Codex checking", "OpenCode checking"]).map((line) => ` ${line}`)
16861
- );
16862
- lines2.push(
16863
- "",
16864
- ` Status ${cabane("status")}${mode === "attached" ? "" : ` Stop ${cabane("stop")}`} Log ${cabane("logs")}`
16865
- );
16866
- if (!input.service && input.servicePlatform) {
16867
- lines2.push(
16868
- ` Start at login and keep Cabane updating itself: ${cabane("service install")}${input.servicePlatform === "launchd" ? " (macOS will ask you to allow a background item)" : ""}`
16869
- );
16870
- lines2.push(
16871
- ` Updates itself only as a service; to turn that off: ${cabane("update --auto off")}`
16872
- );
16873
- } else if (input.service && input.autoUpdate)
16874
- lines2.push(` Updating itself; to turn that off: ${cabane("update --auto off")}`);
16875
- }
16876
- if (input.address) lines2.push("", ` In Cabane: ${input.address}`);
16877
- if (mode === "attached") lines2.push("", " Listening for messages\u2026");
16878
- return lines2;
16879
- }
16880
- function startFailure(message2, reload) {
16881
- return [
16882
- ...pathLines(reload),
16883
- `\u2717 Cabane couldn't start: ${message2}`,
16884
- "",
16885
- ` Log: ${cabane("logs")} Try again: ${cabane("start")}`
16886
- ];
16887
- }
16888
-
16889
17088
  // src/terminal-status.ts
16890
17089
  async function readTerminalStatus(socket, timeoutMs = 1e3) {
16891
17090
  if (!socket) return emptyHeartbeatStatus();
@@ -17024,18 +17223,17 @@ async function startScript(opts, deps) {
17024
17223
  if (service2.state !== "running" || service2.pid !== current.pid) mode = "existing";
17025
17224
  }
17026
17225
  }
17027
- const personalUrl = `${cfg.baseUrl.replace(/\/$/, "")}/settings/devices`;
17028
- const address = opts.pairing ? opts.pairing.devicesUrl : personalUrl;
17226
+ const address = `${cfg.baseUrl.replace(/\/$/, "")}/settings/devices`;
17029
17227
  if (!daemon)
17030
17228
  for (const line of closingBlock({
17031
17229
  mode,
17032
- facts: { ...status2, running: true, devicesUrl: address ?? personalUrl },
17230
+ facts: { ...status2, running: true, devicesUrl: address },
17033
17231
  detection,
17034
17232
  reload: reloadLine(),
17035
17233
  service: installed,
17036
17234
  servicePlatform: servicePlatform(),
17037
17235
  autoUpdate: cfg.update?.auto !== false,
17038
- ...address ? { address } : {}
17236
+ address
17039
17237
  }))
17040
17238
  write(line);
17041
17239
  if (runtime) {
@@ -17171,22 +17369,22 @@ async function setup(opts = {}, deps = {}) {
17171
17369
  } catch (err) {
17172
17370
  for (const line of pathLines(reloadLine())) write(line);
17173
17371
  write(err instanceof Error ? err.message : String(err));
17174
- write(
17175
- "Check the server connection, then take a fresh setup line from the workspace's Settings \u2192 Devices."
17176
- );
17372
+ write(`Check the server connection, then run ${cabane("setup")} again.`);
17177
17373
  process.exitCode = 1;
17178
17374
  } finally {
17179
17375
  setConsoleLogging(true);
17180
17376
  }
17181
17377
  }
17182
17378
  async function setupScript(opts, deps) {
17183
- const interactive = deps.interactive ?? isInteractive();
17184
- const paired = isDevicePaired();
17185
- if (!opts.code && !paired) {
17186
- write(UNPAIRED);
17379
+ if (opts.code) {
17380
+ write(
17381
+ `Setup codes come from this device now: run ${cabane("setup")} and enter the code it shows in Cabane.`
17382
+ );
17187
17383
  process.exitCode = 1;
17188
17384
  return;
17189
17385
  }
17386
+ const interactive = deps.interactive ?? isInteractive();
17387
+ const paired = isDevicePaired();
17190
17388
  const ui = deps.ui ?? (interactive ? terminalUi() : plainUi());
17191
17389
  const running = await (deps.live ?? liveCompanionState)();
17192
17390
  const cfg = safeLoadConfig();
@@ -17197,32 +17395,16 @@ async function setupScript(opts, deps) {
17197
17395
  process.exitCode = 1;
17198
17396
  return;
17199
17397
  }
17200
- let device;
17201
- if (opts.code) {
17398
+ let justPaired = false;
17399
+ if (!paired) {
17202
17400
  const baseUrl = resolvePairBaseUrl(opts.server ?? cfg?.baseUrl);
17203
- if (paired && cfg && new URL(cfg.baseUrl).origin !== new URL(baseUrl).origin) {
17204
- for (const line of pathLines(reloadLine())) write(line);
17205
- write(
17206
- "This device is paired to another Cabane server. Log out before setting it up on a different server."
17207
- );
17208
- write(`Run ${cabane("logout")} before using the new setup line.`);
17209
- process.exitCode = 1;
17210
- return;
17211
- }
17212
- device = await (deps.claim ?? claimSetupCode)(baseUrl, {
17213
- userCode: opts.code,
17214
- label: deviceLabelFromHostname(hostname2()) ?? "Computer",
17215
- ...paired && cfg?.deviceToken ? { deviceToken: cfg.deviceToken } : {}
17216
- });
17401
+ const device = await (deps.pair ?? pairDevice)({ baseUrl, interactive, ui });
17402
+ if (!device) return;
17217
17403
  writePairedConfig(device);
17218
- write(pairingLine(device));
17404
+ justPaired = true;
17219
17405
  }
17220
17406
  const detection = await start(
17221
- {
17222
- ...opts,
17223
- ...device ? { pairing: device } : {},
17224
- ...!interactive && device ? { foreground: true } : {}
17225
- },
17407
+ { ...opts, ...!interactive && justPaired ? { foreground: true } : {} },
17226
17408
  { ...deps, interactive }
17227
17409
  );
17228
17410
  if (interactive && detection && !anyUsable(detection)) {
@@ -17443,7 +17625,7 @@ async function stop(deps = {}) {
17443
17625
  const readState = deps.readState ?? readLiveRuntimeState;
17444
17626
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
17445
17627
  const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
17446
- const sleep3 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17628
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17447
17629
  const now = deps.now ?? (() => Date.now());
17448
17630
  const state = readState();
17449
17631
  if (!state) {
@@ -17471,7 +17653,7 @@ async function stop(deps = {}) {
17471
17653
  process.stdout.write("companion: stopped.\n");
17472
17654
  return;
17473
17655
  }
17474
- await sleep3(POLL_INTERVAL_MS);
17656
+ await sleep4(POLL_INTERVAL_MS);
17475
17657
  }
17476
17658
  process.stdout.write(
17477
17659
  `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
@@ -17497,7 +17679,7 @@ function isAlive(kill, pid) {
17497
17679
  var STARTUP_WAIT_MS = 2e4;
17498
17680
  async function installAndStartService(deps = {}) {
17499
17681
  const readState = deps.readState ?? readLiveRuntimeState;
17500
- const sleep3 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17682
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17501
17683
  const now = deps.now ?? Date.now;
17502
17684
  const before = await serviceState(deps);
17503
17685
  const running = readState();
@@ -17513,7 +17695,7 @@ async function installAndStartService(deps = {}) {
17513
17695
  while (now() < deadline) {
17514
17696
  const state = readState();
17515
17697
  if (state?.socket) return true;
17516
- await sleep3(250);
17698
+ await sleep4(250);
17517
17699
  }
17518
17700
  write(
17519
17701
  `${INDENT}The service is installed but Cabane hasn't come up yet. ${cabane("service status")} and ${cabane("logs")} say why.`
@@ -17643,9 +17825,7 @@ async function status(deps = {}) {
17643
17825
  cfg = null;
17644
17826
  }
17645
17827
  if (!cfg || !cfg.deviceToken) {
17646
- out(
17647
- " Not paired yet. Get a setup line from a workspace's Settings \u2192 Devices and paste it here."
17648
- );
17828
+ out(` Not paired yet. Run ${cabane("setup")} on this device.`);
17649
17829
  for (const line of strays) out(line);
17650
17830
  process.exitCode = 1;
17651
17831
  return;
@@ -18308,7 +18488,7 @@ init_cabane_command();
18308
18488
  rememberInvokedCommand();
18309
18489
  var program = new Command();
18310
18490
  program.name("cabane").description("Run your Cabane agents on this device, with Claude Code, Codex or OpenCode.").version(COMPANION_VERSION);
18311
- program.command("setup [code]").description("set up this device: find your agents, pair it with Cabane, and keep it running.").allowExcessArguments(false).option("--server <url>", "the Cabane instance to pair with (default https://app.cabane.ai)").addOption(
18491
+ program.command("setup").argument("[code]", "not taken: run cabane setup and enter the code it shows in Cabane").description("set up this device: find your agents, pair it with Cabane, and keep it running.").allowExcessArguments(false).option("--server <url>", "the Cabane instance to pair with (default https://app.cabane.ai)").addOption(
18312
18492
  new Option("--log-level <level>", "info (default), warn, or debug").choices([
18313
18493
  "info",
18314
18494
  "warn",
@@ -413,55 +413,112 @@ function saveConfig(cfg) {
413
413
  }
414
414
 
415
415
  // src/enrollment.ts
416
- async function claimSetupCode(baseUrl, input) {
416
+ var EnrollmentExpiredError = class extends CompanionError {
417
+ constructor() {
418
+ super("this pairing code expired before it was confirmed.");
419
+ this.name = "EnrollmentExpiredError";
420
+ }
421
+ };
422
+ var EnrollmentCancelledError = class extends CompanionError {
423
+ constructor() {
424
+ super("pairing was cancelled.");
425
+ this.name = "EnrollmentCancelledError";
426
+ }
427
+ };
428
+ function trimBase(baseUrl) {
429
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
430
+ }
431
+ async function postJson(baseUrl, path, body, signal) {
417
432
  let res;
418
433
  try {
419
- res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/device-enrollment/claim`, {
434
+ res = await fetch(`${trimBase(baseUrl)}${path}`, {
420
435
  method: "POST",
421
- headers: {
422
- "content-type": "application/json",
423
- ...input.deviceToken ? { authorization: `Bearer ${input.deviceToken}` } : {}
424
- },
425
- body: JSON.stringify({
426
- userCode: input.userCode,
427
- label: input.label,
428
- platform: process.platform,
429
- arch: process.arch,
430
- nodeMajor: Number.parseInt(process.versions.node.split(".")[0], 10)
431
- })
436
+ headers: { "content-type": "application/json", accept: "application/json" },
437
+ body: JSON.stringify(body),
438
+ ...signal ? { signal } : {}
432
439
  });
433
440
  } catch {
441
+ if (signal?.aborted) throw new EnrollmentCancelledError();
434
442
  throw new CompanionError(
435
443
  `Couldn't reach Cabane at ${baseUrl}. Check the server URL and your connection.`
436
444
  );
437
445
  }
438
- if (res.status === 429)
439
- throw new CompanionError(
440
- "Too many attempts from this network. Wait a few minutes and paste the setup line again."
441
- );
442
- if (res.status === 404 && input.deviceToken)
443
- throw new CompanionError(
444
- "The setup code or saved device credential is unavailable. If this device was removed, run cabane logout, then take a fresh setup line from your workspace\u2019s Settings \u2192 Devices."
445
- );
446
- if (res.status === 404 || res.status === 410)
446
+ let raw;
447
+ try {
448
+ raw = await res.text();
449
+ } catch {
450
+ if (signal?.aborted) throw new EnrollmentCancelledError();
447
451
  throw new CompanionError(
448
- "That setup code is unavailable. Take a fresh setup line from your workspace\u2019s Settings \u2192 Devices."
452
+ `Couldn't reach Cabane at ${baseUrl}. Check the server URL and your connection.`
449
453
  );
450
- if (!res.ok) throw new ApiError(res.status, `Setup failed (${res.status}).`);
451
- const body = await res.json();
452
- if (!body || typeof body !== "object" || !("baseUrl" in body) || typeof body.baseUrl !== "string" || !("deviceId" in body) || typeof body.deviceId !== "string")
453
- throw new CompanionError("Cabane returned an incomplete setup response.");
454
- const token = "deviceToken" in body && typeof body.deviceToken === "string" ? body.deviceToken : input.deviceToken;
455
- if (!token) throw new CompanionError("Cabane returned no device credential.");
454
+ }
455
+ let parsed = void 0;
456
+ if (raw.length > 0) {
457
+ try {
458
+ parsed = JSON.parse(raw);
459
+ } catch {
460
+ parsed = raw;
461
+ }
462
+ }
463
+ if (res.status >= 400) {
464
+ if (res.status === 429)
465
+ throw new CompanionError(
466
+ "Too many pairing requests from this network. Wait a few minutes and run cabane setup again."
467
+ );
468
+ if (res.status === 404 && path.endsWith("/code"))
469
+ throw new CompanionError(
470
+ `No pairing endpoint at ${baseUrl}. Either that server is too old for this companion, or it isn't a Cabane API origin: the hosted app is https://app.cabane.ai (pass \`--server <url>\` for your own instance).`
471
+ );
472
+ const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
473
+ throw new ApiError(res.status, `${res.status} ${msg}`, parsed);
474
+ }
475
+ return parsed;
476
+ }
477
+ function requestEnrollmentCode(baseUrl, opts = {}) {
478
+ return postJson(
479
+ baseUrl,
480
+ "/api/device-enrollment/code",
481
+ { ...opts.label ? { label: opts.label } : {}, ...hostFacts() },
482
+ opts.signal
483
+ );
484
+ }
485
+ function hostFacts() {
486
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10);
456
487
  return {
457
- baseUrl: body.baseUrl,
458
- deviceToken: token,
459
- deviceId: body.deviceId,
460
- ..."workspace" in body && body.workspace && typeof body.workspace === "object" && "name" in body.workspace && typeof body.workspace.name === "string" && "slug" in body.workspace && typeof body.workspace.slug === "string" ? { workspace: { name: body.workspace.name, slug: body.workspace.slug } } : {},
461
- ..."devicesUrl" in body && typeof body.devicesUrl === "string" ? { devicesUrl: body.devicesUrl } : {},
462
- ..."deviceLabel" in body && typeof body.deviceLabel === "string" ? { deviceLabel: body.deviceLabel } : {}
488
+ platform: process.platform,
489
+ arch: process.arch,
490
+ ...Number.isInteger(nodeMajor) ? { nodeMajor } : {}
463
491
  };
464
492
  }
493
+ function pollOnce(baseUrl, deviceCode, signal) {
494
+ return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode }, signal);
495
+ }
496
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
497
+ async function awaitEnrollment(baseUrl, code, opts = {}) {
498
+ const now = opts.now ?? (() => Date.now());
499
+ const wait = opts.sleepMs ?? sleep;
500
+ const deadline = now() + code.expiresIn * 1e3;
501
+ let intervalMs = Math.max(1, code.interval) * 1e3;
502
+ while (now() < deadline) {
503
+ await wait(intervalMs);
504
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
505
+ const res = await pollOnce(baseUrl, code.deviceCode, opts.signal);
506
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
507
+ if (res.status === "complete") {
508
+ if (!res.deviceToken || !res.baseUrl)
509
+ throw new CompanionError("Cabane reported the pairing complete but returned no token.");
510
+ return {
511
+ baseUrl: res.baseUrl,
512
+ deviceToken: res.deviceToken,
513
+ ...res.deviceId ? { deviceId: res.deviceId } : {},
514
+ ...res.deviceLabel ? { deviceLabel: res.deviceLabel } : {}
515
+ };
516
+ }
517
+ if (res.status === "expired") throw new EnrollmentExpiredError();
518
+ if (res.status === "slow_down") intervalMs += 1e3;
519
+ }
520
+ throw new EnrollmentExpiredError();
521
+ }
465
522
 
466
523
  // src/pairing-config.ts
467
524
  var DEFAULT_BASE_URL = "https://app.cabane.ai";
@@ -496,8 +553,11 @@ function isDevicePaired() {
496
553
  export {
497
554
  ApiError,
498
555
  CompanionError,
499
- claimSetupCode,
556
+ EnrollmentCancelledError,
557
+ EnrollmentExpiredError,
558
+ awaitEnrollment,
500
559
  isDevicePaired,
560
+ requestEnrollmentCode,
501
561
  resolvePairBaseUrl,
502
562
  writePairedConfig
503
563
  };
package/dist/runtime.js CHANGED
@@ -10201,9 +10201,7 @@ var CompanionSupervisor = class {
10201
10201
  this.detectTimer = setInterval(() => void this.detect(), DETECT_INTERVAL_MS);
10202
10202
  this.detectTimer.unref?.();
10203
10203
  if (!this.deviceApi || !this.config.deviceToken) {
10204
- this.log.warn(
10205
- "This device isn't paired. Get a setup line from a workspace's Settings \u2192 Devices."
10206
- );
10204
+ this.log.warn(`This device isn't paired. Run ${cabane("setup")} on it.`);
10207
10205
  return;
10208
10206
  }
10209
10207
  this.deviceSub = new SseSubscriber({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.214",
3
+ "version": "0.6.215",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect an agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",