brane 0.1.1 → 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.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +360 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1101 -0
- package/dist/commands/token.js +154 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6353 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +643 -0
- package/dist/db/tokens.js +624 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +84 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +216 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +56 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +299 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +143 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +150 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +128 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +646 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +627 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +104 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +53 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# The Request Layer
|
|
2
|
+
|
|
3
|
+
Every call from the app to a daemon is **cross-origin** — the bundle is served
|
|
4
|
+
from `brane.organelle.dev` (or another daemon's copy of it) while the daemon
|
|
5
|
+
answers on its own Tailscale host. That fact is free on a LAN and decisive on
|
|
6
|
+
a bad link, because CORS makes the browser *ask permission before asking the
|
|
7
|
+
question*: a request that is not "simple" gets a preflight `OPTIONS` and may
|
|
8
|
+
not be sent until the answer comes back.
|
|
9
|
+
|
|
10
|
+
The cost was measured rather than assumed. On in-flight satellite wifi at
|
|
11
|
+
**741 ms RTT** (`tailscale ping`, direct path — not a DERP relay and not the
|
|
12
|
+
travel router), the app's four opening requests — narratives, folds, branes,
|
|
13
|
+
protocols — were each aborted at exactly **10.00 s having transferred zero
|
|
14
|
+
bytes**, every one still waiting on a preflight that had never returned.
|
|
15
|
+
Nothing was slow. Nothing had started.
|
|
16
|
+
|
|
17
|
+
Three things were wrong, and only the third is the one you would guess:
|
|
18
|
+
|
|
19
|
+
| | Was | Is |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Bodiless requests | `Authorization` + `Content-Type: application/json` → preflight | no headers at all, token in `?token=` → **no preflight** |
|
|
22
|
+
| Preflight caching | no `Access-Control-Max-Age`; Chrome's default is **5 s** | `86400`, so a bodied request's preflight is a daily cost |
|
|
23
|
+
| Timeout | 10 s, no retry | 60 s, with backoff on transport failures |
|
|
24
|
+
|
|
25
|
+
`src/shared/api-request.ts` holds the rules, window-free so they can be tested
|
|
26
|
+
without a browser — the same arrangement as `view-url.ts`, and for a sharper
|
|
27
|
+
reason. **Breaking these rules is invisible on a fast network.** Put an
|
|
28
|
+
`Authorization` header back on a GET and every local test still passes while
|
|
29
|
+
the app becomes unusable at altitude, so the invariant is pinned by
|
|
30
|
+
`src/test/api-request.test.ts`, which reads `expo/lib/api.ts` and asserts that
|
|
31
|
+
no request sets that header and that `JSON_HEADERS` appears only where a
|
|
32
|
+
`body:` does.
|
|
33
|
+
|
|
34
|
+
| Piece | Does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `requiresPreflight()` | The CORS cost model itself — safelisted methods, headers, and content types |
|
|
37
|
+
| `withToken()` | Token into the query string, joining an existing one with `&` and staying in front of a `#` |
|
|
38
|
+
| `isRetryableMethod()`, `isRetryableError()` | What may be replayed |
|
|
39
|
+
| `DEFAULT_TIMEOUT_MS` | 60 s |
|
|
40
|
+
| `setCorsHeaders()` | `src/daemon/server.ts`; exported for `src/test/cors-preflight.test.ts` |
|
|
41
|
+
|
|
42
|
+
Three decisions are less obvious than they look:
|
|
43
|
+
|
|
44
|
+
**The token moves to the URL, and that is not a downgrade.** It arrived in a
|
|
45
|
+
URL — the meld link that opens the app carries it (in the fragment, since the
|
|
46
|
+
[View URLs](view-urls.md) change; before that, as `?token=`). These requests
|
|
47
|
+
address the operator's own daemon, so no third party sees a `Referer` for
|
|
48
|
+
them, and the query string here goes to the daemon and nowhere else. The daemon
|
|
49
|
+
has always accepted the parameter, checked immediately after the `Bearer`
|
|
50
|
+
header; nothing on the server needed to change to allow it.
|
|
51
|
+
|
|
52
|
+
**Bodied requests still preflight, deliberately.** `application/json` is not a
|
|
53
|
+
CORS-safelisted content type, and the way to dodge that is to label JSON as
|
|
54
|
+
`text/plain` — a Content-Type that lies, and a trap for whoever reads the code
|
|
55
|
+
next. The app reads far more than it writes, so the honest header is cheap;
|
|
56
|
+
`Access-Control-Max-Age` is what makes it rare.
|
|
57
|
+
|
|
58
|
+
**Only idempotent requests retry.** A timed-out `POST /api/spawn` is genuinely
|
|
59
|
+
ambiguous — the daemon may have spawned the agent and merely been too slow to
|
|
60
|
+
say so. Replaying that spawns a second one. An HTTP error is likewise an
|
|
61
|
+
*answer* and never retried: a 404 will not change on a second ask, and a 500
|
|
62
|
+
may have had effects. Only a request that provably never landed is safe to
|
|
63
|
+
send again.
|
|
64
|
+
|
|
65
|
+
**HTTP/2 would help and is not available.** One connection instead of Chrome's
|
|
66
|
+
six means one TLS handshake instead of six, which is worth real time at this
|
|
67
|
+
RTT. But all three WebSocket endpoints ride the HTTP/1.1 `Upgrade` mechanism,
|
|
68
|
+
which HTTP/2 replaces with extended CONNECT (RFC 8441) — and `ws` does not
|
|
69
|
+
implement the server side. ALPN is negotiated per origin, not per request, so
|
|
70
|
+
advertising h2 would take the control channel, the MCP bridge and terminal
|
|
71
|
+
mirroring down with it. It stays on the table as its own change, gated on a
|
|
72
|
+
WebSocket transport that survives it.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# The Selection Store
|
|
2
|
+
|
|
3
|
+
The chat's message selection is a store outside React state
|
|
4
|
+
(`MessageSelection`, `src/shared/message-selection.ts`), and the two hooks
|
|
5
|
+
that bind it (`expo/lib/selection.ts`) are the only React in it. The screen
|
|
6
|
+
holds no selection state at all.
|
|
7
|
+
|
|
8
|
+
It was `useState<Set<number>>`, and that shape had both a cost and a bug.
|
|
9
|
+
|
|
10
|
+
**The cost is a cascade, not a copy.** A tap replaced the `Set`, which
|
|
11
|
+
re-rendered the `[nid]` screen, which rebuilt `handleTimestampPress`
|
|
12
|
+
(dependency: `selectedSeqs`), which rebuilt `renderItem`, which handed every
|
|
13
|
+
mounted `MessageBubble` a new callback prop — so `memo` missed on all of
|
|
14
|
+
them. Two bubbles changed color; two hundred rows and the screen above them
|
|
15
|
+
re-rendered.
|
|
16
|
+
|
|
17
|
+
**The bug is a stale closure, and it drops taps.** Two taps in the same
|
|
18
|
+
React batch — a fast double-tap, a trackpad — and the second computed from
|
|
19
|
+
the set the first had already replaced, silently undoing it. Measured in a
|
|
20
|
+
headless browser against a real daemon: on `origin/main` two rapid taps left
|
|
21
|
+
**one** message selected, on the store both. A store is single-writer and
|
|
22
|
+
authoritative; a closure over a snapshot is neither.
|
|
23
|
+
|
|
24
|
+
| Subscription | Who | Wakes when |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `subscribeSeq(seq)` | each `MessageBubble`, via `useIsSelected` | *its* seq is selected or deselected |
|
|
27
|
+
| `subscribe()` | the header (Export's count), the composer (`re`) | any change — one component each, which is the point |
|
|
28
|
+
| none | the screen, `renderItem`, Export's press handler (`getSelected()` at press time) | never |
|
|
29
|
+
|
|
30
|
+
Three details are load-bearing:
|
|
31
|
+
|
|
32
|
+
**A clear notifies what was cleared, not everyone.** `clear()` wakes exactly
|
|
33
|
+
the rows that were selected and returns them; an empty clear is not a change
|
|
34
|
+
and wakes nobody. `replace()` wakes the symmetric difference. A broadcast
|
|
35
|
+
would put the whole list back on the path this exists to keep it off.
|
|
36
|
+
|
|
37
|
+
**The snapshot's identity is stable.** `useSyncExternalStore` compares
|
|
38
|
+
snapshots by identity, so a getter that allocates re-renders forever. The
|
|
39
|
+
ascending array is rebuilt on change and only on change, and an empty
|
|
40
|
+
selection is one frozen shared array.
|
|
41
|
+
|
|
42
|
+
**Buckets are dropped when their last listener unsubscribes.** Rows unmount
|
|
43
|
+
as the list scrolls; without that, the per-seq map would grow by every
|
|
44
|
+
message ever scrolled past.
|
|
45
|
+
|
|
46
|
+
Pure and in `shared/` for the usual reason: `src/test/message-selection.test.ts`
|
|
47
|
+
checks the notification rules without a browser, and
|
|
48
|
+
`src/test/selection-wiring.test.ts` reads `expo/app/[nid].tsx` to hold the
|
|
49
|
+
screen to the wiring — both spellings select messages, so only a source lint
|
|
50
|
+
can keep the old one from coming back.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Session Affinity
|
|
2
|
+
|
|
3
|
+
A `claude` narrative whose Claude Code talks to Fireworks (an
|
|
4
|
+
`ANTHROPIC_BASE_URL` on a `fireworks.ai` host) sends `X-Session-Affinity:
|
|
5
|
+
<nid>` with its LLM requests. `ensureSessionAffinityConfig`
|
|
6
|
+
(`src/session-affinity.ts`) runs on every spawn and revive, from `doSpawn`,
|
|
7
|
+
`prepareAgentLaunch`, and the same-agent revive path in `src/daemon/server.ts`,
|
|
8
|
+
and returns `{ args, env }`: CLI args for the agent and an env prefix for the
|
|
9
|
+
tmux command. Fireworks uses the header to keep a session on one replica, and
|
|
10
|
+
its per-replica KV prefix cache only stays warm that way. The nid survives
|
|
11
|
+
revives, so the affinity does too. Every other claude spawn launches exactly as
|
|
12
|
+
before. Opt out with `brane config session-affinity.enabled false`.
|
|
13
|
+
|
|
14
|
+
The header name and its alternatives come from Fireworks' prompt-caching guide:
|
|
15
|
+
`x-session-affinity` (header), `prompt_cache_key` (request body, the
|
|
16
|
+
preferred field), `user` (body, fallback), and `x-multi-turn-session-id`
|
|
17
|
+
(agent traffic). Claude Code can't set a body field (it has no config surface
|
|
18
|
+
for one), so the header is what it gets. Cache hits show up as
|
|
19
|
+
`usage.cache_read_input_tokens` on the Anthropic-compatible `/v1/messages`
|
|
20
|
+
path, which the cost ledger already records, and in the
|
|
21
|
+
`fireworks-cached-prompt-tokens` response header.
|
|
22
|
+
|
|
23
|
+
Claude Code reads `ANTHROPIC_CUSTOM_HEADERS` (newline-separated
|
|
24
|
+
`Name: Value`) from its environment. Where that value comes from decides how
|
|
25
|
+
brane adds to it:
|
|
26
|
+
|
|
27
|
+
**A settings file defines it.** A settings file's `env` block is written
|
|
28
|
+
*over* the inherited shell value, and settings `env` values are not
|
|
29
|
+
`${VAR}`-expanded (verified against claude 2.1.278). So nothing in the shell
|
|
30
|
+
can add to it, and a shared fold file could only hold one literal nid. The
|
|
31
|
+
workaround is `claude --settings <path>`, which outranks every settings file.
|
|
32
|
+
brane writes one file per narrative at `~/.brane/session-affinity/<nid>.json`
|
|
33
|
+
containing one key: the value from the highest-precedence file (local >
|
|
34
|
+
project > user), with any stale affinity line stripped (case-insensitive
|
|
35
|
+
name match, the same parse Claude Code performs) and the affinity line
|
|
36
|
+
appended. That value includes whatever credentials the operator's headers
|
|
37
|
+
carry, so the file is `0600` in a `0700` directory, re-tightened on every
|
|
38
|
+
write. The operator's own settings files are never written. This was verified
|
|
39
|
+
end to end against a header-logging listener: the nid arrives, the fold's other
|
|
40
|
+
lines come through, and a hand-added stale affinity line is replaced.
|
|
41
|
+
|
|
42
|
+
**Nothing defines it.** Then Claude Code uses whatever the *session's* shell
|
|
43
|
+
inherits, which comes from the tmux server, and the tmux server need not share
|
|
44
|
+
the daemon's environment. Reading the daemon's `process.env` into a
|
|
45
|
+
`--settings` file would *replace* headers the session had and the daemon
|
|
46
|
+
didn't. The first draft did exactly that, and an `X-Shell` header exported
|
|
47
|
+
where tmux started disappeared from the wire. So the header goes in the tmux
|
|
48
|
+
command's env prefix instead (`shellAffinityEnv`), and the session's own shell
|
|
49
|
+
appends it to whatever it inherited. The Fireworks check does read the
|
|
50
|
+
daemon's env as a last resort for `ANTHROPIC_BASE_URL`. If that misses a URL
|
|
51
|
+
only the session has, the cost is affinity and nothing more.
|
|
52
|
+
|
|
53
|
+
Managed settings still outrank `--settings`; delivering through them is out of
|
|
54
|
+
scope.
|
|
55
|
+
|
|
56
|
+
**Crush is deliberately left out.** It already sends `x-session-affinity` and
|
|
57
|
+
`x-session-id` (a hash of its own session id), and that value wins over any
|
|
58
|
+
configured `extra_headers`. A static `$BRANE_NARRATIVE_ID` entry was tried and
|
|
59
|
+
never reached the wire. Crush's key changes when a narrative is revived,
|
|
60
|
+
because crush starts a new session. Other harnesses have no observed Fireworks
|
|
61
|
+
traffic.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Terminal Mirroring
|
|
2
|
+
|
|
3
|
+
The terminal tab is a **mirror, not an attach**, and that is a design position
|
|
4
|
+
rather than an unfinished one. `capture-pane` is a *read* of shared state, so
|
|
5
|
+
any number of viewers watch one 100 ms capture loop with no tmux client
|
|
6
|
+
attaching, no attach/detach churn, and sizing that is brane's policy
|
|
7
|
+
(`fitWindowToViewers` — smallest active viewer wins, so a phone joining can
|
|
8
|
+
never clip a TUI's bottom rows). Idle costs almost nothing: `content ===
|
|
9
|
+
cap.lastContent` returns before a *diff* is sent, so a session waiting on
|
|
10
|
+
`listen` produces no repaints and no client rAF work — only the resync
|
|
11
|
+
heartbeat below. And because the frame is text rendered into DOM lines,
|
|
12
|
+
selection, ⌘F and copy are the platform's rather than ours — an emulator would
|
|
13
|
+
have us re-implement them.
|
|
14
|
+
|
|
15
|
+
The mirror's own consequence is that **anything not in the pane's text has to
|
|
16
|
+
be sent explicitly**. There is exactly one such thing, and it is the cursor.
|
|
17
|
+
|
|
18
|
+
## The resync heartbeat
|
|
19
|
+
|
|
20
|
+
Sending a diff only when the content changes is right, but it rests on an
|
|
21
|
+
assumption the protocol never checks: that a frame the daemon *sent* is a frame
|
|
22
|
+
the browser *rendered*. `c.lastContent` records the former and is read as
|
|
23
|
+
though it meant the latter. Every way a browser can drop a frame — a
|
|
24
|
+
backgrounded tab whose `requestAnimationFrame` never ran, a socket that
|
|
25
|
+
reconnected into a wiped `<pre>`, an early return in `applyFrame` — therefore
|
|
26
|
+
strands that viewer, and because the pane then goes quiet, *nothing ever
|
|
27
|
+
corrects it*. The only repair is a page reload, which is not a repair the
|
|
28
|
+
operator should have to know about. Observed in the wild as a phone showing an
|
|
29
|
+
entirely black terminal while the daemon's socket was demonstrably serving
|
|
30
|
+
frames the whole time.
|
|
31
|
+
|
|
32
|
+
`TERMINAL_RESYNC_MS` (2 s) closes the class without an ack protocol: a viewer
|
|
33
|
+
that hasn't been sent *content* in that long is re-sent the current frame,
|
|
34
|
+
whatever the reason it fell behind. Any dropped frame self-heals within one
|
|
35
|
+
interval.
|
|
36
|
+
|
|
37
|
+
| Piece | Where | Does |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `lastSentAt` | `TerminalClient` | When this viewer was last sent content. Cursor-only messages don't touch it — they carry none |
|
|
40
|
+
| `isResyncDue()` | `src/daemon/server.ts` | The boundary, pure, so `src/test/terminal-resync.test.ts` can pin it |
|
|
41
|
+
| `resyncStaleViewers()` | the capture loop | Runs on **both** paths — see below |
|
|
42
|
+
|
|
43
|
+
Three details are load-bearing:
|
|
44
|
+
|
|
45
|
+
**Both paths, not just the idle one.** The obvious place is the `content ===
|
|
46
|
+
cap.lastContent` early return. The other is after the per-client fan-out, where
|
|
47
|
+
a viewer skipped by `content === c.lastContent` is *exactly* the stranded case
|
|
48
|
+
— its `lastContent` says it has this screen and its DOM says otherwise.
|
|
49
|
+
|
|
50
|
+
**`lastSentAt: 0` is the "due now" sentinel.** A fresh socket and a `refresh`
|
|
51
|
+
that just wiped the client's `<pre>` both set it, and against an epoch clock
|
|
52
|
+
`now - 0` clears any threshold — so those viewers get content on the very next
|
|
53
|
+
tick rather than waiting out an interval.
|
|
54
|
+
|
|
55
|
+
**Idle stays cheap.** One ~1.6 KB message per viewer every 2 s, serialized once
|
|
56
|
+
per tick and shared across viewers. On the client it is a full frame whose
|
|
57
|
+
per-line diff finds nothing changed, so it writes no DOM. A busy pane already
|
|
58
|
+
sends more often than this and is unaffected.
|
|
59
|
+
|
|
60
|
+
## The stranded rAF
|
|
61
|
+
|
|
62
|
+
The heartbeat guarantees the daemon keeps *offering* content. It does not
|
|
63
|
+
guarantee the client can *accept* it, and there was one way it could not.
|
|
64
|
+
|
|
65
|
+
Frames are coalesced through a single `requestAnimationFrame`, with
|
|
66
|
+
`rafIdRef !== null` standing for "a drain is already coming". That reading
|
|
67
|
+
holds only while the browser still intends to run the callback. **iOS Safari
|
|
68
|
+
freezes a backgrounded tab and can drop the scheduled callback outright** — an
|
|
69
|
+
app switch, a screen lock, an incoming call. The page resumes holding a
|
|
70
|
+
non-null id for a drain that will never fire, and from that moment the guard is
|
|
71
|
+
permanently closed: every later frame is stored in `pendingRafFrameRef` and
|
|
72
|
+
none is applied. Unmounting is the only thing that clears it, which is to say a
|
|
73
|
+
page reload. A pane that never got its first frame this way is not stale but
|
|
74
|
+
*blank*.
|
|
75
|
+
|
|
76
|
+
Note the interaction: **a stranded rAF swallows the heartbeat too**, delivering
|
|
77
|
+
fresh frames every 2 s into a queue nothing drains. The two fixes are not
|
|
78
|
+
alternatives; neither is sufficient alone.
|
|
79
|
+
|
|
80
|
+
| Piece | Does |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `RAF_DRAIN_DEADLINE_MS` (400 ms) | An arriving frame that finds one already waiting this long stops trusting the schedule: cancel the token and drain inline. Generous beside a 16 ms paint on purpose — a liveness backstop, not a latency budget |
|
|
83
|
+
| `visibilitychange` → visible | Clear the stale token, drain anything pending, and `refresh()` the channel |
|
|
84
|
+
|
|
85
|
+
The `visibilitychange` half earns its place because foregrounding is both the
|
|
86
|
+
moment a callback was most likely dropped and the only moment repair is
|
|
87
|
+
possible without waiting: an idle pane's next frame is two seconds out, and the
|
|
88
|
+
`refresh()` is what restates a DOM that went stale for a reason the client
|
|
89
|
+
never observed. It is gated on the pane being active: an inactive one
|
|
90
|
+
has its DOM wiped and refreshed by `cleanRefresh` on activation anyway.
|
|
91
|
+
|
|
92
|
+
**A frame arriving no longer means the pane changed.** That equivalence was
|
|
93
|
+
free before the heartbeat and is what `applyFrame`'s auto-scroll was built on.
|
|
94
|
+
"At bottom" is a 40 px band, so an identical frame every 2 s would yank a
|
|
95
|
+
just-started finger-scroll back down for as long as the reader stayed inside
|
|
96
|
+
that band — a window that recurs forever rather than a one-off, on every
|
|
97
|
+
scrollback pane (alternate-screen panes are `overflow: hidden` and unaffected).
|
|
98
|
+
`applyFrameLines` and `applyAppendLines` therefore **return whether they wrote
|
|
99
|
+
any line node**, and the scroll follows that rather than the frame's arrival. A
|
|
100
|
+
frame that changed nothing has nothing to follow.
|
|
101
|
+
|
|
102
|
+
## The cursor
|
|
103
|
+
|
|
104
|
+
One tmux invocation per tick carries both halves of a frame:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
display-message -p -F "#{cursor_x} #{cursor_y} #{cursor_flag} #{alternate_on} #{pane_height}" \; capture-pane -e -p -S - -E - -t <nid>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Deliberately **one** call. A keystroke landing between a separate cursor query
|
|
111
|
+
and its capture would paint the block a cell away from the character it belongs
|
|
112
|
+
on — the one artifact a cursor cannot have. It also replaced the periodic
|
|
113
|
+
`alternate_on` probe, so the per-session exec count went down.
|
|
114
|
+
|
|
115
|
+
| Piece | Where | Does |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `TERMINAL_STATUS_FORMAT` | `src/daemon/server.ts` | The five fields, all numeric, so the parse cannot be ambiguous |
|
|
118
|
+
| `parseTerminalStatus()` | `src/daemon/server.ts` | Status line → fields, `null` on anything unreadable |
|
|
119
|
+
| `terminalCursorFrom()` | `src/daemon/server.ts` | Pane coordinates → frame coordinates (pure; `src/test/terminal-cursor.test.ts`) |
|
|
120
|
+
| `syncCursor()` | `expo/components/TerminalPane.tsx` | Parks the block on the line it belongs to |
|
|
121
|
+
|
|
122
|
+
Three things are less obvious than they look:
|
|
123
|
+
|
|
124
|
+
**`cursor_y` is pane-relative; a frame is not.** `capture-pane -S - -E -`
|
|
125
|
+
returns `history_size + pane_height` rows, so the visible pane is the *last*
|
|
126
|
+
`pane_height` of them and the cursor's frame line is `rows − pane_height +
|
|
127
|
+
cursor_y`. The pane's trailing blank rows are sent, not trimmed, and the
|
|
128
|
+
client keeps them. They are what puts a cleared screen's prompt (^L, `clear`)
|
|
129
|
+
at the top of the viewer, with the old screen scrolled into history above it,
|
|
130
|
+
as in a real terminal; trimmed, the prompt sat at the bottom, right under the
|
|
131
|
+
output it had cleared. Because the window is fitted to the smallest active
|
|
132
|
+
viewer, the pane always fits, so those blank rows can never fill the view of
|
|
133
|
+
someone scrolled to the bottom.
|
|
134
|
+
|
|
135
|
+
**`cursor_flag` gates rendering.** An application that hides its cursor means
|
|
136
|
+
it — Claude Code while a dialog is up, lazygit throughout — and a block drawn
|
|
137
|
+
anyway is a caret the operator's real terminal doesn't show. Claude Code
|
|
138
|
+
otherwise uses the *real* terminal cursor, so what tmux reports is Claude
|
|
139
|
+
Code's cursor rather than an approximation of it.
|
|
140
|
+
|
|
141
|
+
**A cursor moves when nothing else does.** Walking an arrow key along a shell
|
|
142
|
+
input line repaints not one character, so the frame early-return would send
|
|
143
|
+
nothing at all — precisely when a cursor is worth most. Position changes with
|
|
144
|
+
unchanged content therefore get their own `{type: "cursor"}` message, outside
|
|
145
|
+
the rAF frame coalescer, which exists to batch whole-screen work.
|
|
146
|
+
|
|
147
|
+
Client-side, the block is a span parked **inside the line div it belongs to**,
|
|
148
|
+
not positioned against the pane. It inherits that line's vertical position, so
|
|
149
|
+
scrolling, alternate-screen centering and reflow are all correct for free, and
|
|
150
|
+
the only thing computed here is `col × charWidth`. It paints with
|
|
151
|
+
`mix-blend-mode: difference` over white, inverting the glyph underneath the way
|
|
152
|
+
a real terminal does instead of covering it — which also means the client never
|
|
153
|
+
has to know which character it is sitting on. Solid and blinking when this pane
|
|
154
|
+
is where keystrokes land, hollow when they aren't: across split tabs and
|
|
155
|
+
stacked routes, "which terminal am I typing into" is otherwise a guess.
|
|
156
|
+
|
|
157
|
+
## Paste and copy
|
|
158
|
+
|
|
159
|
+
Paste goes through a tmux **buffer**, never `send-keys`:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
load-buffer -b brane-paste - (text on stdin)
|
|
163
|
+
paste-buffer -b brane-paste -p -t <nid>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`-p` is the point: it wraps the text in bracketed-paste markers *when the
|
|
167
|
+
application asked for them*, so Claude Code sees 40 pasted lines as one paste
|
|
168
|
+
rather than 40 submits, while a shell that doesn't do bracketed paste behaves
|
|
169
|
+
exactly as it would under `tmux attach`. A buffer is also not bounded by argv
|
|
170
|
+
(1 MB ceiling, `MAX_PASTE_BYTES`).
|
|
171
|
+
|
|
172
|
+
Reaching the clipboard differs by platform, and all three paths converge on the
|
|
173
|
+
socket's `paste` message:
|
|
174
|
+
|
|
175
|
+
| Where | Path |
|
|
176
|
+
|---|---|
|
|
177
|
+
| Desktop | A document-level `paste` listener, behind the same two guards as keydown — the terminal is a `<pre>`, never an editable, so the event has no closer home |
|
|
178
|
+
| Any platform chord | `isPasteChord()` makes keydown *step aside* for ⌘V (Mac) / ⌃V, ⌃⇧V (elsewhere). Swallowing ⌃V sent `C-v` to tmux and the browser never fired `paste`; ⌃V on a Mac stays quoted-insert |
|
|
179
|
+
| Touch | The `^V 📋` keycap in the 🦾 controls menu (it took ^R's slot, so the quick bar fits phones narrower than a Max) reads the clipboard directly (a tap is the required gesture; iOS shows its own confirm chip). Mobile fires no `paste` event without a focused editable, so there is nothing for the listener to catch |
|
|
180
|
+
| Touch, refused | A sheet with a real `<textarea>`, so the OS long-press → Paste menu works. The document listener catches that paste and closes the sheet |
|
|
181
|
+
|
|
182
|
+
**Copy-out needs an escape hatch, because the mouse is spoken for.** In
|
|
183
|
+
alternate mode every drag is forwarded as an SGR mouse event, which leaves no
|
|
184
|
+
way to select a line. Holding **Option or Shift** suppresses forwarding and
|
|
185
|
+
lets the browser select normally — the same convention Terminal.app and iTerm
|
|
186
|
+
use when an application has grabbed the mouse — and the modifier also restores
|
|
187
|
+
the context menu with its Copy item.
|
|
188
|
+
|
|
189
|
+
## Block glyphs
|
|
190
|
+
|
|
191
|
+
A mirror line is text in a line box pinned at `line-height: 1.4`, and a
|
|
192
|
+
font's block glyph (`▀ ▄ █`) covers only its own ascent and descent, about
|
|
193
|
+
14 px of a 16.8 px row. So block art was striped with a dark gap on every
|
|
194
|
+
row, and `brane meld`'s half-block QR code came out looking like a barcode
|
|
195
|
+
that scanned badly. The fix is on the client and nowhere else. `meld` prints
|
|
196
|
+
the same bytes, so a real terminal is unaffected.
|
|
197
|
+
|
|
198
|
+
`paintBlockGlyphs()` (`src/shared/block-glyphs.ts`, pinned by
|
|
199
|
+
`src/test/block-glyphs.test.ts`) runs on each changed line after ansi_up. It
|
|
200
|
+
wraps every U+2580–U+259F character in a span one cell wide and one line box
|
|
201
|
+
tall, which paints the glyph as a background in `currentColor`, so SGR
|
|
202
|
+
colors carry through. The character stays in the DOM, hidden with
|
|
203
|
+
`-webkit-text-fill-color`, so copy still yields it. A line with no block
|
|
204
|
+
element costs one regex test.
|
|
205
|
+
|
|
206
|
+
**One full-cell image per glyph, never a sized layer.** The first cut
|
|
207
|
+
painted each rectangle as a layer sized and positioned inside the cell, and
|
|
208
|
+
left a hairline between lit rows. Each row starts on a fractional pixel, and
|
|
209
|
+
each layer anti-aliased its own edge onto the shared boundary pixel. A
|
|
210
|
+
full-cell gradient with hard stops (linear for bars, conic for quadrants)
|
|
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.
|