brane 0.3.0 → 0.3.2

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 (42) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +56 -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/archive-gate.js +11 -0
  12. package/dist/daemon/server.js +112 -89
  13. package/dist/db/schema.js +10 -0
  14. package/dist/db/tokens.js +43 -2
  15. package/dist/shared/app-origin.js +26 -0
  16. package/dist/shared/narrative-menu.js +16 -11
  17. package/dist/shared/view-url.js +39 -20
  18. package/dist/test/app-host-literal.test.js +53 -0
  19. package/dist/test/archive-gate.test.js +22 -0
  20. package/dist/test/cors-preflight.test.js +28 -21
  21. package/dist/test/hook-config.test.js +38 -1
  22. package/dist/test/meld.test.js +59 -1
  23. package/dist/test/narrative-menu.test.js +30 -3
  24. package/dist/test/remote-meld.test.js +13 -0
  25. package/dist/test/terminal-size.test.js +36 -0
  26. package/dist/test/token-devices.test.js +59 -0
  27. package/dist/test/view-url.test.js +93 -47
  28. package/dist/test/wrangler-route.test.js +27 -0
  29. package/dist/tmux.js +11 -0
  30. package/docker-entrypoint.sh +15 -2
  31. package/docs/daemon-api.md +5 -5
  32. package/docs/mcp.md +1 -1
  33. package/docs/releasing.md +62 -9
  34. package/docs/terminal-mirroring.md +32 -0
  35. package/docs/token-rotation.md +26 -6
  36. package/docs/view-urls.md +32 -7
  37. package/docs/web-app.md +2 -2
  38. package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-807575a48b8a2f18f8bffe53c8b35fbd.js} +47 -45
  39. package/expo/dist/index.html +1 -1
  40. package/expo/dist/sw.js +2 -2
  41. package/package.json +1 -1
  42. package/wiki/shared/index.md +5 -0
@@ -1,14 +1,24 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { CANONICAL_TABS, SHARED_SPA_HOST, isIsolatedHost, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, } from "../shared/view-url.js";
3
+ import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, } from "../shared/view-url.js";
4
4
  // These pin the URL contract that the Isolate menu item depends on. It opens
5
- // the same narrative on the *other* origin — the shared SPA at brane.organelle.dev
6
- // or a daemon's own bundle — and the tab layout and open file live in
7
- // sessionStorage, which is origin-scoped. So the query string is the only thing
8
- // that crosses, and every assertion below is really asking the same question:
9
- // does the view you were looking at survive the hop?
5
+ // the same narrative on the *other* origin — a shared app, or a daemon's own
6
+ // bundle — and the tab layout and open file live in sessionStorage, which is
7
+ // origin-scoped. So the query string is the only thing that crosses, and every
8
+ // assertion below is really asking the same question: does the view you were
9
+ // looking at survive the hop?
10
+ //
11
+ // The app carries no host of its own: "isolated" is `location.host` equal to
12
+ // the connection's host, and the way out of a daemon's bundle is whatever the
13
+ // daemon reports as its app host. So the shared host here is deliberately not
14
+ // brane's; any host serving the bundle is a shared app.
10
15
  const CONN = { host: "chalk.tail1234.ts.net", token: "tok-abc123" };
11
16
  const NID = "claude-0b9okmy2";
17
+ /** The page's host while on a shared app. */
18
+ const SHARED_SPA_HOST = "shell.example.test";
19
+ /** What the daemon reports as its `app_domain`: where a daemon's own bundle
20
+ * hops out to, which need not be the shared app the operator came from. */
21
+ const APP_HOST = "app.example.test";
12
22
  /** Every subset of the tab set, as a Set — 8 of them. */
13
23
  function pinPowerset() {
14
24
  const out = [];
@@ -146,13 +156,19 @@ describe("parseActiveTab / parsePinnedTabs", () => {
146
156
  }
147
157
  });
148
158
  });
