brane 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AGENTS.md +12 -7
  2. package/CHANGELOG.md +37 -4
  3. package/CONTRIBUTING.md +12 -2
  4. package/DESIGN.md +7 -4
  5. package/README.md +2 -2
  6. package/SECURITY.md +8 -0
  7. package/dist/commands/config.js +17 -0
  8. package/dist/commands/meld.js +38 -10
  9. package/dist/commands/spawn.js +11 -4
  10. package/dist/commands/token.js +2 -1
  11. package/dist/daemon/server.js +88 -84
  12. package/dist/db/schema.js +10 -0
  13. package/dist/db/tokens.js +43 -2
  14. package/dist/shared/app-origin.js +26 -0
  15. package/dist/shared/narrative-menu.js +8 -6
  16. package/dist/shared/view-url.js +39 -20
  17. package/dist/test/app-host-literal.test.js +53 -0
  18. package/dist/test/cors-preflight.test.js +28 -21
  19. package/dist/test/hook-config.test.js +38 -1
  20. package/dist/test/meld.test.js +59 -1
  21. package/dist/test/narrative-menu.test.js +13 -0
  22. package/dist/test/remote-meld.test.js +13 -0
  23. package/dist/test/terminal-size.test.js +36 -0
  24. package/dist/test/token-devices.test.js +59 -0
  25. package/dist/test/view-url.test.js +93 -47
  26. package/dist/test/wrangler-route.test.js +27 -0
  27. package/dist/tmux.js +11 -0
  28. package/docker-entrypoint.sh +15 -2
  29. package/docs/daemon-api.md +4 -4
  30. package/docs/releasing.md +59 -8
  31. package/docs/terminal-mirroring.md +32 -0
  32. package/docs/token-rotation.md +22 -4
  33. package/docs/view-urls.md +26 -6
  34. package/docs/web-app.md +1 -1
  35. package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-e94247f119fdcd3b424a1764233a41d2.js} +47 -45
  36. package/expo/dist/index.html +1 -1
  37. package/expo/dist/sw.js +2 -2
  38. package/package.json +1 -1
  39. package/wiki/shared/index.md +5 -0
package/AGENTS.md CHANGED
@@ -22,7 +22,7 @@ Brane is a TypeScript reimagining of the [organelle](https://github.com/benjamn/
22
22
  - **Terminal mirroring** of tmux sessions with keyboard input
23
23
  - The **LIVE loop** protocol for cursor-based agent communication
24
24
  - A **semantic memory** system with embeddings and full-text search
25
- - **Multi-agent support** with session resumption across 6 agent types
25
+ - **Multi-agent support** across 8 agent types, with session resumption where the agent offers it
26
26
 
27
27
  ## Golden Rule
28
28
 
@@ -66,7 +66,7 @@ brane/
66
66
  │ ├── components/ # MacroMenu, TerminalPane, TerminalKeyboard
67
67
  │ ├── lib/ # api.ts, terminal.ts, connection.ts, registry.ts
68
68
  │ └── dist/ # Built web bundle (served by daemon)
69
- ├── worker/ # Cloudflare Workers deployment (serves expo/dist as SPA)
69
+ ├── worker/ # Cloudflare Workers deployment (serves the latest release's expo/dist as SPA)
70
70
  ├── protocols/
71
71
  │ ├── LIVE_loop.md # Core agent communication protocol
72
72
  │ ├── digest.md # /digest protocol for activity summaries
@@ -113,8 +113,11 @@ non-executable files) while `node dist/cli.js` keeps working.
113
113
 
114
114
  Publishing to npm is done by `.github/workflows/release.yml`, triggered by a
115
115
  published GitHub release, and only by it. **Agents never run `npm publish`.**
116
- The version bump, tag rule, dist-tag choice and why there is no provenance
117
- attestation are in [docs/releasing.md](docs/releasing.md).
116
+ The shared web app ships with it: a release deploys its tarball's
117
+ `expo/dist`, and a merge to `main` deploys nothing (staging is Isolate on a
118
+ daemon running `main`). The version bump, tag rule, dist-tag choice, app
119
+ deploy and rollback, and why 0.3.0 has no provenance attestation are in
120
+ [docs/releasing.md](docs/releasing.md).
118
121
 
119
122
  ## CLI Commands
120
123
 
@@ -125,7 +128,7 @@ attestation are in [docs/releasing.md](docs/releasing.md).
125
128
  | `attach` | Attach to a tmux session |
126
129
  | `list` | List narratives grouped by brane (`--brane`, `--archived`, `--limit`) — reads `GET /api/narratives` + `GET /api/branes` |
