@cabane/companion 0.6.156 → 0.6.157

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
@@ -106,6 +106,8 @@ A freshly paired device runs nothing yet: pairing doesn't put any agent on it. I
106
106
  | `pair [--server <url>]` | Pair this device with a short code entered in Cabane, without starting the Companion. Refuses, like `start`, until an agent is signed in. `start` pairs by itself, so `pair` is rarely needed. |
107
107
  | `start [--foreground] [--daemon]` | Offer the signed-in agents, pair if needed, and run in the background; refuse, pairing nothing, when none is usable. `--daemon` is an accepted compatibility alias for the default. `--foreground` stays attached for a terminal or supervisor and never refuses. A non-interactive `start` on a paired device also stays attached. |
108
108
  | `connect <claude-code\|codex\|opencode>` | Connect a signed-in agent; with the Companion running, it checks and connects over its control socket, no restart. Refuses a signed-out agent and changes nothing. |
109
+ | `update [--auto on\|off]` | Update to the version Cabane names now, between turns; with `--auto`, set whether the Companion keeps itself up to date. |
110
+ | `service install\|uninstall\|status` | Run the Companion as a systemd user service or a LaunchAgent that starts at boot and restarts if it stops; remove it; show its state and how updates are going. |
109
111
  | `stop` | Stop a foreground or detached Companion cleanly. Idempotent — "nothing running" is a success. |
110
112
  | `status` | Print the version and install, the paired device, whether it's running, each agent's sign-in, log and transcript paths, declared secret names, and local overrides. Works unpaired. |
111
113
  | `logs [-n N] [-f]` | Show recent log lines or follow new ones. |
@@ -128,119 +130,15 @@ If `cabane start` says the Companion is running but the device stays **Offline**
128
130
 
129
131
  ## Background and supervised operation
130
132
 
131
- In an interactive terminal, `cabane start` backgrounds itself, returns the prompt, and keeps running after the terminal closes:
133
+ In an interactive terminal, `cabane start` backgrounds itself, returns the prompt, and keeps running after the terminal closes. The first time, it asks once whether to keep Cabane running and up to date on this computer.
132
134
 