149
- describe("isIsolatedHost", () => {
150
- it("treats only the shared SPA as un-isolated", () => {
151
- assert.equal(isIsolatedHost(SHARED_SPA_HOST), false);
152
- for (const host of ["chalk.tail1234.ts.net", "localhost", "127.0.0.1", "brane.example.com"]) {
153
- assert.equal(isIsolatedHost(host), true, host);
159
+ describe("isIsolated", () => {
160
+ it("is true only when the page is served by the daemon it is connected to", () => {
161
+ assert.equal(isIsolated(CONN.host, CONN.host), true);
162
+ assert.equal(isIsolated("CHALK.tail1234.TS.NET", CONN.host), true, "hosts are case-insensitive");
163
+ for (const host of [SHARED_SPA_HOST, "brane.organelle.dev", "main.brane.organelle.dev", "localhost:8081", "127.0.0.1:8081"]) {
164
+ assert.equal(isIsolated(host, CONN.host), false, host);
154
165
  }
155
166
  });
167
+ it("counts the port: a daemon's bundle is reached on its port", () => {
168
+ assert.equal(isIsolated("192.168.1.20:7777", "192.168.1.20:7777"), true);
169
+ assert.equal(isIsolated("192.168.1.20:7777", "192.168.1.20:8888"), false);
170
+ assert.equal(isIsolated("192.168.1.20", "192.168.1.20:7777"), false);
171
+ });
156
172
  });
157
173
  /** Parse a built URL back into something assertable. */
158
174
  /** A built URL, taken apart the way the app takes it apart: `params` is the
@@ -234,7 +250,8 @@ describe("connectionFragment", () => {
234
250
  for (const c of CASES) {
235
251
  const view = params(c);
236
252
  const url = new URL(buildIsolateUrl({
237
- hostname: CONN.host,
253
+ host: CONN.host,
254
+ appHost: APP_HOST,
238
255
  protocol: "https:",
239
256
  search: "?scrollToSeq=42",
240
257
  path: `/${NID}`,
@@ -258,14 +275,17 @@ describe("connectionFragment", () => {
258
275
  describe("buildIsolateUrl", () => {
259
276
  it("targets the shared SPA when leaving a daemon, and melds it back", () => {
260
277
  const { origin, pathname, params, conn } = open(buildIsolateUrl({
261
- hostname: CONN.host,
278
+ host: CONN.host,
279
+ appHost: APP_HOST,
262
280
  protocol: "https:",
263
281
  search: "?token=old-token",
264
282
  path: `/${NID}`,
265
283
  conn: CONN,
266
284
  view: viewParams("files", new Set(["terminal"]), "src/cli.ts", 4, 9),
267
285
  }));
268
- assert.equal(origin, `https://${SHARED_SPA_HOST}`);
286
+ // Out of a daemon's bundle, the target is what the daemon reported, not
287
+ // any host the app knows.
288
+ assert.equal(origin, `https://${APP_HOST}`);
269
289
  assert.equal(pathname, `/${NID}`);
270
290
  // The connection rides in the fragment, never the query string: a query
271
291
  // string is sent to the SPA's host, a fragment is not.
@@ -280,7 +300,7 @@ describe("buildIsolateUrl", () => {
280
300
  });
281
301
  it("targets the daemon's own bundle when leaving the SPA, with no host param", () => {
282
302
  const { origin, pathname, params, conn } = open(buildIsolateUrl({
283
- hostname: SHARED_SPA_HOST,
303
+ host: SHARED_SPA_HOST,
284
304
  protocol: "https:",
285
305
  search: `?host=${CONN.host}&token=${CONN.token}`,
286
306
  path: `/${NID}`,
@@ -299,7 +319,7 @@ describe("buildIsolateUrl", () => {
299
319
  it("follows the page's scheme to the daemon, so plain http stays http", () => {
300
320
  for (const [protocol, scheme] of [["https:", "https"], ["http:", "http"]]) {
301
321
  const { origin } = open(buildIsolateUrl({
302
- hostname: SHARED_SPA_HOST,
322
+ host: SHARED_SPA_HOST,
303
323
  protocol,
304
324
  search: "",
305
325
  path: `/${NID}`,
@@ -308,39 +328,59 @@ describe("buildIsolateUrl", () => {
308
328
  }));
309
329
  assert.equal(origin, `${scheme}://${CONN.host}`, protocol);
310
330
  }
311
- // The shared SPA is https-only, whatever the page you left was served over.
331
+ // The hosted shell is https-only, whatever the daemon's bundle you left
332
+ // was served over (a LAN session is plain http).
312
333
  const { origin } = open(buildIsolateUrl({
313
- hostname: "localhost",
334
+ host: "192.168.1.20:7777",
314
335
  protocol: "http:",
315
336
  search: "",
316
337
  path: `/${NID}`,
317
- conn: CONN,
338
+ conn: { host: "192.168.1.20:7777", token: CONN.token },
339
+ appHost: APP_HOST,
318
340
  view: viewParams("chat", new Set()),
319
341
  }));
320
- assert.equal(origin, `https://${SHARED_SPA_HOST}`);
342
+ assert.equal(origin, `https://${APP_HOST}`);
343
+ });
344
+ it("has nowhere to go from a daemon's bundle that reported no app host, and says so", () => {
345
+ // A daemon from before `app_domain` rode in /api/status, or one the
346
+ // status probe could not reach: the menu hides Isolate rather than
347
+ // handing out a link to a host the app made up.
348
+ for (const appHost of [undefined, null, ""]) {
349
+ assert.equal(buildIsolateUrl({ host: CONN.host, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, appHost, view: {} }), null, String(appHost));
350
+ }
351
+ // From a shared app the way *in* needs no app host at all.
352
+ assert.ok(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view: {} }));
353
+ });
354
+ it("treats any host serving the bundle as a shared app, and hops in to the daemon from it", () => {
355
+ for (const host of ["brane.organelle.dev", "main.brane.organelle.dev", "fork.example", "localhost:8081"]) {
356
+ const { origin, conn } = open(buildIsolateUrl({ host, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view: {} }));
357
+ assert.equal(origin, `https://${CONN.host}`, host);
358
+ assert.equal(conn.get("host"), null, host);
359
+ }
321
360
  });
322
361
  it("never carries the departing origin's credentials", () => {
323
362
  // A stale host/token in the search string is the danger case: the SPA's
324
363
  // meld params must not follow you onto the daemon's own origin, where the
325
364
  // host is wrong and the URL would carry a second copy of the token.
326
- for (const hostname of [SHARED_SPA_HOST, CONN.host]) {
365
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
327
366
  const { params, conn } = open(buildIsolateUrl({
328
- hostname,
367
+ host,
368
+ appHost: APP_HOST,
329
369
  protocol: "https:",
330
370
  search: "?host=stale.example.com&token=stale-token&tab=files",
331
371
  path: `/${NID}`,
332
372
  conn: CONN,
333
373
  view: viewParams("files", new Set()),
334
374
  }));
335
- assert.deepEqual(params.getAll("host"), [], hostname);
336
- assert.deepEqual(params.getAll("token"), [], hostname);
337
- assert.notEqual(conn.get("host"), "stale.example.com", hostname);
338
- assert.deepEqual(conn.getAll("token"), [CONN.token], hostname);
375
+ assert.deepEqual(params.getAll("host"), [], host);
376
+ assert.deepEqual(params.getAll("token"), [], host);
377
+ assert.notEqual(conn.get("host"), "stale.example.com", host);
378
+ assert.deepEqual(conn.getAll("token"), [CONN.token], host);
339
379
  }
340
380
  });
341
381
  it("keeps params it doesn't model, like the scroll target", () => {
342
382
  const { params } = open(buildIsolateUrl({
343
- hostname: SHARED_SPA_HOST,
383
+ host: SHARED_SPA_HOST,
344
384
  protocol: "https:",
345
385
  search: "?scrollToSeq=42&scrollTo=1787255699631&theme=dark",
346
386
  path: `/${NID}`,
@@ -355,7 +395,7 @@ describe("buildIsolateUrl", () => {
355
395
  // This is why the URL alone isn't enough: what's in the address bar can be
356
396
  // older than what you're looking at.
357
397
  const { params } = open(buildIsolateUrl({
358
- hostname: SHARED_SPA_HOST,
398
+ host: SHARED_SPA_HOST,
359
399
  protocol: "https:",
360
400
  search: "?tab=chat&pinned=chat&path=old/file.ts&line=1",
361
401
  path: `/${NID}`,
@@ -369,7 +409,7 @@ describe("buildIsolateUrl", () => {
369
409
  });
370
410
  it("lets live state at its default clear a stale param", () => {
371
411
  const { params } = open(buildIsolateUrl({
372
- hostname: SHARED_SPA_HOST,
412
+ host: SHARED_SPA_HOST,
373
413
  protocol: "https:",
374
414
  search: "?tab=files&pinned=terminal&path=old/file.ts&line=1-9",
375
415
  path: `/${NID}`,
@@ -384,7 +424,7 @@ describe("buildIsolateUrl", () => {
384
424
  // The restore path deliberately skips router.setParams, so this is the
385
425
  // case the whole overlay exists for: an empty search string, a full view.
386
426
  const { params } = open(buildIsolateUrl({
387
- hostname: SHARED_SPA_HOST,
427
+ host: SHARED_SPA_HOST,
388
428
  protocol: "https:",
389
429
  search: "",
390
430
  path: `/${NID}`,
@@ -398,15 +438,15 @@ describe("buildIsolateUrl", () => {
398
438
  });
399
439
  it("accepts a search string with or without its leading ?", () => {
400
440
  const view = viewParams("files", new Set(), "a.ts");
401
- const withQ = open(buildIsolateUrl({ hostname: SHARED_SPA_HOST, protocol: "https:", search: "?scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
402
- const without = open(buildIsolateUrl({ hostname: SHARED_SPA_HOST, protocol: "https:", search: "scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
441
+ const withQ = open(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "?scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
442
+ const without = open(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
403
443
  assert.equal(withQ.params.get("scrollToSeq"), "7");
404
444
  assert.deepEqual([...withQ.params], [...without.params]);
405
445
  });
406
446
  it("encodes paths that would otherwise break the URL", () => {
407
447
  for (const path of ["docs/a file with spaces.md", "src/c#sharp.ts", "a/b/c/d.ts", "notes/100% done.md", "x?y=z.ts", "π/unicode.ts"]) {
408
448
  const url = buildIsolateUrl({
409
- hostname: SHARED_SPA_HOST,
449
+ host: SHARED_SPA_HOST,
410
450
  protocol: "https:",
411
451
  search: "",
412
452
  path: `/${NID}`,
@@ -419,9 +459,9 @@ describe("buildIsolateUrl", () => {
419
459
  it("preserves every view in the grid, in both directions", () => {
420
460
  for (const c of CASES) {
421
461
  const view = params(c);
422
- for (const hostname of [SHARED_SPA_HOST, CONN.host]) {
462
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
423
463
  const { params: got, conn } = open(buildIsolateUrl({
424
- hostname, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view,
464
+ host, appHost: APP_HOST, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view,
425
465
  }));
426
466
  assert.equal(got.get("tab"), view.tab ?? null, label(c));
427
467
  assert.equal(got.get("pinned"), view.pinned ?? null, label(c));
@@ -439,7 +479,7 @@ describe("buildIsolateUrl", () => {
439
479
  for (const c of CASES) {
440
480
  const view = params(c);
441
481
  const out = new URL(buildIsolateUrl({
442
- hostname: SHARED_SPA_HOST,
482
+ host: SHARED_SPA_HOST,
443
483
  protocol: "https:",
444
484
  search: "?scrollToSeq=42",
445
485
  path: `/${NID}`,
@@ -447,8 +487,10 @@ describe("buildIsolateUrl", () => {
447
487
  view,
448
488
  }));
449
489
  const back = new URL(buildIsolateUrl({
450
- hostname: out.hostname,
490
+ host: out.host,
451
491
  protocol: out.protocol,
492
+ // The daemon's bundle is told where out is, as /api/status would.
493
+ appHost: APP_HOST,
452
494
  search: out.search,
453
495
  path: `/${NID}`,
454
496
  conn: CONN,
@@ -456,7 +498,9 @@ describe("buildIsolateUrl", () => {
456
498
  // the screen does when it mounts.
457
499
  view: viewParams(parseActiveTab(out.searchParams.get("tab") ?? undefined), parsePinnedTabs(out.searchParams.get("pinned") ?? undefined), out.searchParams.get("path") ?? undefined, parseLineSpec(out.searchParams.get("line")).line, parseLineSpec(out.searchParams.get("line")).lineEnd),
458
500
  }));
459
- assert.equal(back.origin, `https://${SHARED_SPA_HOST}`, label(c));
501
+ // Back out lands where the daemon says, which is the page we left only
502
+ // if its app_domain names it; here it names APP_HOST.
503
+ assert.equal(back.origin, `https://${APP_HOST}`, label(c));
460
504
  assert.equal(back.pathname, `/${NID}`, label(c));
461
505
  assert.equal(back.searchParams.get("scrollToSeq"), "42", label(c));
462
506
  const conn = new URLSearchParams(back.hash.slice(1));
@@ -477,7 +521,7 @@ describe("buildMeldUrl", () => {
477
521
  // has never heard of that daemon.
478
522
  it("stays on the shared SPA when that's where you are", () => {
479
523
  const url = new URL(buildMeldUrl({
480
- hostname: SHARED_SPA_HOST,
524
+ host: SHARED_SPA_HOST,
481
525
  protocol: "https:",
482
526
  search: "",
483
527
  path: `/${NID}`,
@@ -500,7 +544,8 @@ describe("buildMeldUrl", () => {
500
544
  });
501
545
  it("stays on the daemon's own bundle when that's where you are", () => {
502
546
  const url = new URL(buildMeldUrl({
503
- hostname: CONN.host,
547
+ host: CONN.host,
548
+ appHost: APP_HOST,
504
549
  protocol: "https:",
505
550
  search: "",
506
551
  path: `/${NID}`,
@@ -516,9 +561,10 @@ describe("buildMeldUrl", () => {
516
561
  });
517
562
  it("goes the opposite way from Isolate, from either origin", () => {
518
563
  // The whole difference between the two menu items, stated once.
519
- for (const hostname of [SHARED_SPA_HOST, CONN.host]) {
564
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
520
565
  const opts = {
521
- hostname,
566
+ host,
567
+ appHost: APP_HOST,
522
568
  protocol: "https:",
523
569
  search: "",
524
570
  path: `/${NID}`,
@@ -527,16 +573,16 @@ describe("buildMeldUrl", () => {
527
573
  };
528
574
  const meld = new URL(buildMeldUrl(opts));
529
575
  const isolate = new URL(buildIsolateUrl(opts));
530
- assert.equal(meld.hostname, hostname, `meld stays put from ${hostname}`);
531
- assert.notEqual(isolate.hostname, hostname, `isolate flips from ${hostname}`);
576
+ assert.equal(meld.host, host, `meld stays put from ${host}`);
577
+ assert.notEqual(isolate.host, host, `isolate flips from ${host}`);
532
578
  }
533
579
  });
534
580
  it("carries the same view Isolate would, over the whole grid", () => {
535
581
  for (const c of CASES) {
536
582
  const view = params(c);
537
- for (const hostname of [SHARED_SPA_HOST, CONN.host]) {
583
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
538
584
  const url = new URL(buildMeldUrl({
539
- hostname, protocol: "https:", search: "?scrollToSeq=42", path: `/${NID}`, conn: CONN, view,
585
+ host, appHost: APP_HOST, protocol: "https:", search: "?scrollToSeq=42", path: `/${NID}`, conn: CONN, view,
540
586
  }));
541
587
  assert.equal(url.searchParams.get("scrollToSeq"), "42", label(c));
542
588
  assert.equal(new URLSearchParams(url.hash.slice(1)).get("token"), CONN.token, label(c));
@@ -550,7 +596,7 @@ describe("buildMeldUrl", () => {
550
596
  it("hands out an http link when the page itself is http", () => {
551
597
  // A LAN session without TLS must not emit https links the phone can't load.
552
598
  const url = new URL(buildMeldUrl({
553
- hostname: "192.168.1.20:7777",
599
+ host: "192.168.1.20:7777",
554
600
  protocol: "http:",
555
601
  search: "",
556
602
  path: `/${NID}`,
@@ -0,0 +1,27 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CONFIG_DEFAULTS } from "../commands/config.js";
7
+ // The shared app's host is written in two places: the route the worker is
8
+ // deployed to (worker/wrangler.toml) and the default `app_domain` that
9
+ // `brane meld` points a QR at and a daemon's bundle hops out to. A fork that
10
+ // changes one and not the other melds phones to a host that serves nothing,
11
+ // so they are pinned together here.
12
+ //
13
+ // This reads *source*, which the npm tarball does not carry, so it skips when
14
+ // the source is absent rather than failing `brane doctor` on every install.
15
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+ const WRANGLER = join(root, "worker", "wrangler.toml");
17
+ describe("the worker's route", () => {
18
+ it("is CONFIG_DEFAULTS.app_domain, as a custom domain, with no workers.dev alias", (t) => {
19
+ if (!existsSync(WRANGLER))
20
+ return t.skip("source is not present (an installed package)");
21
+ const toml = readFileSync(WRANGLER, "utf8").replace(/#.*$/gm, "");
22
+ const routes = [...toml.matchAll(/\{\s*pattern\s*=\s*"([^"]+)"\s*,\s*custom_domain\s*=\s*true\s*\}/g)].map((m) => m[1]);
23
+ assert.deepEqual(routes, [CONFIG_DEFAULTS.app_domain]);
24
+ assert.match(toml, /^workers_dev\s*=\s*false\s*$/m);
25
+ });
26
+ });
27
+ //# sourceMappingURL=wrangler-route.test.js.map
package/dist/tmux.js CHANGED
@@ -171,6 +171,17 @@ function download(url, dest, redirects = 0) {
171
171
  export function tmuxQuote(word) {
172
172
  return `'${word.replace(/'/g, `'\\''`)}'`;
173
173
  }
174
+ /** A request's `terminal_size`, if it is a usable grid: the size the
175
+ * requesting viewer's pane last measured, which a new session should start
176
+ * at. Anything else — absent, non-integer, out of range — is null, and the
177
+ * caller falls back to the last size applied to any session. */
178
+ export function parseTerminalSize(value) {
179
+ if (!value || typeof value !== "object")
180
+ return null;
181
+ const { cols, rows } = value;
182
+ const ok = (n) => Number.isInteger(n) && n >= 1 && n <= 10000;
183
+ return ok(cols) && ok(rows) ? { cols, rows } : null;
184
+ }
174
185
  /** The `new-session` line `newTmuxSession` sends, as tmux will parse it. */
175
186
  export function newSessionLine(opts) {
176
187
  const parts = ["new-session", "-d", "-s", tmuxQuote(opts.name), "-c", tmuxQuote(opts.cwd)];
@@ -109,8 +109,18 @@ fi
109
109
  # Clear stale daemon meta from the persisted volume — PIDs from a previous
110
110
  # container life are never valid in a new container, but may collide with
111
111
  # a live PID here (e.g., tailscaled) and trip the "already running" check.
112
+ #
113
+ # Not the ports, though. Every melded device's URL and every fold's hook
114
+ # config names them, so the daemon comes back on the ones it had, as it
115
+ # does after any restart; clearing them moved both on every boot. The main
116
+ # port's row still goes (the fold registration below waits for the daemon
117
+ # to write it back), so it is read first and handed back with --port. The
118
+ # HTTP fallback port's row stays: the daemon only ever takes it as a hint
119
+ # for what to bind, and nothing reads it while daemon_tls is unset.
120
+ PREV_PORT=$(gosu brane sqlite3 /home/brane/.brane/brane.db \
121
+ "SELECT value FROM meta WHERE key = 'daemon_port';" 2>/dev/null || true)
112
122
  gosu brane sqlite3 /home/brane/.brane/brane.db \
113
- "DELETE FROM meta WHERE key IN ('daemon_pid','daemon_token','daemon_port','daemon_version','daemon_tls','daemon_http_port','daemon_socket');" \
123
+ "DELETE FROM meta WHERE key IN ('daemon_pid','daemon_token','daemon_port','daemon_version','daemon_tls','daemon_socket');" \
114
124
  2>/dev/null || true
115
125
 
116
126
  # Register the default workspace as a fold (idempotent) once the daemon is up.
@@ -138,4 +148,7 @@ gosu brane sqlite3 /home/brane/.brane/brane.db \
138
148
  # with EADDRNOTAVAIL. Binding broadly is safe here — Fly's only reachable
139
149
  # networks are Tailscale and the private 6PN, both of which the userspace
140
150
  # IP filter already restricts.
141
- exec gosu brane brane daemon start --foreground --bind 0.0.0.0 "$@"
151
+ # --port: the port the last boot had (above). If something else holds it
152
+ # now, the daemon exits before writing its row back, so the next boot finds
153
+ # none and lets the OS pick.
154
+ exec gosu brane brane daemon start --foreground --bind 0.0.0.0 ${PREV_PORT:+--port "$PREV_PORT"} "$@"
@@ -24,10 +24,10 @@ until someone lists it. An agent also may not revive or prompt a
24
24
 
25
25
  | Method | Path | Purpose |
26
26
  |--------|------|---------|
27
- | `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`) |
27
+ | `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`, `app_domain`: the shared app's host, where a daemon's own bundle hops out to on Isolate — see [View URLs](view-urls.md)) |
28
28
  | `GET` | `/api/narratives?archived=1` | List narratives (excludes archived by default) |
29
29
  | `GET` | `/api/narratives/recent?since=&search=&status=&fold=&limit=&archived=1&brane_ids=` | Recently active narratives — the browse half of the `narrative_info` MCP tool; every brane unless `brane_ids` scopes it |
30
- | `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative |
30
+ | `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative; archiving a running one is a 409 (`src/daemon/archive-gate.ts`) |
31
31
  | `GET` | `/api/narratives/:id/events?after=N&before=S&tail=T` | Fetch events with cursor-based query |
32
32
  | `POST` | `/api/narratives/:id/signal` | Send operator signal (supports payloads). `source` is stamped from the token (`web` for a device, `local` for this account); a `source` in the body is ignored, and an agent's token is refused (`src/daemon/signal-source.ts`) |
33
33
  | `GET` | `/api/narratives/:id/export?format=html[&palette=N][&seqs=A,B,C][&seqs_mode=except]` | Conversation as a standalone HTML document; `palette` is the device's 🎨 Recolor salt, so foreign speakers wear the hues the operator sees, `seqs` is the chat's message selection, and `seqs_mode=except` exports its complement — see [Export Scope](export-scope.md) |
@@ -36,9 +36,9 @@ until someone lists it. An agent also may not revive or prompt a
36
36
  | `GET` | `/api/folds?archived=1` | List registered fold directories (includes brane_id; archived hidden unless asked) |
37
37
  | `POST` | `/api/folds` | Register a fold (`{ path, brane_id? }`) — idempotent; see [Folds API](#folds-api) for who may register what |
38
38
  | `PATCH` | `/api/folds/archive` | Soft-hide/unhide a fold (`{ path, archived }`) — never deletes the row |
39
- | `POST` | `/api/spawn` | Spawn agent (`{ agent, fold_path, role?, model?, re?, extra_args? }`) — used by both the web app and `brane spawn` |
39
+ | `POST` | `/api/spawn` | Spawn agent (`{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }`) — used by both the web app and `brane spawn` |
40
40
  | `GET` | `/api/spawn-history` | Recent distinct `launch_args`, most-recent first (spawn dialog quick-fill) |
41
- | `POST` | `/api/run` | Run non-agent command (`{ command, fold_path }`) |
41
+ | `POST` | `/api/run` | Run non-agent command (`{ command, fold_path, terminal_size? }`) |
42
42
  | `GET` | `/api/run-history` | Commands utility sessions have run before (by basename), most-recent first — the spawn dialog's Utility list; derived from `narratives`, nothing stored |
43
43
  | `POST` | `/api/revive/:nid` | Revive a dead agent session (`{ prompt?, no_prompt?, agent? }`) — see [Revive API](#revive-api) |
44
44
  | `POST` | `/api/search` | Semantic search over narrative history (`{ query, limit?, brane_ids? }`) |
@@ -85,7 +85,7 @@ Control WebSocket commands (client → server):
85
85
 
86
86
  ## Spawn API
87
87
 
88
- `POST /api/spawn` accepts `{ agent, fold_path, role?, model?, re?, extra_args? }` and returns the new narrative object with `mcp_connected` status. The fold picker modal lets the operator choose which registered fold to spawn into.
88
+ `POST /api/spawn` accepts `{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }` and returns the new narrative object with `mcp_connected` status. `terminal_size` (`{ cols, rows }`) is the grid the requesting viewer's terminal pane last measured; the session starts at it rather than at the last size applied to any session, so its first screen is not redrawn through a resize — see [Launch residue in scrollback](terminal-mirroring.md#launch-residue-in-scrollback). `/api/run` takes it too. The fold picker modal lets the operator choose which registered fold to spawn into. The dialog's Agent list is the built-in adapters plus an "Other…" entry that takes any agent name, as `brane spawn <agent>` does: the daemon accepts any `[A-Za-z0-9_-]+` name it can find with `which`, and an agent without an adapter gets tmux, MCP config and mirroring but no session resumption. What is typed there splits like the args box, so the first word is the agent and the rest lead its `extra_args`.
89
89
 
90
90
  This is the **only** spawn path. The web UI calls it, and so does `brane spawn` — the CLI resolves a daemon (starting one if needed), POSTs here with `fold_path` set from `cwd` and `-- <args>` as `extra_args`, and then does only terminal-shaped work with the response: `--meld`'s QR code and `--attach`. It does not open the database or create tmux sessions itself, so tmux bootstrap (`ensureTmux`), terminal sizing, and MCP config writing happen once, daemon-side, identically for both callers. Two consequences follow from that and are intended: the agent binary is resolved against the *daemon's* `PATH`, and MCP config files point at the checkout the *daemon* runs from — not the CLI you invoked.
91
91
 
package/docs/mcp.md CHANGED
@@ -66,4 +66,4 @@ Core tools available via MCP:
66
66
 
67
67
  Narratives have persistent `summary` and `title` columns. Agents set them via the `meta` parameter on `narrate`. The daemon broadcasts metadata changes over the control WebSocket as `type: "meta"` messages. The Expo app renders the summary as a subtitle below the narrative ID.
68
68
 
69
- Narratives can be **archived** via `PATCH /api/narratives/:id/archive`. Archived narratives are hidden from the default list but preserved in the database.
69
+ Narratives can be **archived** via `PATCH /api/narratives/:id/archive`. Archived narratives are hidden from the default list but preserved in the database. Only a narrative whose session is over can be archived: the daemon answers 409 while its status isn't `ended`, an agent is on its bridge, or its tmux session is up (`src/daemon/archive-gate.ts`), so archiving can't hide a running agent. Unarchiving is never refused.
package/docs/releasing.md CHANGED
@@ -6,15 +6,15 @@ with a short-lived OIDC token ([npm trusted
6
6
  publishing](https://docs.npmjs.com/trusted-publishers)). **Agents never run
7
7
  `npm publish`**, from a checkout or anywhere else.
8
8
 
9
- Published versions carry **no provenance attestation**, and `npm publish` is
10
- not passed `--provenance`. npm will only attest a build from a public
11
- repository, and this one stays private until brane is announced: its history
12
- is a place to experiment, with invited collaborators or without, and it goes
13
- public before any public promotion. With the flag, the publish step would fail
14
- on a 422 every time. Without it, trusted publishing leaves attestation out for
15
- a private repository and turns it on by itself for a public one, so the first
16
- release after the repo goes public carries provenance with no workflow
17
- change.
9
+ `npm publish` is not passed `--provenance`, and needs no flag for it.
10
+ Trusted publishing attests a build by itself when the repository is public
11
+ and leaves attestation out when it is private, where `--provenance` would
12
+ fail the publish on a 422. 0.3.0 and 0.3.1 were published while the
13
+ repository was still private, so they carry **no provenance attestation**;
14
+ every release after the repository goes public will, with no workflow
15
+ change. Check `gh repo view --json visibility` before a release's notes
16
+ say which it is: the 0.3.1 CHANGELOG entry in its tarball claims
17
+ provenance it does not have.
18
18
 
19
19
  The trigger is a **published GitHub release**, not a tag push, so the release
20
20
  notes and the publish are one act and a stray tag cannot publish anything.
@@ -51,3 +51,56 @@ where it was.
51
51
  `src/cli.ts` reads its version from `package.json` at startup, so
52
52
  `brane --version` reports whatever was published rather than a string that
53
53
  has to be bumped by hand.
54
+
55
+ ## The app ships with the release
56
+
57
+ The shared app (the `brane` worker in `worker/wrangler.toml`, at
58
+ `app_domain`'s default host) serves the web app of the version npm calls
59
+ `latest`, and nothing else. A merge to `main` deploys nothing: `ci.yml` only
60
+ tests.
61
+
62
+ - After an ordinary release publishes, `release.yml`'s `deploy-app` job runs
63
+ `.github/workflows/deploy-app.yml` with the release's version. A pre-release
64
+ deploys nothing.
65
+ - `deploy-app.yml` does not build. It waits until the registry has the version
66
+ and names it `latest` (right after a publish either can lag by up to a
67
+ minute), unpacks `npm pack <name>@<version>`, checks the tarball's name,
68
+ version and `expo/dist/index.html`, runs the precache check, and
69
+ `wrangler deploy`s that `expo/dist`. The shared app is therefore the same
70
+ bytes every daemon of that version installs and serves itself, by
71
+ construction rather than by a second build that is supposed to match.
72
+ - It is its own job because a publish cannot be undone and a deploy can be
73
+ repeated: a Cloudflare failure is a re-run of that job, never a second
74
+ release.
75
+ - **Redeploy or roll back** by dispatching `deploy-app.yml` from the Actions
76
+ tab with a version. It refuses a version that is not `latest`, so a rollback
77
+ is `npm dist-tag add brane@<version> latest` (the operator's, like everything
78
+ on the registry) and then the dispatch. The dispatch checks out the branch it
79
+ is run from, so an old version's bundle is deployed with *that branch's*
80
+ `wrangler.toml` and precache check, not its tag's: the route is where the
81
+ app lives whatever its version. A version that isn't `latest` is refused
82
+ only after the registry-lag retries, about four minutes in.
83
+ - **A `wrangler.toml` change is first exercised by the next release's
84
+ deploy.** A merge deploys nothing and `wrangler deploy --dry-run` doesn't
85
+ talk to Cloudflare, so watch that release's `deploy-app` job. Declaring the
86
+ custom domain needs zone permissions on `CLOUDFLARE_API_TOKEN`, not only
87
+ Workers ones; if the job fails there, the fix is the token and a re-run, and
88
+ the publish before it stands.
89
+
90
+ **Staging is Isolate.** A daemon running `main` (after `/rrr`) serves its own
91
+ bundle, built from that checkout, so Isolate on it shows everything merged
92
+ since the last release, together, before a release promotes it to the shared
93
+ app. Every daemon serves its own version's app the same way, which is also
94
+ what an older install has: `brane meld --local`, or Isolate, gives it the app
95
+ it shipped with. Between releases, then, a daemon running `main` is newer than
96
+ the shared app, the ordinary case of an older app on a newer daemon.
97
+
98
+ The deploy names no host and no package: the package name comes from
99
+ `package.json`, and the route is declared in `worker/wrangler.toml` as a
100
+ custom domain, with `workers_dev = false` so it is the only place the shared
101
+ app is served and the Cloudflare dashboard holds nothing the repository
102
+ doesn't. That route is `CONFIG_DEFAULTS.app_domain`, the host `brane meld`
103
+ points at, and `src/test/wrangler-route.test.ts` keeps the two equal. A fork
104
+ that publishes under its own name and serves its own app changes `name` in
105
+ `package.json`, `name` and the route in `wrangler.toml`, and
106
+ `CONFIG_DEFAULTS.app_domain`.
@@ -209,3 +209,35 @@ left a hairline between lit rows. Each row starts on a fractional pixel, and
209
209
  each layer anti-aliased its own edge onto the shared boundary pixel. A
210
210
  full-cell gradient with hard stops (linear for bars, conic for quadrants)
211
211
  has the span's own edges, and the browser snaps those to whole pixels.
212
+
213
+ ## Launch residue in scrollback
214
+
215
+ The mirror shows the pane's whole scrollback (`capture-pane -S -`), so
216
+ anything a TUI drew and abandoned before the session settled is on screen for
217
+ good. The case that prompted this section: a new fold opened under a stack of
218
+ yellow `────` rules and three copies of "Accessing workspace". It was Claude
219
+ Code's folder-trust dialog, redrawn at each width the window passed through:
220
+ first created at `lastTerminalSize` (the last size applied to *any* session),
221
+ then fitted to the viewer. Every stale frame went up into history, reflowed,
222
+ its full-width rules now wider than the pane. The live screen was clean; the
223
+ input box was pushed off the bottom only because the history above it was
224
+ tall and wide.
225
+
226
+ Two things address it, and neither touches the live screen:
227
+
228
+ - **Start at the viewer's size.** The app remembers the grid its terminal pane
229
+ last measured (`lastTerminalSize()`, `expo/lib/terminal.ts`) and sends it as
230
+ `terminal_size` on `POST /api/spawn` and `POST /api/run`. The daemon starts
231
+ the tmux session at that grid when it is usable (`parseTerminalSize`,
232
+ `src/tmux.ts`, pinned by `src/test/terminal-size.test.ts`) and falls back to
233
+ `lastTerminalSize` otherwise, which is still what `brane spawn` from a shell
234
+ gets. A session that launches at the size it is watched at isn't resized
235
+ through a TUI's first screen.
236
+ - **Clear scrollback, on request.** The 🦾 menu's last row, `Clear 🧽`, sends
237
+ `{ type: "clear_history" }` on the terminal socket; the daemon runs `tmux
238
+ clear-history` on the pane and forces a capture. The client trims to the
239
+ frame's `totalLines`, so the stale lines go with the next frame. It discards
240
+ history, so it is only ever the operator's explicit action, never automatic.
241
+
242
+ A fold Claude has never been trusted in still shows the dialog once. Accepting
243
+ it on the operator's behalf was considered and left for the operator to decide.
@@ -208,8 +208,10 @@ Cline's per-session files) meant no harness's plumbing had to change.
208
208
  nothing. A connected external client (no tmux session) is not adopted,
209
209
  and lands on a fresh `external-*` narrative once, at the upgrade.
210
210
  Adopted agent rows don't trigger the plaintext-token warning.
211
- - **Archiving revokes** a narrative's agent token when its session is not
212
- running. Unarchiving brings nothing back; a revive mints a new one.
211
+ - **Archiving revokes** a narrative's agent token. Only an ended narrative
212
+ can be archived (the daemon answers 409 otherwise; see
213
+ `src/daemon/archive-gate.ts`), so there is never a live session to cut
214
+ off. Unarchiving brings nothing back; a revive mints a new one.
213
215
  - **An external goes with its dialer, always.** An external's token
214
216
  records the token that dialed it (`minted_by`); it is that token's
215
217
  credential in another form, since one `/ws/mcp` dial mints it, so
@@ -319,6 +321,16 @@ device. Less obvious than it looks:
319
321
  both prefer the daemon's Unix socket, recorded as `daemon.sock` since it
320
322
  has no address (`connectionLabel`); one token used by any of them is not
321
323
  a copy.
324
+ - **Which app a device runs is recorded too** (`app_origin`,
325
+ `last_app_origin`): the request's `Origin`, first and latest, shown as
326
+ "via main.brane.organelle.dev", or "via mac.tail1234.ts.net:47443" for a
327
+ daemon's own bundle. Browsers omit `Origin` on same-origin GETs, so the
328
+ first request that names a device often cannot name its app; the store
329
+ writes the first `Origin` it is given, which in practice is the control
330
+ socket's upgrade, and never overwrites it. It is a label, not a gate:
331
+ the token is the credential and the daemon accepts any origin
332
+ (`SECURITY.md`, Authentication), which is what lets a staging hostname
333
+ or a fork's deployment front the same daemon with nothing to configure.
322
334
  - **The address is the only field a token holder cannot choose.** The
323
335
  User-Agent and the label are both free text from whoever holds the
324
336
  token. So the app never uses the label as a row's title — a row with a
@@ -404,10 +416,18 @@ should guard revoking, as it will guard adding a device, is open: see
404
416
  - A revoke does not cut a streaming response already in flight (the Files
405
417
  search and `POST /api/absorb`, both NDJSON). Each is bounded work, not a
406
418
  listener, and ends on its own.
407
- - The WebSocket origin check compares only the port for same-origin and
408
- accepts any `*.ts.net` host. Nothing rides on it today, since every
409
- request must present a token; a passkey verifier will need its own exact
410
- origin list.
419
+ - There is no origin check, by design: every request must present a token,
420
+ and the origin it came from is recorded on the token rather than judged.
421
+ A passkey verifier will need its own exact origin list
422
+ ([Passkeys](passkeys.md)); the origins recorded per token are where one
423
+ would come from.
424
+ - A token is not bound to the origin it was first used from. Pinning it
425
+ (refusing a different `Origin` afterwards) was considered and left out:
426
+ Isolate carries the same token between the hosted shell and a daemon's
427
+ own bundle ([View URLs](view-urls.md)), so the daemon's own origin would need
428
+ an exemption, and since `?token=` is taken on any method a pin would
429
+ protect only reads. If a phished link ever needs more than revocation,
430
+ this is the lever (claude-wh3d2hz2:20, point 4).
411
431
  - Fold registration treats a loopback peer as the operator's shell, so a
412
432
  forwarder to 127.0.0.1 (`tailscale serve`, an SSH tunnel) would make a
413
433
  remote caller look local.