@cabane/companion 0.6.174 → 0.6.176
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 +83 -319
- package/dist/cli.js +68 -56
- package/dist/runtime.js +39 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,248 +1,124 @@
|
|
|
1
1
|
# cabane
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Cabane Companion runs your Cabane agents on your own device. Install it, paste one setup line, and the agents you assign to this device answer in Cabane while the model, the shell and the files stay on your device. Only the reply crosses back.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
It drives a **harness** you already have signed in: [Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code), [Codex](https://developers.openai.com/codex/cli) or [OpenCode](https://opencode.ai). The Companion never installs a harness and never runs a sign-in; it finds the ones that are usable and reports them.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Step-by-step guides with screenshots are in Cabane: open the **Account menu** and choose **Docs**. This README is the reference for the device side.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## The model
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Three things, one relation each.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
- **A device** is a machine running the Companion. It belongs to your account and can be joined to any of your workspaces. Cabane shows it as online, offline or deactivated.
|
|
14
|
+
- **A harness** is a coding tool on the device with a login behind it. The Companion detects each one and reports its state: connected, needs sign-in, limited, problem, off or gone. A device offers exactly the harnesses it has.
|
|
15
|
+
- **An agent** is assigned to one device and runs through one of that device's harnesses, with a model from it. Assign in as many workspaces as you like: one Companion serves them all.
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
**Cabane decides what an agent is; the device decides what it can touch.** Everything about the agent lives on its page in Cabane and is delivered with each turn, so an edit there lands on the next reply with no restart: its charter, its harness and model, its MCP servers, its host access and its starting folder. The device holds only what can't travel from a server: the device token, your secrets, the sandbox, and local overrides for this device.
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
How a turn runs, top to bottom:
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
| Layer | Holds |
|
|
22
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| **Cabane** | The agent's reach: **host access** off (workspace only, the default) or on (shell and files on the device, starting in a folder). Only the agent's owner changes it. A conversation can't widen it. |
|
|
24
|
+
| **Companion** | The listener on the device. Holds the pairing and the harness logins. Sets the device's ceiling: the sandbox and what it allows, which no agent setting can override. |
|
|
25
|
+
| **Runner** | A child process per turn with a one-turn credential, when runners are on. Under Docker, a container per conversation and agent. |
|
|
26
|
+
| **Sandbox** | An OS wrapper around the whole harness process (bubblewrap on Linux, Seatbelt on macOS), or the container itself. Everything the harness spawns is inside it: subprocesses, stdio MCP servers, the lot. |
|
|
27
|
+
| **Harness** | Claude Code, Codex or OpenCode with its normal tools. Nothing is removed by name; the sandbox is enforced by the operating system around the process, never by a list of tool names. Nothing asks for permission mid-task. |
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
> **An agent with host access acts on the device as the user account the Companion runs under, with no approval prompts.** Turn it on for an agent whose work needs the device, on a device where that reach is acceptable, and put the sandbox under it. An agent that reads untrusted input (public web, inbound mail, outside members) stays sandboxed, whatever the device.
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
curl -fsSL https://claude.ai/install.sh | bash
|
|
25
|
-
~/.local/bin/claude auth login
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
The full path works in the same window even when Claude's installer leaves `~/.local/bin` off your PATH. With an Anthropic API key instead, `export ANTHROPIC_API_KEY=…` in the window that runs `cabane start`: the Companion reports it as **API key configured**. A key lives in that window's environment, so one exported later in another window isn't seen by a running Companion; `cabane connect` says so. A plan sign-in is stored on disk and never needs a restart.
|
|
29
|
-
|
|
30
|
-
**Codex** — `codex login`, or `export CODEX_API_KEY=…` in the window that runs `cabane start` (Cabane never sees the key).
|
|
31
|
-
|
|
32
|
-
**opencode** — authenticate a provider through its own flow and leave its server running (`opencode serve --port 4096`). A reachable server counts as usable; for one anywhere else, connect it with `cabane connect opencode --url <url>`.
|
|
33
|
-
|
|
34
|
-
You also need **a Cabane account.** A device belongs to your account, not to one workspace: once paired, it is available in every workspace the account is in.
|
|
31
|
+
**No account password or full-account token ever touches the device.** The setup code delivers a device token (`cabdev_…`) good only for pulling this device's assignments and reporting liveness. Each assigned agent gets its own workspace-bound credential, delivered once. Renaming, deactivating, removing a device and rotating its token happen in Cabane.
|
|
35
32
|
|
|
36
33
|
## Install
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
**The install script** brings its own Node (22, checksum-checked, into `~/.cabane`), needs no sudo, and runs `cabane start` in the same window:
|
|
35
|
+
In a workspace, open **Settings → Devices** and choose **Add a 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:
|
|
41
36
|
|
|
42
37
|
```sh
|
|
43
|
-
curl -fsSL https://cabane.ai/install.sh | bash
|
|
38
|
+
curl -fsSL https://cabane.ai/install.sh | bash -s -- CODE
|
|
44
39
|
```
|
|
45
40
|
|
|
46
|
-
|
|
41
|
+
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):
|
|
47
42
|
|
|
48
43
|
```sh
|
|
49
|
-
npm i -g @cabane/companion
|
|
50
|
-
cabane start
|
|
44
|
+
npm i -g @cabane/companion && cabane setup CODE
|
|
51
45
|
```
|
|
52
46
|
|
|
53
|
-
Either
|
|
54
|
-
|
|
55
|
-
To **update**, run `cabane update`, whichever way you installed: it installs the version Cabane names. Under the service (below) the Companion restarts into it once its turns finish; without the service, the next `cabane start` runs it, and `cabane stop` before that ends any turn in progress. Re-running the install script changes nothing while a Companion is running.
|
|
47
|
+
Either way `cabane setup CODE` pairs the device to your account, joins it to that workspace, detects your harnesses, and starts the Companion: as a service on macOS and Linux, in the background elsewhere. No confirm, no name screen, no browser on the device. The same line on a device that is already paired joins it to the workspace and mints nothing.
|
|
56
48
|
|
|
57
|
-
## First run
|
|
58
|
-
|
|
59
|
-
```sh
|
|
60
|
-
cabane start
|
|
61
49
|
```
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
✓ Claude Code signed in (2.1.289) — connected
|
|
51
|
+
Codex not signed in — run codex login
|
|
52
|
+
OpenCode not running
|
|
53
|
+
All set here. Back to the browser.
|
|
65
54
|
```
|
|
66
|
-
Claude Code is signed in on this computer (2.1.289).
|
|
67
|
-
Use it in Cabane? [Y/n]
|
|
68
|
-
✓ Claude Code — available once this computer is paired.
|
|
69
55
|
|
|
70
|
-
|
|
56
|
+
With nothing signed in, the device still pairs and the Companion still runs; the rows say which harness needs sign-in, and the device shows **Needs attention** in Cabane until one is usable. Sign in, and the next check finds it: no restart.
|
|
71
57
|
|
|
72
|
-
|
|
58
|
+
A fresh device runs nothing yet. In a workspace, open an agent's page and assign it to the device; the Companion picks the assignment up on its next poll.
|
|
73
59
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
https://app.cabane.ai/pair/enter-code?code=4HSP-FPWB
|
|
77
|
-
|
|
78
|
-
Waiting for you to confirm in Cabane… (the code is good for 15 minutes)
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
With nothing signed in, it pairs nothing, starts nothing, prints the sign-in lines above and exits non-zero.
|
|
82
|
-
|
|
83
|
-
In Cabane, open the **Account menu** at the bottom left and choose **Settings**, then **Devices**, then **Pair a device** and **Enter pairing code**. Type the eight characters and choose **Pair device**. Or open the link from the terminal, which fills the code in. Any browser signed in to the account will do, so a headless device needs no browser of its own. The device token is delivered straight to the waiting CLI. Nothing is copied or pasted.
|
|
84
|
-
|
|
85
|
-
The terminal finishes by itself:
|
|
86
|
-
|
|
87
|
-
```
|
|
88
|
-
✓ Paired with Maya Finch's Cabane account as maya-mbp.
|
|
89
|
-
✓ Claude Code connected.
|
|
90
|
-
✓ Cabane is running in the background. You can close this window.
|
|
91
|
-
Stop: cabane stop Logs: cabane logs
|
|
92
|
-
|
|
93
|
-
! It won't restart on its own after a reboot — run cabane start again.
|
|
94
|
-
|
|
95
|
-
All set on this side. Finish up in Cabane.
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Back in Cabane, **Name this device** asks for a **Device name**, which starts as the device's hostname and can change later. Choose **Save and continue**. **What Runs on This Device** lists each coding tool on the device as a connector; choose **Continue**. The device is then listed under **Settings → Devices** as **Online**.
|
|
99
|
-
|
|
100
|
-
A freshly paired device runs nothing yet: pairing doesn't put any agent on it. In a workspace, open **Settings**, then **Agents**, choose an agent, and under **Assignment** choose **Assign a device**, pick this device, and choose **Assign device**. Then, under **Execution**, choose its **Connector** and model and choose **Save execution settings**. Until those are saved, the agent's row in the list still says "Not configured". Only the device's own owner can assign an agent to it. The Create an agent guide in Cabane's docs walks these screens. Assign agents in as many workspaces as the account is in: one running Companion serves them all.
|
|
60
|
+
If `cabane` isn't found afterwards, open a new terminal, or run the `source` line setup printed.
|
|
101
61
|
|
|
102
62
|
## Commands
|
|
103
63
|
|
|
104
|
-
| Command
|
|
105
|
-
|
|
|
106
|
-
| `
|
|
107
|
-
| `start [--foreground]
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `logout [-y] [--purge]` | Remove the device token and cached agent credentials. `--purge` removes the whole local config. Neither removes the device from Cabane. |
|
|
116
|
-
| `--version` / `-V` | Print the Companion version. |
|
|
117
|
-
|
|
118
|
-
Everything else about a device is done in Cabane, not the CLI: **rename**, **deactivate**, **rotate token** and **remove** are on the device's page under the account's **Settings → Devices**, and agents are assigned from each agent's page in Cabane, under **Where it runs**.
|
|
119
|
-
|
|
120
|
-
## Managing the device
|
|
121
|
-
|
|
122
|
-
Each of these starts in Cabane, on the device's page, and three of them need a command on the device afterwards.
|
|
123
|
-
|
|
124
|
-
- **After a reboot, or whenever the Companion has stopped**, run `cabane start`. Running it while the Companion is already running changes nothing. In Cabane, a device whose Companion has stopped turns to **Offline** about two minutes later, and back to **Online** once it is up.
|
|
125
|
-
- **Deactivate** turns the device off without removing it. The Companion on the device exits. To bring it back, choose **Reactivate** in Cabane, then run `cabane start` on the device. The Companion's log says to log out and pair again; that isn't needed.
|
|
126
|
-
- **Rotate token** shows a new device token (`cabdev_…`) once, and the old one stops working at once. On the device, run `cabane stop`, replace the value of `deviceToken` in `~/.cabane/config.json` with the new token, then run `cabane start`. Stop first: a Companion that is still running keeps the old token, and `start` does nothing while one is running.
|
|
127
|
-
- **Remove device** can't be undone. The Companion on the device exits. To pair the same device again, run `cabane logout`, then `cabane start`.
|
|
64
|
+
| Command | What it does |
|
|
65
|
+
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| `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. |
|
|
67
|
+
| `start [--foreground]` | Run in the background. Never asks. Unpaired, it names `setup`. `--foreground` stays attached, for a supervisor of your own. |
|
|
68
|
+
| `stop` | Stop a running Companion cleanly. Ends any turn in progress. |
|
|
69
|
+
| `status` | Version and install, the paired device by its Cabane name, whether it's running, each harness's state, the sandbox posture, declared secret names, and local overrides. |
|
|
70
|
+
| `update [--auto on\|off]` | Install the version Cabane names now; with `--auto`, set whether the Companion keeps itself up to date. |
|
|
71
|
+
| `service install\|uninstall\|status` | Run as a systemd user service or a LaunchAgent that starts at boot and restarts if it stops; remove it; show its state. |
|
|
72
|
+
| `logs [-n N] [-f]` | Recent log lines, or follow. |
|
|
73
|
+
| `transcript [file] [--last] [-f]` | Read back a full agent turn, tool calls and results included. |
|
|
74
|
+
| `logout [--purge]` | Remove the device token and cached agent credentials. Doesn't remove the device from Cabane. |
|
|
128
75
|
|
|
129
|
-
|
|
76
|
+
Anything else about a device is done in Cabane, on the device's page.
|
|
130
77
|
|
|
131
|
-
##
|
|
78
|
+
## Keeping it running
|
|
132
79
|
|
|
133
|
-
|
|
80
|
+
Under the service the Companion restarts if it stops, comes back after a reboot, and follows the version Cabane names: it installs the new version under `~/.cabane/versions`, checks it starts, and swaps between turns. A version that can't start is rolled back by the boot guard. `cabane update --auto off` stops updates; without the service, `cabane update` installs and the next `cabane start` runs it.
|
|
134
81
|
|
|
135
|
-
|
|
82
|
+
Other supervisors: run `cabane start --foreground` with `CABANE_COMPANION_SUPERVISED=1` set, using the absolute paths `~/.cabane/node/bin/node` and `~/.cabane/current/dist/bin.js`, which don't move on an update.
|
|
136
83
|
|
|
137
|
-
|
|
84
|
+
A device whose Companion has stopped shows **Offline** in Cabane about two minutes later. Deactivating a device in Cabane stops its Companion; reactivate there, then `cabane start` on the device. After **Rotate token**: `cabane stop`, replace `deviceToken` in `~/.cabane/config.json`, `cabane start`.
|
|
138
85
|
|
|
139
|
-
|
|
86
|
+
## What's on this device
|
|
140
87
|
|
|
141
|
-
|
|
88
|
+
All of it in `~/.cabane/`, mode 600, no database. Cabane is the source of truth for which agents run and how.
|
|
142
89
|
|
|
143
|
-
<a id="configuring-an-agent-whats-in-cabane-vs-on-this-machine"></a>
|
|
144
|
-
|
|
145
|
-
## Configuring an agent: what's in Cabane vs. on this device
|
|
146
|
-
|
|
147
|
-
There's a deliberate split. **What the agent is** — its host access, working directory, MCP servers, connector, model and charter — is configured in **Cabane** (each agent's page) and delivered to the Companion per turn, so an edit in Cabane lands on the agent's next reply with **no restart**. One exception: a conversation given Custom execution settings keeps its own connector, model and tuning when the agent's defaults change. The Companion holds only the bits that can't travel from a server: your **secrets**, and device-local overrides such as a prepare-hook.
|
|
148
|
-
|
|
149
|
-
An agent can reach MCP servers two ways, and they don't overlap. **The agent's own MCP servers** are part of what the agent is: set on its page, they go with it into every room. **Connections** belong to a person instead: you add one under a workspace's **Settings → Connections** (an endpoint and a pasted key, stored encrypted in Cabane), then add it to a room from the room's menu and pick which agents there may use it. On each turn in that room, Cabane attaches the room's connections for that agent and sends the key with the turn, into the harness's environment and never onto its command line. Nothing is configured on this device. Replacing, disconnecting or deleting a key takes effect on the agent's next turn, and nothing is revoked at the service itself.
|
|
150
|
-
|
|
151
|
-
### Host access (set in Cabane)
|
|
152
|
-
|
|
153
|
-
**Host access** is a setting under **What it can reach**, on the agent's page in Cabane. Only the agent's owner can change it.
|
|
154
|
-
|
|
155
|
-
| Host access | What the agent can do |
|
|
156
|
-
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
157
|
-
| No | Work in the workspace: read and write the workspace's files and take part in conversations. No shell or file tools on the device. The default. |
|
|
158
|
-
| Yes | All of that, plus run shell commands and read and write files on the device, starting in its working directory. ⚠️ |
|
|
159
|
-
|
|
160
|
-
> ⚠️ **An agent with host access acts on the device as the user account the Companion runs under, with no approval prompts.** With Claude Code as the connector, it runs every command and writes every file it is asked to, and it isn't restricted to its working directory. Turn it on for an agent whose work needs the device, on a device where that reach is acceptable. The [sandbox](#sandbox-set-on-this-device) below limits what those tools can touch.
|
|
161
|
-
|
|
162
|
-
The agent's MCP servers don't depend on host access: a stdio server listed there runs on the device and gives the agent its tools either way.
|
|
163
|
-
|
|
164
|
-
### Working directory + prepare-hook
|
|
165
|
-
|
|
166
|
-
An agent with host access starts in the folder set under **Starts in**, in Cabane's **What it can reach** dialog alongside host access. Left empty, or naming a folder that isn't on the device, the agent starts in the folder the Companion was started from; a missing folder is reported only in the Companion's log.
|
|
167
|
-
|
|
168
|
-
The device can override it. A `cwd` in `~/.cabane/config.json` wins over the directory set in Cabane, and a prepare-hook wins over both. Add an `agents` map keyed by the agent's id, its `@username`, or `workspace-slug/username` (all three show up in `cabane status`, copy-pasteable):
|
|
169
|
-
|
|
170
|
-
```jsonc
|
|
171
|
-
{
|
|
172
|
-
// …device identity, written when the device is paired. The one field to edit by
|
|
173
|
-
// hand is `deviceToken`, after rotating the token in Cabane…
|
|
174
|
-
|
|
175
|
-
// Per-agent device-local overrides. Only `cwd`, `prepareHook`, `claudeCode` and
|
|
176
|
-
// `sandbox` live here — everything else about the agent (host access, MCP
|
|
177
|
-
// servers, model) is set in Cabane.
|
|
178
|
-
"agents": {
|
|
179
|
-
"my-coding-agent": {
|
|
180
|
-
"cwd": "/Users/you/code/project", // overrides the working directory set in Cabane
|
|
181
|
-
"prepareHook": { "command": "/Users/you/bin/prepare-turn" }, // optional: resolves the cwd before each turn
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
}
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
A malformed `agents` block fails `cabane start` immediately with a message pointing at the exact field — it won't silently fall back.
|
|
188
|
-
|
|
189
|
-
**A hook that is the same for every agent goes at the top level instead.** If your hook works out the directory itself — from the conversation and agent ids the Companion hands it — then listing every agent by name is a roster you have to remember to update, and an agent you forget gets no hook at all and starts its turn wherever the Companion happens to be. So `prepareHook` can sit beside `agents` rather than inside it:
|
|
190
|
-
|
|
191
|
-
```jsonc
|
|
192
|
-
{
|
|
193
|
-
"prepareHook": { "command": "/Users/you/bin/prepare-turn" }, // every agent on this device
|
|
194
|
-
"agents": {
|
|
195
|
-
"special-case": { "prepareHook": { "command": "/Users/you/bin/other-hook" } }, // wins for this one
|
|
196
|
-
},
|
|
197
|
-
}
|
|
198
90
|
```
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
```jsonc
|
|
207
|
-
{
|
|
208
|
-
"agents": {
|
|
209
|
-
"my-coding-agent": {
|
|
210
|
-
"cwd": "/Users/you/code/project",
|
|
211
|
-
"claudeCode": { "autoMemory": true }, // opt back into local Claude Code auto-memory
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
}
|
|
91
|
+
~/.cabane/
|
|
92
|
+
├── config.json # pairing, local overrides, sandbox policy
|
|
93
|
+
├── credentials.json # per-agent workspace-bound credentials, delivered once
|
|
94
|
+
├── secrets.json # { NAME: "value" } for ${NAME} placeholders
|
|
95
|
+
├── transcripts/ # one JSONL per turn, newest ~200 kept, local only
|
|
96
|
+
├── outbox/ # a reply survives a transient API outage
|
|
97
|
+
└── companion.log
|
|
215
98
|
```
|
|
216
99
|
|
|
217
|
-
|
|
100
|
+
### Secrets
|
|
218
101
|
|
|
219
|
-
|
|
102
|
+
An MCP server configured in Cabane names its credential as `${NAME}`; the value never leaves the device. The Companion resolves it from `secrets.json` at dispatch, never from your shell environment, and a name that isn't declared fails the turn loudly. It reports the declared names (never values) on its heartbeat, so Cabane can warn before a turn runs.
|
|
220
103
|
|
|
221
|
-
|
|
104
|
+
### Local overrides
|
|
222
105
|
|
|
223
|
-
|
|
224
|
-
// ~/.cabane/secrets.json
|
|
225
|
-
{
|
|
226
|
-
"GITHUB_TOKEN": "ghp_…",
|
|
227
|
-
"LINEAR_API_KEY": "lin_api_…",
|
|
228
|
-
}
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
A `${VAR}` the store doesn't declare fails the turn loudly — it is **never** read from your shell environment, so a config can't smuggle out an ambient credential. The Companion reports the declared **names** (never values) to Cabane on its heartbeat, so the settings UI can warn "this agent needs `${GITHUB_TOKEN}`, this device doesn't expose it" before a turn ever runs.
|
|
232
|
-
|
|
233
|
-
<a id="sandbox-set-on-this-machine"></a>
|
|
106
|
+
`config.json` can carry, per agent (keyed by id, `@username` or `workspace-slug/username`) or for the whole device:
|
|
234
107
|
|
|
235
|
-
|
|
108
|
+
- `cwd`: wins over the agent's **Starts in** folder set in Cabane.
|
|
109
|
+
- `prepareHook`: a command that resolves the working directory before each turn; wins over both. A device-level hook applies to every agent that doesn't name its own.
|
|
110
|
+
- `harnesses`: an allowlist such as `["claude-code"]`; absent means all three.
|
|
111
|
+
- `claudeCode.autoMemory`: Claude Code's local memory directory is off by default on every Companion, because notes belong in the workspace where others can read them. `true` hands it back.
|
|
236
112
|
|
|
237
|
-
|
|
113
|
+
A malformed block fails `cabane start` with the field named.
|
|
238
114
|
|
|
239
|
-
|
|
115
|
+
### Sandbox
|
|
240
116
|
|
|
241
117
|
```jsonc
|
|
242
118
|
{
|
|
243
119
|
"runners": {
|
|
244
120
|
"enabled": true,
|
|
245
|
-
"sandbox": "os",
|
|
121
|
+
"sandbox": "os", // or "provider": "docker", where the container is the sandbox
|
|
246
122
|
"policy": {
|
|
247
123
|
"hosts": { "allow": ["gitlab.example.com"], "deny": [], "defaults": true },
|
|
248
124
|
"filesystem": { "mode": "blocklist", "denyRead": [], "allowRead": [], "allowWrite": [] },
|
|
@@ -255,142 +131,30 @@ What the sandbox allows is `runners.policy` in `~/.cabane/config.json`. Every ke
|
|
|
255
131
|
}
|
|
256
132
|
```
|
|
257
133
|
|
|
258
|
-
|
|
259
|
-
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
260
|
-
| **Hosts**, always reachable | Cabane, and the model provider of the turn's harness (Anthropic for Claude Code, OpenAI for Codex). `allow` and `defaults` never remove these; a `deny` entry naming one does, and blocks the turn from reaching it. |
|
|
261
|
-
| **Hosts**, `defaults` | `github.com`, `*.github.com`, `*.githubusercontent.com`, `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`, `static.crates.io`, `index.crates.io`. `allow` adds to them, `deny` removes a host (and wins), `defaults: false` drops the list. |
|
|
262
|
-
| **Reads**, `blocklist` mode (the default) | Everything is readable except every companion's store (`~/.cabane`, and any `.cabane` under your home or beside this companion's home; see below), the `.claude` and `.codex` of the other companion homes those stores sit in, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.git-credentials`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`, `~/.docker/config.json`, `~/.kube`, `~/.config/gcloud`, `~/.azure`, `~/.config/op`, `~/.password-store`, and your `denyRead` paths. `allowRead` reopens a path inside a denied one. |
|
|
263
|
-
| **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`. |
|
|
264
|
-
|
|
265
|
-
**One agent can be excepted.** The sandbox is set per device, and one agent's row in `agents` can say otherwise — for example an ops agent whose job is the device itself, on a device whose other agents stay sandboxed:
|
|
266
|
-
|
|
267
|
-
```jsonc
|
|
268
|
-
{
|
|
269
|
-
"runners": {
|
|
270
|
-
"enabled": true,
|
|
271
|
-
"sandbox": "os",
|
|
272
|
-
"policy": {
|
|
273
|
-
/* unchanged */
|
|
274
|
-
},
|
|
275
|
-
},
|
|
276
|
-
"agents": {
|
|
277
|
-
"014d18a7-6395-48e3-a2f2-313d5baa2a5e": { "sandbox": "none" }, // the ops agent, by its id: it runs with the device; everyone else stays sandboxed
|
|
278
|
-
},
|
|
279
|
-
}
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
It works the other way too: `"agents": { "<agent id>": { "sandbox": "os" } }` sandboxes one agent on a device that otherwise isn't. **A sandbox row is keyed by the agent's id**, the last part of the agent's settings page URL in Cabane (`/<workspace>/settings/agents/<agent id>`). A username, or `workspace-slug/username`, can match more than one agent: slugs are unique only per workspace owner, so two workspaces this device serves can share one. A `sandbox` row keyed any other way is refused at start, with the field named. `cwd` and `prepareHook` rows still take all three keys. The agent's row wins, then `runners.sandbox`, then no sandbox. The policy stays the device's. Under the `docker` provider the container is the sandbox, so a row's `sandbox` is ignored, and the log says so at start. The exception is set only here: Cabane shows it on the device page and can't change it.
|
|
283
|
-
|
|
284
|
-
Paths may start with `~/` and may be globs. An invalid `policy` doesn't stop the Companion from starting. Instead, every sandboxed turn is refused and the conversation names the field that's wrong, for example `runners.policy.filesystem.mode`.
|
|
285
|
-
|
|
286
|
-
**Other companions on the same device.** A second companion (another account, a test device) keeps its own secrets in its own `.cabane`, so a turn can't read any companion's store, not only this device's. Every turn denies this companion's store and `~/.cabane` (both, when the Companion runs from a home set with `CABANE_COMPANION_HOME`), plus any `.cabane` up to two levels under your home or beside this companion's home, where companion homes conventionally sit. Stores anywhere deeper are found by a search of your home with `find`, along with the `.claude` and `.codex` beside each one, which hold that companion's harness logins. That search takes seconds over a large home, so the Companion runs it in the background: when it starts, and again whenever a turn starts more than 5 minutes after the last search finished. Only the first turn after a start waits for it. **A store created deeper than two levels is denied within about 5 minutes plus one search, not instantly.** If a search can't finish (`find` is missing, runs past 60 seconds, or hits a directory it can't list), the stores it did find are still denied, and the log says so. The search runs in strict mode too, because strict mode reopens the working directory, and a store inside it must stay hidden.
|
|
287
|
-
|
|
288
|
-
**`.env` files are readable.** Beyond the credential locations above, nothing guesses which of your files hold secrets, so `.env` files, in the working directory or any other project, are readable unless you deny them. To hide them, list the roots that hold them, for example `"denyRead": ["~/work/**/.env", "~/work/**/.env.*"]`. Each glob is expanded when the turn starts, which costs a walk of that tree on every turn and misses files created during the turn, so keep the roots narrow. When other projects' secrets really matter, strict mode is the better answer, because it hides them all without a walk.
|
|
289
|
-
|
|
290
|
-
**Strict mode.** `"filesystem": { "mode": "strict" }` hides your whole home directory except the working directory, the harness state (`~/.claude`, `~/.codex`), what the Companion and its harnesses run from, and the paths you list in `allowRead`. Toolchains live all over a home directory (`~/.nvm`, `~/.local/share/pnpm`, `~/.cargo`, `~/.pyenv`, mise or asdf shims), so list yours, or builds will fail with "No such file or directory" on a path you didn't expect. On Linux a hidden path looks missing rather than denied, so the Companion log prints the readable list at the start of each strict turn. Compare it against the path the build couldn't find.
|
|
134
|
+
Every key under `policy` is optional. The defaults:
|
|
291
135
|
|
|
292
|
-
**
|
|
136
|
+
- **Network**: Cabane and the turn's model provider always; GitHub, npm, PyPI and crates.io by default; `allow` adds hosts, `deny` removes them and wins. Nothing else is reachable.
|
|
137
|
+
- **Reads**: everything except known credential locations (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.netrc`, `~/.npmrc`, `~/.kube`, cloud CLI configs, every Companion's own `~/.cabane` and the harness logins beside it) plus your `denyRead` globs. `.env` files are readable unless you deny them. `"mode": "strict"` hides the whole home except the working directory, the harness state and your `allowRead` list; name your toolchains there.
|
|
138
|
+
- **Writes**: the working directory, the temp dir, the harness's own state, plus `allowWrite`.
|
|
139
|
+
- **Credentials**: an entry sets the variable to a placeholder inside the sandbox and the proxy swaps the real value in only on requests to the hosts named. The harness never holds the token. A `git` entry does the same for HTTPS remotes (`~/.ssh` is unreadable, so use HTTPS).
|
|
293
140
|
|
|
294
|
-
|
|
141
|
+
One agent can be excepted, keyed by its id: `"agents": { "<agent id>": { "sandbox": "none" } }`, for an ops agent whose job is the device itself. It works the other way too. The agent's row wins, then `runners.sandbox`. Cabane shows the exception on the device page and can't change it.
|
|
295
142
|
|
|
296
|
-
|
|
297
|
-
2. Put it in `~/.cabane/secrets.json` as `"GITHUB_TOKEN": "github_pat_…"`.
|
|
298
|
-
3. Add both credential entries from the example above: `env.GH_TOKEN` with `"hosts": ["api.github.com"]` for `gh`, and a `git` entry for `github.com`.
|
|
299
|
-
4. Use HTTPS remotes, because `~/.ssh` is unreadable in the sandbox. Rewrite existing SSH remotes once on the device: `git config --global url."https://github.com/".insteadOf git@github.com:`.
|
|
143
|
+
A policy the sandbox can't enforce, or a sandbox that fails to start for a turn holding credentials, refuses the turn rather than running it unsandboxed. On Linux the OS sandbox needs `bwrap`, `socat` and `rg` on the Companion's PATH. `cabane status` prints the effective posture.
|
|
300
144
|
|
|
301
|
-
|
|
145
|
+
## Debugging a turn
|
|
302
146
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
## Harnesses
|
|
306
|
-
|
|
307
|
-
A **harness** is the local tool that runs a model — **[Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code)**, **[Codex](https://developers.openai.com/codex/cli)**, or **[opencode](https://opencode.ai)** — together with the credential behind it. A harness connected to Cabane on this device is a **Connector**, and one device runs as many Connectors as it has harnesses. None is privileged: a device advertises exactly the harnesses it actually has.
|
|
308
|
-
|
|
309
|
-
Which harness a turn uses is the agent's **Connector**, chosen in Cabane under **How it thinks** on the agent's page, together with a model from that connector. The list holds the connectors on the agent's device, so set the harness up first, then choose it for the agent.
|
|
310
|
-
|
|
311
|
-
What "exposed" means differs per harness, because each is discoverable in a different way. Bring your own install and your own login in all three cases — the Companion never installs a binary and never drives a login:
|
|
312
|
-
|
|
313
|
-
| Harness | How the device exposes it |
|
|
314
|
-
| --------------- | -------------------------------------------------------------------------------------------------- |
|
|
315
|
-
| **Claude Code** | A `claudeCode` block in `~/.cabane/config.json`, **and** the Agent SDK's bundled binary installed. |
|
|
316
|
-
| **Codex** | A `codex` block in `~/.cabane/config.json`, **and** the Codex SDK's vendored binary installed. |
|
|
317
|
-
| **opencode** | An `opencode.serverUrl` in `~/.cabane/config.json` pointing at a reachable `opencode serve`. |
|
|
318
|
-
|
|
319
|
-
Claude Code and Codex each run a native binary their SDK ships, installed alongside the Companion as an optional npm dependency. Those are what a turn launches — not the `claude` / `codex` CLIs on your PATH, which are for setup and login. If one didn't install, the Companion says so on startup and on the device's page in Cabane, with the commands that repair it: `npm i -g @cabane/companion --include=optional` for an npm install, `cabane stop` then the install script for a script install, and `cabane stop` then `cabane update` for a copy Cabane installed itself when it updated or became a service. Stopping ends any turn in progress.
|
|
320
|
-
|
|
321
|
-
**You usually don't hand-edit the config.** Interactive `cabane start` offers each signed-in agent, and `cabane connect codex` (or `claude-code` / `opencode`) can connect one later. Editing `~/.cabane/config.json` yourself is the fallback and what a headless device wants:
|
|
322
|
-
|
|
323
|
-
```jsonc
|
|
324
|
-
{
|
|
325
|
-
// …device identity, written when the device is paired…
|
|
326
|
-
"claudeCode": { "enabled": true },
|
|
327
|
-
"codex": { "enabled": true },
|
|
328
|
-
"opencode": { "serverUrl": "http://127.0.0.1:4096" },
|
|
329
|
-
}
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
Every block is optional and independent — set only the harnesses you have. Claude Code and Codex each take a flag and no URL, because the SDK bundled with the Companion spawns its own native binary per turn; opencode is a long-lived server addressed by URL, so run one `opencode serve` per Companion process. A hand-edit needs a restart (`cabane stop && cabane start`); the `connect` command updates a running Companion over its control socket.
|
|
333
|
-
|
|
334
|
-
Everything else about a Companion-run agent is the same whichever harness runs it — same assignment, same working directory, same secrets. A harness is a way to execute a turn, not a different way to run the Companion.
|
|
335
|
-
|
|
336
|
-
## Files on disk
|
|
337
|
-
|
|
338
|
-
```
|
|
339
|
-
~/.cabane/
|
|
340
|
-
├── config.json # device identity: baseUrl + device token (cabdev_…) + device id/label,
|
|
341
|
-
│ # plus optional per-agent `agents` overrides (cwd / prepareHook), the
|
|
342
|
-
│ # optional harness blocks (`claudeCode` / `codex` enabled,
|
|
343
|
-
│ # `opencode` serverUrl), and
|
|
344
|
-
│ # legacy local prefs. mode 600.
|
|
345
|
-
│ # No account password, no full-account token.
|
|
346
|
-
├── credentials.json # agentId → per-agent workspace-bound token, cached on first assignment
|
|
347
|
-
│ # pull (delivered once, then never re-sent). mode 600.
|
|
348
|
-
├── secrets.json # operator-declared { NAME: "value" } store for ${PLACEHOLDER} resolution. mode 600.
|
|
349
|
-
├── cursors/<workspace_id> # last-seen SSE event id per workspace
|
|
350
|
-
├── outbox/<workspace_id>/ # durable per-agent commit queue — a reply survives a transient API outage
|
|
351
|
-
├── runtime.json # written while `start` runs: control socket + pid (swept on exit)
|
|
352
|
-
├── transcripts/ # one JSONL file per dispatch — the full agent turn (see below)
|
|
353
|
-
└── companion.log # readable event log (JSON opt-in)
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
No SQLite, no embedded DB. Cabane is the source of truth for which agents to run and how they're configured.
|
|
357
|
-
|
|
358
|
-
## Logs
|
|
359
|
-
|
|
360
|
-
`cabane logs` shows the last 50 lines. Use `-n 20` for twenty lines or
|
|
361
|
-
`-f` to follow new lines until Ctrl-C. The initial read is capped at 1 MiB.
|
|
362
|
-
The file is `~/.cabane/companion.log` and is human-readable by default: local
|
|
363
|
-
`HH:MM:SS`, a level word for warnings and errors, then a sentence. Turns include
|
|
364
|
-
the agent's name and an eight-character conversation id. Failed turns include
|
|
365
|
-
a reason, the next step and the transcript path.
|
|
366
|
-
|
|
367
|
-
- `info` (default): startup, connection, assigned agents, and turn outcomes.
|
|
368
|
-
- `warn`: degraded operation, such as reconnecting or updates waiting to arrive.
|
|
369
|
-
- `debug`: internal diagnostics for investigating a problem. Errors are always shown.
|
|
370
|
-
|
|
371
|
-
Choose with `cabane start --log-level debug` or `"logLevel": "debug"`
|
|
372
|
-
in `~/.cabane/config.json`. Config changes apply on reload; a start flag wins.
|
|
373
|
-
Use `--log-format json` or `"logFormat": "json"` for machine-readable JSON with
|
|
374
|
-
full ids. The default is `human`; the terminal always stays human-readable.
|
|
375
|
-
Log files append across starts. Transcripts remain JSONL in either mode.
|
|
376
|
-
|
|
377
|
-
## Debugging a turn: transcripts
|
|
378
|
-
|
|
379
|
-
Cabane shows the agent's final reply, but not _how_ it got there. When a turn misbehaves — an agent that "couldn't read the file", a tool that errored, MCP tools that didn't load, a missing secret — the full picture is on disk. Every dispatch — on whichever harness ran it — writes the complete turn stream (the system init with its tool list, every tool call **and its result**, the assistant text, the outcome) to a JSONL file under `~/.cabane/transcripts/`. On a failed turn, the Companion log also prints the exact path.
|
|
380
|
-
|
|
381
|
-
Read one back in a readable form:
|
|
147
|
+
Cabane shows the reply, not how the agent got there. Every turn writes its full stream to `~/.cabane/transcripts/`: the tool list, every tool call and its result, the text, the outcome. A failed turn's log line names its transcript.
|
|
382
148
|
|
|
383
149
|
```sh
|
|
384
|
-
cabane transcript --last
|
|
385
|
-
cabane transcript
|
|
386
|
-
cabane transcript
|
|
387
|
-
cabane
|
|
150
|
+
cabane transcript --last # render the most recent turn
|
|
151
|
+
cabane transcript # list recent turns
|
|
152
|
+
cabane transcript -f # live-watch turns as they land
|
|
153
|
+
cabane logs -f # follow the log
|
|
388
154
|
```
|
|
389
155
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
It renders the conversation step by step — `🔧 tool(input) → ok/ERROR: result` — so you can see exactly where a turn went wrong. The raw JSONL stays on disk for `jq`. Transcripts can contain whatever the agent read (file contents in tool results), so they're kept local-only in a `700` dir; the newest ~200 are retained.
|
|
156
|
+
`cabane start --log-level debug` or `"logLevel": "debug"` in `config.json` for more; `--log-format json` for machine-readable lines.
|
|
393
157
|
|
|
394
|
-
##
|
|
158
|
+
## Not yet
|
|
395
159
|
|
|
396
|
-
Windows has no service install and no self-update. There
|
|
160
|
+
Windows has no service install and no self-update. There is no per-tool permissioning for an agent with host access: the sandbox is the boundary, and it's all or nothing per agent.
|
package/dist/cli.js
CHANGED
|
@@ -12117,11 +12117,60 @@ init_config();
|
|
|
12117
12117
|
init_bundled_binaries();
|
|
12118
12118
|
init_cabane_command();
|
|
12119
12119
|
init_config();
|
|
12120
|
+
import { homedir as homedir6 } from "os";
|
|
12121
|
+
import { join as join7 } from "path";
|
|
12122
|
+
|
|
12123
|
+
// src/device-state.ts
|
|
12124
|
+
var HARNESS_STATE_LABEL = {
|
|
12125
|
+
checking: "checking",
|
|
12126
|
+
connected: "connected",
|
|
12127
|
+
needs_sign_in: "needs sign-in",
|
|
12128
|
+
limited: "limited",
|
|
12129
|
+
problem: "problem",
|
|
12130
|
+
off: "off",
|
|
12131
|
+
gone: "gone"
|
|
12132
|
+
};
|
|
12133
|
+
|
|
12134
|
+
// src/harness-state.ts
|
|
12135
|
+
init_config();
|
|
12136
|
+
function harnessEntry(runtime, state) {
|
|
12137
|
+
switch (state.state) {
|
|
12138
|
+
case "usable":
|
|
12139
|
+
return {
|
|
12140
|
+
runtime,
|
|
12141
|
+
state: "connected",
|
|
12142
|
+
reason: state.via,
|
|
12143
|
+
version: state.version,
|
|
12144
|
+
...state.url ? { url: state.url } : {}
|
|
12145
|
+
};
|
|
12146
|
+
case "signed_out":
|
|
12147
|
+
return { runtime, state: "needs_sign_in", reason: "signed_out", version: state.version };
|
|
12148
|
+
case "not_running":
|
|
12149
|
+
return { runtime, state: "problem", reason: "not_running", url: state.url };
|
|
12150
|
+
case "off":
|
|
12151
|
+
return { runtime, state: "off" };
|
|
12152
|
+
case "problem":
|
|
12153
|
+
return { runtime, state: "problem", reason: checkReason(state.check) };
|
|
12154
|
+
}
|
|
12155
|
+
}
|
|
12156
|
+
function harnessEntriesFromDetection(detection) {
|
|
12157
|
+
return HARNESS_RUNTIMES.map((runtime) => harnessEntry(runtime, detection[runtime]));
|
|
12158
|
+
}
|
|
12159
|
+
function checkReason(check) {
|
|
12160
|
+
switch (check.status) {
|
|
12161
|
+
case "bundle_missing":
|
|
12162
|
+
return check.bundled;
|
|
12163
|
+
case "check_failed":
|
|
12164
|
+
return `check_failed:${check.reason}`;
|
|
12165
|
+
default:
|
|
12166
|
+
return check.status;
|
|
12167
|
+
}
|
|
12168
|
+
}
|
|
12169
|
+
|
|
12170
|
+
// src/harness-copy.ts
|
|
12120
12171
|
init_install_kind();
|
|
12121
12172
|
init_install_record();
|
|
12122
12173
|
init_term();
|
|
12123
|
-
import { homedir as homedir6 } from "os";
|
|
12124
|
-
import { join as join7 } from "path";
|
|
12125
12174
|
var HARNESS_LABELS = {
|
|
12126
12175
|
"claude-code": "Claude Code",
|
|
12127
12176
|
codex: "Codex",
|
|
@@ -12176,23 +12225,23 @@ function agentRows(detection, opts = {}) {
|
|
|
12176
12225
|
});
|
|
12177
12226
|
}
|
|
12178
12227
|
function agentRowText(runtime, state, opts = {}) {
|
|
12179
|
-
const
|
|
12228
|
+
const word = HARNESS_STATE_LABEL[harnessEntry(runtime, state).state];
|
|
12180
12229
|
switch (state.state) {
|
|
12181
12230
|
case "usable": {
|
|
12182
12231
|
const version = state.version ? ` (${state.version})` : "";
|
|
12183
|
-
if (state.via === "api_key") return
|
|
12184
|
-
if (state.via === "container") return
|
|
12185
|
-
if (state.via === "serve") return
|
|
12186
|
-
return
|
|
12232
|
+
if (state.via === "api_key") return `${word} \u2014 API key configured${version}`;
|
|
12233
|
+
if (state.via === "container") return `${word} \u2014 turns run in Docker`;
|
|
12234
|
+
if (state.via === "serve") return `${word} \u2014 running at ${state.url ?? ""}`;
|
|
12235
|
+
return `${word} \u2014 signed in${version}`;
|
|
12187
12236
|
}
|
|
12188
12237
|
case "signed_out":
|
|
12189
|
-
return opts.signInHint && runtime !== "opencode" ?
|
|
12238
|
+
return opts.signInHint && runtime !== "opencode" ? `${word} \u2014 run ${loginCommand(runtime)}` : word;
|
|
12190
12239
|
case "not_running":
|
|
12191
|
-
return
|
|
12240
|
+
return `${word} \u2014 not running`;
|
|
12192
12241
|
case "off":
|
|
12193
|
-
return
|
|
12242
|
+
return `${word} \u2014 turned off in config`;
|
|
12194
12243
|
case "problem":
|
|
12195
|
-
return runtime === "opencode" ?
|
|
12244
|
+
return runtime === "opencode" ? `${word} \u2014 not running` : `${word} \u2014 ${unusableReason(runtime, state.check) ?? "could not be checked"}`;
|
|
12196
12245
|
}
|
|
12197
12246
|
}
|
|
12198
12247
|
var AGENT_CHOICES = [
|
|
@@ -12281,7 +12330,7 @@ function nothingUsableLines(detection) {
|
|
|
12281
12330
|
if (reason) lines2.push(reason);
|
|
12282
12331
|
}
|
|
12283
12332
|
}
|
|
12284
|
-
lines2.push(`Cabane can run while you sign in to a
|
|
12333
|
+
lines2.push(`Cabane can run while you sign in to a harness.`);
|
|
12285
12334
|
return lines2;
|
|
12286
12335
|
}
|
|
12287
12336
|
function indent(line) {
|
|
@@ -13460,7 +13509,7 @@ function turnFailureCopy(reason, runtime) {
|
|
|
13460
13509
|
if (reason?.startsWith("runtime_incomplete:"))
|
|
13461
13510
|
return `The agent installation is incomplete. ${repairAction()} Then restart the companion.`;
|
|
13462
13511
|
if (/^(runtime|codex|opencode)_unavailable:/.test(reason ?? ""))
|
|
13463
|
-
return "The
|
|
13512
|
+
return "The harness is off on this device. Enable it here, or choose another harness or device in Cabane.";
|
|
13464
13513
|
if (reason?.startsWith("model_unavailable:"))
|
|
13465
13514
|
return "The selected model is unavailable. Choose a different model in the agent settings in Cabane.";
|
|
13466
13515
|
if (reason?.startsWith("prepare_failed:"))
|
|
@@ -13839,7 +13888,6 @@ init_install_kind();
|
|
|
13839
13888
|
init_src();
|
|
13840
13889
|
var ConnectorHealthStore = class {
|
|
13841
13890
|
byRuntime = /* @__PURE__ */ new Map();
|
|
13842
|
-
sources = /* @__PURE__ */ new Map();
|
|
13843
13891
|
// Record a settled turn's outcome for `runtime`. A success heals to `ok`; a
|
|
13844
13892
|
// classified failure sets `limited`(+reset) / `auth_expired` with the reason as
|
|
13845
13893
|
// `last_error`; anything else (a user Stop, a timeout, an unclassified error)
|
|
@@ -13848,7 +13896,6 @@ var ConnectorHealthStore = class {
|
|
|
13848
13896
|
recordSettle(runtime, outcome) {
|
|
13849
13897
|
if (outcome.ok) {
|
|
13850
13898
|
this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
|
|
13851
|
-
this.sources.set(runtime, "turn");
|
|
13852
13899
|
return;
|
|
13853
13900
|
}
|
|
13854
13901
|
const failure = decodeFailureReason(outcome.errorReason);
|
|
@@ -13860,26 +13907,6 @@ var ConnectorHealthStore = class {
|
|
|
13860
13907
|
limitedUntil: quota.limitedUntil,
|
|
13861
13908
|
lastError: outcome.errorReason ?? null
|
|
13862
13909
|
});
|
|
13863
|
-
this.sources.set(runtime, "turn");
|
|
13864
|
-
}
|
|
13865
|
-
// Record what the companion's own sign-in check found for a connected
|
|
13866
|
-
// runtime at `start` or `connect`, so the heartbeat carries "sign-in needed"
|
|
13867
|
-
// before any turn fails. Signed out seeds `auth_expired` unless a turn has
|
|
13868
|
-
// already spoken; usable clears only a seed of our own.
|
|
13869
|
-
recordLocalCheck(runtime, usable) {
|
|
13870
|
-
const source = this.sources.get(runtime);
|
|
13871
|
-
if (!usable) {
|
|
13872
|
-
if (source === "turn" && this.byRuntime.get(runtime)?.quotaState !== "ok") return;
|
|
13873
|
-
this.byRuntime.set(runtime, {
|
|
13874
|
-
quotaState: "auth_expired",
|
|
13875
|
-
limitedUntil: null,
|
|
13876
|
-
lastError: encodeFailureReason({ kind: "auth_expired" })
|
|
13877
|
-
});
|
|
13878
|
-
this.sources.set(runtime, "local");
|
|
13879
|
-
return;
|
|
13880
|
-
}
|
|
13881
|
-
if (source !== "local") return;
|
|
13882
|
-
this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
|
|
13883
13910
|
}
|
|
13884
13911
|
// The store's current opinion for one runtime, or undefined when it has
|
|
13885
13912
|
// no observation. Read by the dispatcher at idle-timeout fire: an idle reap
|
|
@@ -16364,6 +16391,9 @@ var CompanionSupervisor = class {
|
|
|
16364
16391
|
// Omitted until the first observed failure or heal, so the server's
|
|
16365
16392
|
// status-less manifest synthesis still runs for a fresh companion.
|
|
16366
16393
|
...connectorReports.length > 0 ? { connectors: connectorReports } : {},
|
|
16394
|
+
// The complete harness inventory in the machine's words, once detection
|
|
16395
|
+
// has run; omitted before it, which the server reads as "nothing yet".
|
|
16396
|
+
...this.detection ? { harnesses: harnessEntriesFromDetection(this.detection) } : {},
|
|
16367
16397
|
...opencodeModels !== null ? { models: opencodeModels } : {},
|
|
16368
16398
|
...this.updater ? { update: this.updater.report() } : {}
|
|
16369
16399
|
});
|
|
@@ -17316,7 +17346,6 @@ var CompanionSupervisor = class {
|
|
|
17316
17346
|
...this.probeOpencode ? { probeOpencode: this.probeOpencode } : {}
|
|
17317
17347
|
});
|
|
17318
17348
|
this.detection = detection;
|
|
17319
|
-
this.seedSignInHealth(previous, detection);
|
|
17320
17349
|
this.logBundledTransitions();
|
|
17321
17350
|
this.harnessVersions = {
|
|
17322
17351
|
claudeCode: detectedVersion(detection["claude-code"]),
|
|
@@ -17374,23 +17403,6 @@ var CompanionSupervisor = class {
|
|
|
17374
17403
|
const bare = advertisedRuntimes(this.detection).filter((runtime) => !named.has(runtime)).map((runtime) => ({ runtime }));
|
|
17375
17404
|
return [...health, ...bare];
|
|
17376
17405
|
}
|
|
17377
|
-
// Seed connector health from what detection saw of a sign-in, so Cabane says
|
|
17378
|
-
// "sign-in needed" before a turn fails. Only a LAPSE seeds it — an agent that
|
|
17379
|
-
// was usable at an earlier look and is signed out now — because a device
|
|
17380
|
-
// that never signed in to Codex has nothing to report about Codex. Usable
|
|
17381
|
-
// again clears a seed of our own.
|
|
17382
|
-
seedSignInHealth(previous, detection) {
|
|
17383
|
-
for (const runtime of ["claude-code", "codex"]) {
|
|
17384
|
-
const now = detection[runtime];
|
|
17385
|
-
if (now.state === "usable") {
|
|
17386
|
-
if (now.via !== "container") this.connectorHealth.recordLocalCheck(runtime, true);
|
|
17387
|
-
continue;
|
|
17388
|
-
}
|
|
17389
|
-
if (now.state === "signed_out" && previous?.[runtime].state === "usable") {
|
|
17390
|
-
this.connectorHealth.recordLocalCheck(runtime, false);
|
|
17391
|
-
}
|
|
17392
|
-
}
|
|
17393
|
-
}
|
|
17394
17406
|
// Beat NOW and wait for it to land. `start` calls this before asking the
|
|
17395
17407
|
// person anything, so the browser's connect step already shows what this
|
|
17396
17408
|
// machine has rather than sitting blank while the terminal waits on an answer.
|
|
@@ -18363,7 +18375,7 @@ async function startScript(opts, interactive) {
|
|
|
18363
18375
|
}
|
|
18364
18376
|
if (!isDevicePaired()) {
|
|
18365
18377
|
refuse([
|
|
18366
|
-
`This device isn't set up yet. Run ${cabane("setup")} to
|
|
18378
|
+
`This device isn't set up yet. Run ${cabane("setup")} to sign in to a harness and pair it.`
|
|
18367
18379
|
]);
|
|
18368
18380
|
return;
|
|
18369
18381
|
}
|
|
@@ -18814,7 +18826,7 @@ async function setupScript(opts, deps) {
|
|
|
18814
18826
|
const detection = await detect(cfg, running, deps);
|
|
18815
18827
|
ui.intro("Cabane setup");
|
|
18816
18828
|
const usable = anyUsable(detection);
|
|
18817
|
-
ui.step(["
|
|
18829
|
+
ui.step(["Harnesses on this device", ...agentLines(detection, { signInHint: !usable })]);
|
|
18818
18830
|
if (!usable && hasProblem(detection)) {
|
|
18819
18831
|
ui.cancel(`Fix the above, then run ${cabane("setup")} again.`);
|
|
18820
18832
|
process.exitCode = 1;
|
|
@@ -19214,7 +19226,7 @@ function strayStatusLines(running, find = findStrayCompanions) {
|
|
|
19214
19226
|
}
|
|
19215
19227
|
async function agentLines2(cfg, running) {
|
|
19216
19228
|
const detection = running ?? await detectHarnesses(cfg);
|
|
19217
|
-
return agentRows(detection
|
|
19229
|
+
return agentRows(detection).map((agent) => row2(agent.label, agent.text));
|
|
19218
19230
|
}
|
|
19219
19231
|
function sandboxStatusLines(cfg) {
|
|
19220
19232
|
const rows = agentSandboxRows(cfg);
|
package/dist/runtime.js
CHANGED
|
@@ -1860,7 +1860,7 @@ function turnFailureCopy(reason, runtime) {
|
|
|
1860
1860
|
if (reason?.startsWith("runtime_incomplete:"))
|
|
1861
1861
|
return `The agent installation is incomplete. ${repairAction()} Then restart the companion.`;
|
|
1862
1862
|
if (/^(runtime|codex|opencode)_unavailable:/.test(reason ?? ""))
|
|
1863
|
-
return "The
|
|
1863
|
+
return "The harness is off on this device. Enable it here, or choose another harness or device in Cabane.";
|
|
1864
1864
|
if (reason?.startsWith("model_unavailable:"))
|
|
1865
1865
|
return "The selected model is unavailable. Choose a different model in the agent settings in Cabane.";
|
|
1866
1866
|
if (reason?.startsWith("prepare_failed:"))
|
|
@@ -7761,7 +7761,6 @@ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
|
7761
7761
|
// src/connector-health.ts
|
|
7762
7762
|
var ConnectorHealthStore = class {
|
|
7763
7763
|
byRuntime = /* @__PURE__ */ new Map();
|
|
7764
|
-
sources = /* @__PURE__ */ new Map();
|
|
7765
7764
|
// Record a settled turn's outcome for `runtime`. A success heals to `ok`; a
|
|
7766
7765
|
// classified failure sets `limited`(+reset) / `auth_expired` with the reason as
|
|
7767
7766
|
// `last_error`; anything else (a user Stop, a timeout, an unclassified error)
|
|
@@ -7770,7 +7769,6 @@ var ConnectorHealthStore = class {
|
|
|
7770
7769
|
recordSettle(runtime, outcome) {
|
|
7771
7770
|
if (outcome.ok) {
|
|
7772
7771
|
this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
|
|
7773
|
-
this.sources.set(runtime, "turn");
|
|
7774
7772
|
return;
|
|
7775
7773
|
}
|
|
7776
7774
|
const failure = decodeFailureReason(outcome.errorReason);
|
|
@@ -7782,26 +7780,6 @@ var ConnectorHealthStore = class {
|
|
|
7782
7780
|
limitedUntil: quota.limitedUntil,
|
|
7783
7781
|
lastError: outcome.errorReason ?? null
|
|
7784
7782
|
});
|
|
7785
|
-
this.sources.set(runtime, "turn");
|
|
7786
|
-
}
|
|
7787
|
-
// Record what the companion's own sign-in check found for a connected
|
|
7788
|
-
// runtime at `start` or `connect`, so the heartbeat carries "sign-in needed"
|
|
7789
|
-
// before any turn fails. Signed out seeds `auth_expired` unless a turn has
|
|
7790
|
-
// already spoken; usable clears only a seed of our own.
|
|
7791
|
-
recordLocalCheck(runtime, usable) {
|
|
7792
|
-
const source = this.sources.get(runtime);
|
|
7793
|
-
if (!usable) {
|
|
7794
|
-
if (source === "turn" && this.byRuntime.get(runtime)?.quotaState !== "ok") return;
|
|
7795
|
-
this.byRuntime.set(runtime, {
|
|
7796
|
-
quotaState: "auth_expired",
|
|
7797
|
-
limitedUntil: null,
|
|
7798
|
-
lastError: encodeFailureReason({ kind: "auth_expired" })
|
|
7799
|
-
});
|
|
7800
|
-
this.sources.set(runtime, "local");
|
|
7801
|
-
return;
|
|
7802
|
-
}
|
|
7803
|
-
if (source !== "local") return;
|
|
7804
|
-
this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
|
|
7805
7783
|
}
|
|
7806
7784
|
// The store's current opinion for one runtime, or undefined when it has
|
|
7807
7785
|
// no observation. Read by the dispatcher at idle-timeout fire: an idle reap
|
|
@@ -12767,6 +12745,41 @@ function runtimeIssuesFor(gates) {
|
|
|
12767
12745
|
return issues;
|
|
12768
12746
|
}
|
|
12769
12747
|
|
|
12748
|
+
// src/harness-state.ts
|
|
12749
|
+
function harnessEntry(runtime, state) {
|
|
12750
|
+
switch (state.state) {
|
|
12751
|
+
case "usable":
|
|
12752
|
+
return {
|
|
12753
|
+
runtime,
|
|
12754
|
+
state: "connected",
|
|
12755
|
+
reason: state.via,
|
|
12756
|
+
version: state.version,
|
|
12757
|
+
...state.url ? { url: state.url } : {}
|
|
12758
|
+
};
|
|
12759
|
+
case "signed_out":
|
|
12760
|
+
return { runtime, state: "needs_sign_in", reason: "signed_out", version: state.version };
|
|
12761
|
+
case "not_running":
|
|
12762
|
+
return { runtime, state: "problem", reason: "not_running", url: state.url };
|
|
12763
|
+
case "off":
|
|
12764
|
+
return { runtime, state: "off" };
|
|
12765
|
+
case "problem":
|
|
12766
|
+
return { runtime, state: "problem", reason: checkReason(state.check) };
|
|
12767
|
+
}
|
|
12768
|
+
}
|
|
12769
|
+
function harnessEntriesFromDetection(detection) {
|
|
12770
|
+
return HARNESS_RUNTIMES.map((runtime) => harnessEntry(runtime, detection[runtime]));
|
|
12771
|
+
}
|
|
12772
|
+
function checkReason(check) {
|
|
12773
|
+
switch (check.status) {
|
|
12774
|
+
case "bundle_missing":
|
|
12775
|
+
return check.bundled;
|
|
12776
|
+
case "check_failed":
|
|
12777
|
+
return `check_failed:${check.reason}`;
|
|
12778
|
+
default:
|
|
12779
|
+
return check.status;
|
|
12780
|
+
}
|
|
12781
|
+
}
|
|
12782
|
+
|
|
12770
12783
|
// src/harness-versions.ts
|
|
12771
12784
|
var EMPTY = { claudeCode: null, opencode: null, codex: null };
|
|
12772
12785
|
function parseVersionToken(raw) {
|
|
@@ -14301,6 +14314,9 @@ var CompanionSupervisor = class {
|
|
|
14301
14314
|
// Omitted until the first observed failure or heal, so the server's
|
|
14302
14315
|
// status-less manifest synthesis still runs for a fresh companion.
|
|
14303
14316
|
...connectorReports.length > 0 ? { connectors: connectorReports } : {},
|
|
14317
|
+
// The complete harness inventory in the machine's words, once detection
|
|
14318
|
+
// has run; omitted before it, which the server reads as "nothing yet".
|
|
14319
|
+
...this.detection ? { harnesses: harnessEntriesFromDetection(this.detection) } : {},
|
|
14304
14320
|
...opencodeModels !== null ? { models: opencodeModels } : {},
|
|
14305
14321
|
...this.updater ? { update: this.updater.report() } : {}
|
|
14306
14322
|
});
|
|
@@ -15253,7 +15269,6 @@ var CompanionSupervisor = class {
|
|
|
15253
15269
|
...this.probeOpencode ? { probeOpencode: this.probeOpencode } : {}
|
|
15254
15270
|
});
|
|
15255
15271
|
this.detection = detection;
|
|
15256
|
-
this.seedSignInHealth(previous, detection);
|
|
15257
15272
|
this.logBundledTransitions();
|
|
15258
15273
|
this.harnessVersions = {
|
|
15259
15274
|
claudeCode: detectedVersion(detection["claude-code"]),
|
|
@@ -15311,23 +15326,6 @@ var CompanionSupervisor = class {
|
|
|
15311
15326
|
const bare = advertisedRuntimes(this.detection).filter((runtime) => !named.has(runtime)).map((runtime) => ({ runtime }));
|
|
15312
15327
|
return [...health, ...bare];
|
|
15313
15328
|
}
|
|
15314
|
-
// Seed connector health from what detection saw of a sign-in, so Cabane says
|
|
15315
|
-
// "sign-in needed" before a turn fails. Only a LAPSE seeds it — an agent that
|
|
15316
|
-
// was usable at an earlier look and is signed out now — because a device
|
|
15317
|
-
// that never signed in to Codex has nothing to report about Codex. Usable
|
|
15318
|
-
// again clears a seed of our own.
|
|
15319
|
-
seedSignInHealth(previous, detection) {
|
|
15320
|
-
for (const runtime of ["claude-code", "codex"]) {
|
|
15321
|
-
const now = detection[runtime];
|
|
15322
|
-
if (now.state === "usable") {
|
|
15323
|
-
if (now.via !== "container") this.connectorHealth.recordLocalCheck(runtime, true);
|
|
15324
|
-
continue;
|
|
15325
|
-
}
|
|
15326
|
-
if (now.state === "signed_out" && previous?.[runtime].state === "usable") {
|
|
15327
|
-
this.connectorHealth.recordLocalCheck(runtime, false);
|
|
15328
|
-
}
|
|
15329
|
-
}
|
|
15330
|
-
}
|
|
15331
15329
|
// Beat NOW and wait for it to land. `start` calls this before asking the
|
|
15332
15330
|
// person anything, so the browser's connect step already shows what this
|
|
15333
15331
|
// machine has rather than sitting blank while the terminal waits on an answer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.176",
|
|
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",
|