133
- ```sh
134
- cabane start
135
- # ✓ Cabane is running in the background.
136
- # Stop: cabane stop Logs: cabane logs
137
- ```
138
-
139
- `start --daemon` takes the same path for compatibility. This detached process does not survive logout or reboot and Cabane does not install, enable, or update a login service. Use `cabane status` to check it and `cabane stop` to stop it.
140
-
141
- If you want restart or login persistence, configure your own supervisor to run `cabane start --foreground`. The supervisor owns when the process starts and restarts; the Companion never writes these files for you.
142
-
143
- ### Find the executable paths
144
-
145
- The examples below need absolute paths because service managers do not load your interactive shell setup:
146
-
147
- ```sh
148
- command -v node
149
- printf '%s\n' "$(npm root -g)/@cabane/companion/dist/cli.js"
150
- ```
151
-
152
- Confirm both paths exist, then substitute them for `/absolute/path/to/node` and `/absolute/path/to/cli.js`. NVM and global npm paths are often versioned and can move after a Node or package upgrade. After every upgrade, run these commands again, update both paths in your supervisor config, and reload/restart it.
153
-
154
- ### macOS LaunchAgent
155
-
156
- Save this as `~/Library/LaunchAgents/ai.cabane.companion.plist`, replacing both paths:
157
-
158
- ```xml
159
- <?xml version="1.0" encoding="UTF-8"?>
160
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
161
- <plist version="1.0">
162
- <dict>
163
- <key>Label</key><string>ai.cabane.companion</string>
164
- <key>ProgramArguments</key>
165
- <array>
166
- <string>/absolute/path/to/node</string>
167
- <string>/absolute/path/to/cli.js</string>
168
- <string>start</string>
169
- <string>--foreground</string>
170
- </array>
171
- <key>RunAtLoad</key><true/>
172
- <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
173
- <key>StandardOutPath</key><string>/tmp/cabane-companion.out.log</string>
174
- <key>StandardErrorPath</key><string>/tmp/cabane-companion.err.log</string>
175
- </dict>
176
- </plist>
177
- ```
178
-
179
- Load it with `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.cabane.companion.plist`. Adding a login-persistent LaunchAgent is security-sensitive behavior and may be reported by macOS or endpoint-security tooling. Cabane cannot prevent or hide that report.
180
-
181
- To update moved executable paths, unload it, edit the two paths, then load it again:
182
-
183
- ```sh
184
- launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.cabane.companion.plist
185
- launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.cabane.companion.plist
186
- ```
187
-
188
- To remove it completely:
189
-
190
- ```sh
191
- launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.cabane.companion.plist
192
- rm ~/Library/LaunchAgents/ai.cabane.companion.plist
193
- ```
194
-
195
- ### Linux systemd user unit
196
-
197
- Save this as `~/.config/systemd/user/cabane-companion.service`, replacing both paths:
198
-
199
- ```ini
200
- [Unit]
201
- Description=Cabane Companion
202
- After=network-online.target
203
-
204
- [Service]
205
- Type=simple
206
- ExecStart=/absolute/path/to/node /absolute/path/to/cli.js start --foreground
207
- Restart=on-failure
208
- RestartSec=5
209
-
210
- [Install]
211
- WantedBy=default.target
212
- ```
213
-
214
- Load it with:
215
-
216
- ```sh
217
- systemctl --user daemon-reload
218
- systemctl --user enable --now cabane-companion.service
219
- ```
220
-
221
- A user service normally stops when its login session ends. If this machine must keep running it after logout, an administrator can enable lingering with `sudo loginctl enable-linger "$USER"`; that is a separate operating-system persistence decision.
222
-
223
- After an NVM/npm upgrade, stop the unit, rediscover and replace both executable paths, then reload and restart it:
224
-
225
- ```sh
226
- systemctl --user stop cabane-companion.service
227
- systemctl --user daemon-reload
228
- systemctl --user start cabane-companion.service
229
- ```
230
-
231
- To remove it completely:
232
-
233
- ```sh
234
- systemctl --user disable --now cabane-companion.service
235
- rm ~/.config/systemd/user/cabane-companion.service
236
- systemctl --user daemon-reload
237
- ```
135
+ Yes, or `cabane service install` later, runs it as a service: a systemd user unit (`~/.config/systemd/user/cabane-companion.service`) on Linux, a LaunchAgent (`~/Library/LaunchAgents/ai.cabane.companion.plist`) on macOS, with auto-update on. It runs `<node> ~/.cabane/boot-guard.mjs start --foreground` on its own copy of this install under `~/.cabane/versions`, restarts it whatever it exits with, and reads `~/.cabane/service.env`, which holds your login shell's PATH and any headless sign-in (`CLAUDE_CODE_OAUTH_TOKEN=…`). `<node>` is the install script's `~/.cabane/node/bin/node` when it's there, otherwise the Node that ran `service install`; `cabane service status` says if it's gone. On Linux it tells you when lingering is off and a logout would stop it. With a service installed, `cabane start` and `cabane stop` start and stop the service; `cabane service uninstall` removes it.
238
136
 
239
- If you enabled lingering only for this Companion, undo it separately with `sudo loginctl disable-linger "$USER"`.
137
+ Updates need the service: it's what restarts the Companion into a new version. The Companion follows the version Cabane names on each heartbeat: it installs it under `~/.cabane/versions/<version>`, checks it starts and still has the bundled agents this device uses, points `~/.cabane/current` at it between turns, and exits for the service to restart it. A version that stops three times before connecting, or can't connect within ten minutes of Cabane answering, is rolled back by the boot guard. `cabane update --auto off|on` stops or resumes updates under the service. Without the service, `cabane update` installs the latest version and the next `cabane start` runs it.
240
138
 
241
139
  ### Other supervisors
242
140
 
243
- Run `cabane start --foreground`. Configure the supervisor itself for restart and login/boot behavior, and use absolute executable paths when it does not load your interactive shell. Removing the supervisor configuration removes the persistence; no Cabane-managed service remains.
141
+ Run `cabane start --foreground` with `CABANE_COMPANION_SUPERVISED=1` in its environment. Configure the supervisor itself for restart and login/boot behavior, and use absolute executable paths when it does not load your interactive shell: `~/.cabane/node/bin/node` and `~/.cabane/current/dist/bin.js` don't move on an update. Without the boot guard a supervisor of your own doesn't roll back a release that fails before it starts.
244
142
 
245
143
  ## Configuring an agent: what's in Cabane vs. on this machine
246
144
 