127
130
  | `fold` | Register directories as spawn points (`--brane` to associate with a brane) — delegates to `POST /api/folds` |
128
- | `meld` | Generate QR code for mobile access (defaults to `brane.organelle.dev`, `--local` for direct); waits for Touch ID at this machine — see [Presence Check](docs/presence.md#the-gate). `--remote --send … --ask …` melds a daemon on another machine (a Fly machine: /fly) with a token minted here, and refuses in an agent's session |
131
+ | `meld` | Generate QR code for mobile access (points at the `app_domain` config, `brane.organelle.dev` unless set; `--app <host>` for one QR, `--local` for a direct daemon URL); waits for Touch ID at this machine — see [Presence Check](docs/presence.md#the-gate). `--remote --send … --ask …` melds a daemon on another machine (a Fly machine: /fly) with a token minted here, and refuses in an agent's session |
129
132
  | `mcp` | MCP stdio server (used internally by agent sessions) |
130
133
  | `revive` | Restart dead agent sessions (`--prompt`, `--no-prompt`, `--agent`) — delegates to `POST /api/revive/:nid` |
131
134
  | `config` | Get/set brane configuration (git-config style: `--list`, `--unset`) |
@@ -170,6 +173,8 @@ Brane supports multiple agent types with session resumption. Each agent has an a
170
173
  | `codex` | `resume <session_id>` | Subcommand-based resumption |
171
174
  | `amp` | (none) | Uses thread references `@T-<id>` in prompt |
172
175
  | `copilot` | `--session-id <session_id>` | Daemon-pinned UUID resumes the exact local session |
176
+ | `cline` | (none) | Each revive starts a fresh session; brane captures its id for cost |
177
+ | `crush` | (none) | Each revive starts a fresh session; brane captures its id for cost |
173
178
 
174
179
  Spawning a session generates agent-specific MCP config. This happens in `doSpawn()`, which always runs in the daemon — `brane spawn` gets it by delegating to `POST /api/spawn`, not by writing these files itself:
175
180
  - **Claude**: `.mcp.json` in fold directory
@@ -187,7 +192,7 @@ See `SECURITY.md` for full details. Key features:
187
192
  - **Atomic daemon startup** — `BEGIN EXCLUSIVE` SQLite transaction prevents race conditions
188
193
  - **Version checking** — daemon stores version for CLI compatibility detection
189
194
  - **Payload security** — path traversal protection, 50MB size limits, immutable cached URLs
190
- - **Origin checking** — WebSocket upgrade validates Origin header
195
+ - **The token is the only credential** — no origin allowlist: CORS answers `*` without credentials, the WebSocket upgrade checks the token and nothing else, and the `Origin` a device runs the app from is recorded on its token for the Devices list (`SECURITY.md`, Authentication)
191
196
 
192
197
  ## Rules for Every Change
193
198
 
@@ -345,7 +350,7 @@ read at runtime).
345
350
  - **The mirror sends text; everything else is explicit**: the pane's cursor is the only thing about a terminal that isn't in its text, so it rides the same tmux round trip as the capture — and gets its own message when it moves and the text doesn't
346
351
  - **The chat never scrolls itself**: after landing, only the operator moves the chat. It is a `column-reverse` scroller (`CHAT_SCROLLER_STYLE`, `expo/app/[nid].tsx`), not a virtualized `FlatList`, so scroll position is measured from the bottom: it opens on the latest message without a scroll, an arrival is followed only when the view is already exactly at the bottom, a page of older history lands at the far end and moves nothing, and no row is ever swapped for an estimated spacer mid-scroll. The one scroll the code writes is landing on `?scrollToSeq=` / `?scrollTo=`, plus the ↑/↓ tab button the operator taps. Sending doesn't scroll, and vertical scroll is never disabled, not even under a horizontal pane swipe. Measured in Chromium and WebKit against a sandbox daemon: the message under the viewport moves 0 px on arrival, on send and on each page loaded
347
352
  - **A tap should cost what it changed**: the chat's selection lives in a store with per-seq subscriptions, so selecting a message wakes that bubble, the header's count and the composer — not the screen, not `renderItem`, not the other two hundred rows; the same store is what makes two taps in one frame both land
348
- - **The URL carries the view**: anything that must survive an origin hop (Isolate) or a device hop (Meld) goes in the query string, built through `src/shared/view-url.ts` — `sessionStorage` is origin-scoped and does not travel
353
+ - **The URL carries the view**: anything that must survive an origin hop (Isolate) or a device hop (Meld) goes in the query string, built through `src/shared/view-url.ts` — `sessionStorage` is origin-scoped and does not travel. The app names no host: "isolated" is `location.host` equal to the connection's host, and the way out is the daemon's `app_domain` from `/api/status`; `src/test/app-host-literal.test.ts` keeps it that way
349
354
  - **Back undoes a jump, never a view change**: a different narrative or any `nid:seq` link (even into the narrative you're reading) is a `router.push`; a tab, pin, file or selection change is `router.setParams`, which replaces. Screens behind the top stay mounted, so Back restores them whole, and narrative subscriptions are refcounted because two of them can watch the same narrative — see [View URLs](docs/view-urls.md)
350
355
  - **`shared/` is the publication unit**: a fold's `wiki/shared/` is what other folds — and the npm tarball — can see; everything directly under `wiki/` is fold-local, and the install directory is just another fold
351
356
  - **One renderer, two targets**: markdown features that must survive export (code highlighting, math) render server-side or through shared code, never through a script the exported file carries
package/CHANGELOG.md CHANGED
@@ -6,14 +6,47 @@ page, which is also what triggers its publish (see
6
6
  [docs/releasing.md](docs/releasing.md)). This file is the summary that
7
7
  travels in the npm tarball.
8
8
 
9
+ ## 0.3.1 - 2026-10-12
10
+
11
+ - The shared web app ships with the release. It serves the `latest`
12
+ release's own `expo/dist`, and a merge to `main` deploys nothing; a
13
+ daemon running `main` serves its own newer bundle, through Isolate
14
+ ([#600](https://github.com/benjamn/brane/pull/600)).
15
+ - The app knows no host. `brane meld` and `brane spawn --meld` point
16
+ their QR at the `app_domain` config (`--app` / `--meld-app` for one
17
+ QR), `GET /api/status` reports it, and Isolate and the in-app Meld
18
+ work from whichever host serves the app
19
+ ([#598](https://github.com/benjamn/brane/pull/598),
20
+ [#599](https://github.com/benjamn/brane/pull/599)).
21
+ - The token is the only credential. The daemon no longer checks an
22
+ origin allowlist: CORS answers `*` without credentials. The origin
23
+ each device runs the app from is recorded and shown in the Devices
24
+ list and `brane token list`
25
+ ([#593](https://github.com/benjamn/brane/pull/593),
26
+ [#595](https://github.com/benjamn/brane/pull/595)).
27
+ - The spawn dialog's "Other…" agent runs any command on the daemon's
28
+ `PATH`, as `brane spawn <agent>` does, and spawn errors show in the
29
+ dialog ([#596](https://github.com/benjamn/brane/pull/596)).
30
+ - New terminals start at the viewer's size, so launch redraws no longer
31
+ pile up in scrollback, and the 🦾 menu gains "Clear 🧽" to clear a
32
+ pane's scrollback ([#574](https://github.com/benjamn/brane/pull/574)).
33
+ - On a Fly machine the daemon keeps its ports across boots, and a revive
34
+ repoints the fold's hook config at the running daemon
35
+ ([#594](https://github.com/benjamn/brane/pull/594)).
36
+ - Vulnerabilities are reported through GitHub's private vulnerability
37
+ reporting (see `SECURITY.md`). This is the first release published
38
+ from the public repository, so it carries npm provenance; 0.3.0 has
39
+ none ([#597](https://github.com/benjamn/brane/pull/597)).
40
+
9
41
  ## 0.3.0 - 2026-10-11
10
42
 
11
43
  The first release of this codebase. What ships:
12
44
 
13
45
  - The `brane` CLI and userspace daemon: register directories as folds,
14
- spawn agents in them (Claude Code, Codex, Gemini, Goose, Crush, Copilot,
15
- or any command) under tmux, and keep every session's narrative in one
16
- SQLite database, every message with a stable `nid:seq` address.
46
+ spawn agents in them (Claude Code, Codex, Gemini, Goose, Amp, Copilot,
47
+ Cline, Crush, or any command) under tmux, and keep every session's
48
+ narrative in one SQLite database, every message with a stable `nid:seq`
49
+ address.
17
50
  - The LIVE loop: an MCP server injected into each agent session
18
51
  (`narrate`, `listen`, `whoami`, wiki and memory search), and the
19
52
  protocols agents read at runtime, in `protocols/`.
@@ -29,5 +62,5 @@ The first release of this codebase. What ships:
29
62
 
30
63
  ## 0.1.1
31
64
 
32
- An unrelated earlier package that held the name on npm (2022). Not an
65
+ An unrelated earlier package that held the name on npm (2020). Not an
33
66
  ancestor of this code.
package/CONTRIBUTING.md CHANGED
@@ -6,6 +6,14 @@ and every one of them was reviewed, authored, committed, and merged by a
6
6
  human. This document says what that means for anyone touching the
7
7
  repository, human or agent.
8
8
 
9
+ ## Issues and pull requests
10
+
11
+ Brane is not a product, and its maintainer makes no support promise.
12
+ Issues are open: bug reports and ideas are welcome there, and may or may
13
+ not get an answer. Pull requests are limited to the maintainer; if you
14
+ have a fix, describe it in an issue. Vulnerabilities go through private
15
+ reporting instead, as `SECURITY.md` describes.
16
+
9
17
  ## How changes land
10
18
 
11
19
  - `main` takes pull requests only. The `protect-main-branch` ruleset
@@ -58,7 +66,8 @@ Responsibility for a change belongs to a person. Concretely:
58
66
  the work under the project's license. A model cannot hold copyright,
59
67
  enter an agreement, or take responsibility, so it cannot certify
60
68
  anything, and an agent must not write a sign-off on a human's behalf.
61
- - **Outside contributors sign off.** Add
69
+ - **Outside contributors sign off**, should outside pull requests open
70
+ (for now they are maintainer-only; see above). Add
62
71
  `Signed-off-by: Your Name <you@example.com>` to each commit
63
72
  (`git commit -s`), certifying the
64
73
  [Developer Certificate of Origin 1.1](https://developercertificate.org/).
@@ -82,7 +91,8 @@ that way, including code whose origin you cannot account for.
82
91
 
83
92
  ## Security
84
93
 
85
- Do not open a public issue for a vulnerability. See `SECURITY.md`.
94
+ Report a vulnerability privately, as `SECURITY.md` describes, not in an
95
+ issue.
86
96
 
87
97
  ## For agents
88
98
 
package/DESIGN.md CHANGED
@@ -282,10 +282,13 @@ a manual escape hatch for forcing an immediate re-provision.
282
282
 
283
283
  ### CORS
284
284
 
285
- The daemon allows cross-origin requests from:
286
- - `http://localhost:*` — any port, for local dev
287
- - `https://*.organelle.dev` — production domains
288
- - Configurable `app_domain` via config
285
+ The daemon answers any origin with `Access-Control-Allow-Origin: *` and no
286
+ credentials. There is no allowlist: every API request and WebSocket upgrade
287
+ must present a token, so a page holding none gets a 401 whatever its origin,
288
+ and the hostname the app is served from (`brane.organelle.dev`, a staging
289
+ host, a fork's deployment, the daemon's own bundle, `localhost` in dev) is
290
+ not a trust decision. The origin each device runs the app from is recorded
291
+ on its token for the Devices list instead (`SECURITY.md`, Authentication).
289
292
 
290
293
  ## Web App (Expo)
291
294
 
package/README.md CHANGED
@@ -164,7 +164,7 @@ CLI whenever that calculus changes for you.
164
164
 
165
165
  ## Contributing
166
166
 
167
- Brane is developed by one maintainer working with coding agents. Commits an agent writes carry a disclosure trailer; responsibility for every change stays with a human, who reviews, signs off, and merges it. Sign-off is human-only: an agent never certifies a contribution. See [CONTRIBUTING.md](CONTRIBUTING.md) for the rules, including the DCO sign-off asked of outside contributors, and for working from a clone.
167
+ Brane is developed by one maintainer working with coding agents. Commits an agent writes carry a disclosure trailer; responsibility for every change stays with a human, who reviews, signs off, and merges it. Sign-off is human-only: an agent never certifies a contribution. Issues are open; pull requests are maintainer-only. See [CONTRIBUTING.md](CONTRIBUTING.md) for the rules, including the DCO sign-off, and for working from a clone.
168
168
 
169
169
  ## License
170
170
 
@@ -172,4 +172,4 @@ GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See [LICENS
172
172
 
173
173
  Brane is designed to be run as a standalone daemon by individual operators, not embedded into other products. The AGPL's network-use clause ensures that anyone who modifies brane and offers it as a hosted service must share their modifications back.
174
174
 
175
- > Note: the `brane` package was previously published on npm under MIT in 2020. This codebase is a complete rewrite that supersedes that release; any future npm publication would ship as a new major version under AGPL-3.0-or-later.
175
+ > Note: the `brane` name on npm previously carried unrelated MIT-licensed packages (0.0.x in 2015, 0.1.1 in 2020). This codebase is a complete rewrite under AGPL-3.0-or-later, first published as 0.3.0.
package/SECURITY.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Brane is a local-first system. Your data never leaves your machine unless you explicitly connect a device.
4
4
 
5
+ ## Reporting a vulnerability
6
+
7
+ Report it privately, through GitHub's [private vulnerability reporting](https://github.com/benjamn/brane/security/advisories/new) (the repository's **Security** tab, then **Report a vulnerability**). Only the maintainer sees the report. Don't open an issue for it: issues are public.
8
+
9
+ Brane has one maintainer and no support commitment, so there is no promised response time. The report that gets fixed fastest says which version (`brane --version`), what the attacker needs to start with (a tailnet peer, a process on the machine, a melded device, an agent session), and what they end up with.
10
+
5
11
  ## Network isolation by default
6
12
 
7
13
  The daemon binds **only to loopback and Tailscale addresses**. The listen sockets themselves are the first line of defense: by default, the daemon is attached only to `127.0.0.1`, `::1`, and whatever IPs `tailscale status --json` reports (typically one IPv4 and one IPv6 in the 100.x / fd7a::/8 range). **The machine's LAN IP is not bound.** Same-subnet port scans see nothing on the daemon's port — the kernel rejects those SYNs with RST before any userspace code runs.
@@ -44,6 +50,8 @@ To opt in to LAN reach on a trusted home network, pass `brane meld --host <lan-i
44
50
 
45
51
  Every API and WebSocket request over TCP requires a 128-bit UUID bearer token, passed via `Authorization: Bearer` header or query parameter. Brute-forcing 128 bits of entropy is not feasible. The one exception is a request over the daemon's Unix socket, which needs no token and acts as this machine's account (below).
46
52
 
53
+ **The token is the only credential; the origin is reported, never gated.** The daemon does not care which hostname serves the app: `brane.organelle.dev`, a staging hostname, a fork's own deployment, a daemon's own bundle, or a page on a laptop's `localhost`. A page holding no token gets a 401 whatever its origin. A page holding one can *act* with it from any origin: the token is accepted in `?token=` on any method, and request bodies are parsed as JSON whatever their `Content-Type`, so a `text/plain` POST, which needs no preflight, signals or spawns from any origin regardless. An origin allowlist would only hide responses and refuse WebSockets, never block a write, and the browser is the bridge across the IP filter that `curl` off the tailnet does not have. So the only honest boundary is the token. CORS therefore answers `*` with no credentials (brane sets no cookies, and `Allow-Credentials` with a reflected origin is the one CORS combination that could ever be dangerous), and the WebSocket upgrade checks the token and nothing else. What the origin *is* for is the Devices list: the first `Origin` a token is presented from, and the latest, are recorded on its row (`app_origin`, `last_app_origin`) and shown as "via main.brane.organelle.dev", so you can see which copy of the app each device runs. The one request that still looks at `Origin` is a tokenless one on the Unix socket, below, where its presence is what marks a browser behind a relay.
54
+
47
55
  **Tokens are stored as hashes only.** The `tokens` table holds the SHA-256 of each token, never the token, so the daemon's own record of who may connect authenticates nothing by itself, and the daemon cannot print a token back. Each melded device gets its own token, minted when its QR is made, whether by `brane meld` or by the app's Meld item; that QR is the only copy. The device showing an in-app Meld QR never hands over its own token, and opening the sheet mints a fresh one each time. A minted token nobody uses within 10 minutes lapses, so an unscanned or photographed QR stops working. This machine's CLI and MCP bridges hold no token: they reach the daemon over its Unix socket, `~/.brane/daemon.sock`, and present nothing. `~/.brane` is `0700` and the database is `0600`, so other accounts on the machine can read none of it, and cannot connect to the socket either: reaching it takes search permission on that directory. That is why a request over the socket needs no token: only this account can have sent it, and it acts as this machine's `local` row, which no token opens. This machine's standing therefore exists only on the socket and cannot be copied off the machine. Older branes kept a local token in `~/.brane/.daemon_token` instead, which worked from any address the IP filter admits; the daemon revokes it at its first start and empties the file, and `brane doctor` reports the empty leftover, which is yours to remove. Where the daemon cannot listen on the socket (a `~/.brane` path too long for one, a filesystem that cannot hold one), the shell CLI cannot reach it and says why; agents and devices, which hold tokens, still can. The daemon checks the directory is still owned by it and closed to group and other before every such request, and refuses them if it is not. It also refuses a tokenless request that carries an `Origin` header or a `Host` other than `localhost`, which is what a browser's request looks like when something relays a port to the socket (`ssh -L`, socat, a devcontainer forward). Even so, a relay to `daemon.sock` extends "this account" to whoever can reach the relay: don't run one.
48
56
 
49
57
  **What hashing does not cover.** Two things.
@@ -1,10 +1,27 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { openDatabase } from "../db/schema.js";
3
+ import { cleanAppHost } from "../shared/app-origin.js";
3
4
  // Well-known config keys and their defaults
4
5
  export const CONFIG_DEFAULTS = {
6
+ // The hostname that serves the shared app: where `brane meld` points a QR
7
+ // unless `--app` says otherwise, and what `GET /api/status` reports so a
8
+ // daemon's own bundle knows where Isolate hops out to. Never a trust
9
+ // decision: the daemon accepts any origin (SECURITY.md, Authentication).
10
+ // A fork changes this one line, and its daemons point at its own shell.
5
11
  app_domain: "brane.organelle.dev",
6
12
  "session-affinity.enabled": "true",
7
13
  };
14
+ /** The shared app's host from a configured or given `app_domain` value,
15
+ * reduced to a host (`cleanAppHost`); a blank one means the default, so
16
+ * `brane config app_domain ""` or `brane meld --app ""` can never make a QR
17
+ * for `https:///`. Every reader applies this one rule — `brane meld`
18
+ * (`buildMeldUrl`) and `GET /api/status` (`statusAppDomain`) — so a daemon's
19
+ * bundle and the QR always agree on where the shared app is. Lives here,
20
+ * beside the default it falls back to, so the daemon need not import the
21
+ * meld command to read it. */
22
+ export function resolveAppHost(value) {
23
+ return cleanAppHost(value ?? "") || cleanAppHost(CONFIG_DEFAULTS.app_domain);
24
+ }
8
25
  /** Reads a config key. Pass the caller's `db` whenever it has one: the daemon
9
26
  * and every test do. Without it this opens `~/.brane/brane.db` itself — right
10
27
  * for a CLI command, and exactly wrong anywhere else. That fallback is how a
@@ -4,6 +4,8 @@ import qrcode from "qrcode-terminal";
4
4
  import { getDaemonAddress, openDatabase } from "../db/schema.js";
5
5
  import { getTailscaleHost } from "../tailscale.js";
6
6
  import { connectionFragment } from "../shared/view-url.js";
7
+ import { CONFIG_DEFAULTS, getConfig, resolveAppHost } from "./config.js";
8
+ import { cleanAppHost } from "../shared/app-origin.js";
7
9
  import { UNUSED_TOKEN_LAPSE_MS } from "../daemon/token-ring.js";
8
10
  import { daemonRequest, resolveDaemonEndpoint, warnIfVersionMismatch, } from "../daemon/client.js";
9
11
  /** What the CLI prints before a request the daemon holds for the presence
@@ -64,8 +66,14 @@ export function buildMeldUrl(opts) {
64
66
  let path = opts.path ?? "/";
65
67
  if (!path.startsWith("/"))
66
68
  path = "/" + path;
67
- const url = meldLink({ daemonHost, scheme, useLocal }, token, path);
68
- return { url, daemonHost, scheme, useLocal, isLoopbackFallback, tlsDegraded };
69
+ // Where the shared app lives is config, never a literal: a fork sets its
70
+ // own default (CONFIG_DEFAULTS), an operator sets `app_domain`, and a
71
+ // one-off `--app` wins over both. The daemon trusts none of them — the
72
+ // token is the credential — so this only decides which copy of the app
73
+ // the phone loads.
74
+ const appHost = resolveAppHost(opts.app ?? getConfig("app_domain", db));
75
+ const url = meldLink({ daemonHost, scheme, useLocal, appHost }, token, path);
76
+ return { url, daemonHost, scheme, useLocal, appHost, isLoopbackFallback, tlsDegraded };
69
77
  }
70
78
  /** A meld URL for `token`, at the daemon `parts` describes. */
71
79
  export function meldLink(parts, token, path = "/") {
@@ -75,9 +83,12 @@ export function meldLink(parts, token, path = "/") {
75
83
  // the daemon's own request log does not record it. The app reads it from
76
84
  // `location.hash` and strips it once it has been used.
77
85
  const conn = { host: parts.daemonHost, token };
86
+ // A remote brane from before `appHost` was part of the connection reply
87
+ // (readMeldReply) names no shell; this install's default stands in.
88
+ const appHost = parts.appHost ?? CONFIG_DEFAULTS.app_domain;
78
89
  return parts.useLocal
79
90
  ? `${parts.scheme}://${parts.daemonHost}${path}${connectionFragment(conn, false)}`
80
- : `https://brane.organelle.dev${path}${connectionFragment(conn, true)}`;
91
+ : `https://${appHost}${path}${connectionFragment(conn, true)}`;
81
92
  }
82
93
  /** Ask this machine's daemon for a device token for a daemon on another
83
94
  * machine, and its hash. The daemon stores neither, and first asks the
@@ -117,8 +128,16 @@ export function readMeldReply(output) {
117
128
  continue;
118
129
  if (r.isLoopbackFallback)
119
130
  return { kind: "wait", reason: "the remote machine has no Tailscale name yet" };
120
- const { ready: _ready, ...connection } = r;
121
- return { kind: "address", connection: connection };
131
+ const { ready: _ready, appHost, ...connection } = r;
132
+ // A remote that names its shell does so as a string; anything else is
133
+ // treated as unnamed, and meldLink falls back to this install's default.
134
+ return {
135
+ kind: "address",
136
+ connection: {
137
+ ...connection,
138
+ ...(typeof appHost === "string" && appHost ? { appHost: cleanAppHost(appHost) } : {}),
139
+ },
140
+ };
122
141
  }
123
142
  return { kind: "old" };
124
143
  }
@@ -216,13 +235,17 @@ async function meldRemote(args) {
216
235
  console.error("Run this again once it's up; the token sent this time was never shown, so nothing is lost.");
217
236
  process.exit(1);
218
237
  }
219
- const url = meldLink(connection, token);
238
+ // The remote daemon's `app_domain` names the shell, unless --app does.
239
+ const link = args.app ? { ...connection, appHost: cleanAppHost(args.app) } : connection;
240
+ const url = meldLink(link, token);
220
241
  console.log(`\nScan this QR code to meld:\n`);
221
242
  qrcode.generate(url, { small: args.small ?? true }, (code) => {
222
243
  console.log(code);
223
244
  });
224
245
  console.log(`\n${url}\n`);
225
246
  console.log(`Daemon: ${connection.daemonHost} (${connection.scheme})`);
247
+ if (!connection.useLocal)
248
+ console.log(`App: ${link.appHost ?? CONFIG_DEFAULTS.app_domain}`);
226
249
  // The clock started with the remote daemon's restart, not now.
227
250
  console.log(`This QR is valid for ${UNUSED_TOKEN_LAPSE_MS / 60_000} minutes from the remote daemon's start; once scanned, the device stays signed in until revoked.`);
228
251
  if (connection.tlsDegraded) {
@@ -245,7 +268,11 @@ export const meldCommand = defineCommand({
245
268
  },
246
269
  local: {
247
270
  type: "boolean",
248
- description: "Generate direct daemon URL instead of brane.organelle.dev",
271
+ description: "Generate a direct daemon URL instead of the shared app's (see --app)",
272
+ },
273
+ app: {
274
+ type: "string",
275
+ description: "The shared app's host to point the QR at (default: the `app_domain` config, brane.organelle.dev unless set)",
249
276
  },
250
277
  connection: {
251
278
  type: "boolean",
@@ -278,7 +305,7 @@ export const meldCommand = defineCommand({
278
305
  try {
279
306
  const address = getDaemonAddress(db);
280
307
  const reply = address
281
- ? (({ url: _url, ...connection }) => ({ ready: true, ...connection }))(buildMeldUrl({ db, daemon: { port: address.port, token: "" }, host: args.host, local: args.local }))
308
+ ? (({ url: _url, ...connection }) => ({ ready: true, ...connection }))(buildMeldUrl({ db, daemon: { port: address.port, token: "" }, host: args.host, local: args.local, app: args.app }))
282
309
  : { ready: false, reason: "no daemon is running yet" };
283
310
  console.log(JSON.stringify(reply));
284
311
  }
@@ -300,11 +327,12 @@ export const meldCommand = defineCommand({
300
327
  }
301
328
  const daemon = { port: endpoint.port, token };
302
329
  const db = openDatabase();
303
- const { url, daemonHost, scheme, useLocal, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
330
+ const { url, daemonHost, scheme, useLocal, appHost, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
304
331
  db,
305
332
  daemon,
306
333
  host: args.host,
307
334
  local: args.local,
335
+ app: args.app,
308
336
  });
309
337
  db.close();
310
338
  console.log(`\nScan this QR code to meld:\n`);
@@ -314,7 +342,7 @@ export const meldCommand = defineCommand({
314
342
  console.log(`\n${url}\n`);
315
343
  console.log(`Daemon: ${daemonHost} (${scheme})`);
316
344
  if (!useLocal) {
317
- console.log(`App: brane.organelle.dev`);
345
+ console.log(`App: ${appHost}`);
318
346
  }
319
347
  console.log(meldLapseNote());
320
348
  if (isLoopbackFallback) {
@@ -929,7 +929,9 @@ export function doSpawn(opts) {
929
929
  return { narrativeId, foldPath, agentType: agent, role };
930
930
  }
931
931
  /** Set up MCP config and build launch command for an agent in a fold.
932
- * Used by revive when switching agent types. */
932
+ * Nothing calls this: switching agents on revive goes through
933
+ * `doSpawn({ re })`, and a same-agent revive builds its own launch in
934
+ * `reviveNarrative`, which has to refresh hook and affinity config itself. */
933
935
  export function prepareAgentLaunch(db, agent, narrativeId, foldPath) {
934
936
  const agentCommand = resolveAgentCommand(agent);
935
937
  const absoluteFoldPath = fromPortablePath(foldPath);
@@ -1017,7 +1019,11 @@ export const spawnCommand = defineCommand({
1017
1019
  },
1018
1020
  "meld-local": {
1019
1021
  type: "boolean",
1020
- description: "Use a direct daemon URL in the meld output instead of brane.organelle.dev",
1022
+ description: "Use a direct daemon URL in the meld output instead of the shared app's",
1023
+ },
1024
+ "meld-app": {
1025
+ type: "string",
1026
+ description: "The shared app's host for the meld output (default: the `app_domain` config)",
1021
1027
  },
1022
1028
  },
1023
1029
  async run({ args }) {
@@ -1053,12 +1059,13 @@ export const spawnCommand = defineCommand({
1053
1059
  console.log(` re: ${args.re}`);
1054
1060
  if (deviceToken) {
1055
1061
  const meldDb = openDatabase();
1056
- const { url, daemonHost, scheme, useLocal, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
1062
+ const { url, daemonHost, scheme, useLocal, appHost, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
1057
1063
  db: meldDb,
1058
1064
  daemon: { port: endpoint.port, token: deviceToken },
1059
1065
  path: `/${narrative.id}`,
1060
1066
  host: args["meld-host"],
1061
1067
  local: args["meld-local"],
1068
+ app: args["meld-app"],
1062
1069
  });
1063
1070
  meldDb.close();
1064
1071
  console.log(`\nScan this QR code to open ${narrative.id}:\n`);
@@ -1068,7 +1075,7 @@ export const spawnCommand = defineCommand({
1068
1075
  console.log(`\n${url}\n`);
1069
1076
  console.log(`Daemon: ${daemonHost} (${scheme})`);
1070
1077
  if (!useLocal)
1071
- console.log(`App: brane.organelle.dev`);
1078
+ console.log(`App: ${appHost}`);
1072
1079
  console.log(meldLapseNote());
1073
1080
  if (isLoopbackFallback) {
1074
1081
  console.log("\nNo Tailscale detected — this URL only works from this machine.");
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { resolveDaemonEndpoint, daemonRequest, warnIfVersionMismatch, } from "../daemon/client.js";
3
3
  import { announcePresenceWait } from "./meld.js";
4
+ import { originHost } from "../shared/app-origin.js";
4
5
  /** Sign out every device. One request: this shell holds no token, so there
5
6
  * is none to swap and nothing to confirm — once the operator passes the
6
7
  * presence prompt the daemon revokes every device token before it answers
@@ -67,7 +68,7 @@ export function formatTokenList(tokens) {
67
68
  : "never used";
68
69
  const lines = [
69
70
  `${t.id} ${name}${marks.length ? ` [${marks.join("; ")}]` : ""}`,
70
- ` "${t.label}", created ${formatTime(t.created_at)}${t.minted_by_device ? ` by ${t.minted_by_device}` : ""}, ${seen}${t.remote_addr ? `, from ${t.remote_addr}` : ""}`,
71
+ ` "${t.label}", created ${formatTime(t.created_at)}${t.minted_by_device ? ` by ${t.minted_by_device}` : ""}, ${seen}${t.remote_addr ? `, from ${t.remote_addr}` : ""}${t.app_origin ? `, via ${originHost(t.app_origin)}` : ""}`,
71
72
  ];
72
73
  if (t.descendants?.length)
73
74
  lines.push(` added ${t.descendants.length} device(s): ${t.descendants.join(", ")} (revoke --cascade takes them too)`);