@@ -489,4 +387,4 @@ It renders the conversation step by step — `🔧 tool(input) → ok/ERROR: res
489
387
 
490
388
  ## What v0 doesn't do yet
491
389
 
492
- There's no OS-service install — an interactive `start` runs detached but doesn't survive logout/reboot or auto-restart on crash. A non-interactive `start` stays attached for a supervisor or provisioner. There's no per-tool permissioning for an agent with host access yet. And there's no self-update; re-run `npm i -g @cabane/companion@latest` to pick up a new version.
390
+ Windows has no service install and no self-update. There's no per-tool permissioning for an agent with host access yet.
package/dist/bin.js CHANGED
@@ -10,4 +10,5 @@ if (!(major >= MIN_NODE_MAJOR)) {
10
10
  );
11
11
  process.exit(1);
12
12
  }
13
- await import("./cli.js");
13
+ var { launch } = await import("./launch.js");
14
+ await launch();
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/boot-guard.ts
4
+ import { homedir } from "os";
5
+ import { join as join2 } from "path";
6
+ import { pathToFileURL } from "url";
7
+
8
+ // src/boot-rule.ts
9
+ import {
10
+ mkdirSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ symlinkSync,
16
+ writeFileSync
17
+ } from "fs";
18
+ import { dirname, join } from "path";
19
+ var UNCONFIRMED_WINDOW_MS = 10 * 6e4;
20
+ var MAX_UNCONFIRMED_BOOTS = 3;
21
+ var MAX_STAGE_ATTEMPTS = 3;
22
+ function bootVerdict(pending, version, lastCrash) {
23
+ if (!pending || pending.to !== version) return { kind: "run" };
24
+ if (pending.boots >= MAX_UNCONFIRMED_BOOTS) {
25
+ return {
26
+ kind: "rollback",
27
+ pending,
28
+ reason: lastCrash ?? `it stopped ${pending.boots} times before connecting to Cabane`
29
+ };
30
+ }
31
+ return { kind: "run", pending: { ...pending, boots: pending.boots + 1 } };
32
+ }
33
+ function superseded(pending, ...running) {
34
+ return !!pending && !running.includes(pending.to);
35
+ }
36
+ function rolledBackState(state, pending, reason, now) {
37
+ const { pending: _done, ...rest } = state;
38
+ return {
39
+ ...rest,
40
+ lastResult: {
41
+ kind: "rolled_back",
42
+ from: pending.from,
43
+ to: pending.to,
44
+ at: now.toISOString(),
45
+ reason: reason.replace(/\s+/g, " ").trim().slice(0, 400)
46
+ },
47
+ failed: { version: pending.to, attempts: MAX_STAGE_ATTEMPTS, at: now.toISOString() }
48
+ };
49
+ }
50
+ function guardBoot(dir, now = Date.now()) {
51
+ const link = join(dir, "current");
52
+ let root2;
53
+ try {
54
+ root2 = realpathSync(link);
55
+ } catch {
56
+ return null;
57
+ }
58
+ const statePath = join(dir, "update.json");
59
+ const state = readJson(statePath);
60
+ const version = readJson(join(root2, "package.json")).version ?? "";
61
+ const crash = readJson(join(dir, "last-error.json")).reason ?? null;
62
+ if (superseded(state.pending, version)) {
63
+ const { pending: _gone, ...rest } = state;
64
+ writeJson(statePath, rest);
65
+ return root2;
66
+ }
67
+ const verdict = bootVerdict(state.pending, version, crash);
68
+ if (verdict.kind === "rollback") {
69
+ const back = verdict.pending.fromRoot;
70
+ const fresh = `${link}.${process.pid}.${now}`;
71
+ symlinkSync(back, fresh);
72
+ renameSync(fresh, link);
73
+ writeJson(statePath, rolledBackState(state, verdict.pending, verdict.reason, new Date(now)));
74
+ process.stderr.write(
75
+ `Cabane ${verdict.pending.to} didn't come up (${verdict.reason}); going back to ${verdict.pending.from}.
76
+ `
77
+ );
78
+ return back;
79
+ }
80
+ if (verdict.pending) writeJson(statePath, { ...state, pending: verdict.pending });
81
+ return root2;
82
+ }
83
+ function readJson(path) {
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
86
+ return parsed && typeof parsed === "object" ? parsed : {};
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+ function writeJson(path, value) {
92
+ mkdirSync(dirname(path), { recursive: true });
93
+ const tmp = `${path}.${process.pid}.tmp`;
94
+ try {
95
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
96
+ `, { mode: 384 });
97
+ renameSync(tmp, path);
98
+ } catch (err) {
99
+ rmSync(tmp, { force: true });
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ // src/boot-guard.ts
105
+ var root = guardBoot(join2(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane"));
106
+ if (!root) {
107
+ process.stderr.write(
108
+ "Cabane isn't installed where the service expects it (~/.cabane/current). Run cabane service install again.\n"
109
+ );
110
+ process.exit(1);
111
+ }
112
+ await import(pathToFileURL(join2(root, "dist", "bin.js")).href);