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
package/docs/mentions.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Mentions
|
|
2
|
+
|
|
3
|
+
`@claude-4c7289d1` **addresses** a narrative; the bare `claude-4c7289d1`
|
|
4
|
+
**cites** one. Until this existed, the only way to get another agent's
|
|
5
|
+
attention was to narrate into its narrative — which puts a message in someone
|
|
6
|
+
else's conversation, permanently, to say "look over here". A mention is the
|
|
7
|
+
quiet instrument: it leaves the other log untouched and points at what you
|
|
8
|
+
wrote in yours.
|
|
9
|
+
|
|
10
|
+
That requirement is also the design's whole justification. A mention is
|
|
11
|
+
deliberately **not an event**:
|
|
12
|
+
|
|
13
|
+
| | ledger row | synthetic event |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| Writes into the recipient's log | no — *the point* | yes |
|
|
16
|
+
| `nid:seq` permalink space | untouched | diluted (the `tool_uses` argument) |
|
|
17
|
+
| Re-scan / rebuild | idempotent via `UNIQUE` | impossible |
|
|
18
|
+
| Recursion when the copy quotes the handle | can't happen | needs a guard |
|
|
19
|
+
|
|
20
|
+
The cost is that delivery isn't free: `checkForEvents` returns `null` unless an
|
|
21
|
+
`events` row appeared, so a ledger row alone would wake a parked `listen` that
|
|
22
|
+
finds nothing and parks again — and with `LISTEN_TIMEOUT_BY_AGENT.claude` at
|
|
23
|
+
3400s, "discovered on the next listen" means up to 56 minutes. Mentions
|
|
24
|
+
therefore have their own resolve predicate, threaded through `PendingListen`.
|
|
25
|
+
|
|
26
|
+
| Piece | Where | Does |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `mentionSpec` / `collectMentionSpec` | `src/shared/markdown-mentions.ts` | The one pattern. No bare npm imports, so metro needs no alias |
|
|
29
|
+
| `extractMentions()` | `src/mentions-scan.ts` | The daemon-side walk — the renderer with its output thrown away |
|
|
30
|
+
| `mentions` table | `src/db/schema.ts` | The row, and what became of it |
|
|
31
|
+
| `src/db/mentions.ts` | | record / pending / ack / annotate / dismiss |
|
|
32
|
+
| `recordEventMentions()` | `src/daemon/server.ts` | The scan and the insert |
|
|
33
|
+
| `announceEvent()` | `src/daemon/server.ts` | The one order: record, *then* wake. Every conversational append site calls it |
|
|
34
|
+
| `mentionReceipt()` | `src/daemon/server.ts` | Ack, annotate, report — in that order |
|
|
35
|
+
|
|
36
|
+
Six things are less obvious than they look:
|
|
37
|
+
|
|
38
|
+
**One parse, never a second scanner.** Recording runs the *same* mdast walk and
|
|
39
|
+
the *same* spec as the renderer, so a handle fires a notification **iff** the
|
|
40
|
+
operator sees it as a mention. A regex over `content` would be the parallel
|
|
41
|
+
scanner the [Rendering Warnings](rendering-warnings.md) section forbids, and its
|
|
42
|
+
failure is worse here because it is invisible: a pasted log with a handle in a
|
|
43
|
+
fenced block shows as inert code while silently waking an agent. This is also
|
|
44
|
+
why `mdast-util-from-markdown` and `mdast-util-gfm-autolink-literal` are
|
|
45
|
+
`dependencies` — as devDependencies they worked in the checkout and would have
|
|
46
|
+
thrown on first `narrate` from a published tarball.
|
|
47
|
+
|
|
48
|
+
**Order: mentions, then citations.** Each mdast extension's links are opaque to
|
|
49
|
+
the next, so `narrativeIdLinks` running first consumes the id and strands the
|
|
50
|
+
`@` — which is exactly what brane did before mentions existed. `nidSpec` now
|
|
51
|
+
carries `boundary: before => before !== "@"`, so the two **must ship
|
|
52
|
+
together**: with the veto and without `mentionLinks`, an `@`-prefixed id stops
|
|
53
|
+
rendering as a link at all.
|
|
54
|
+
|
|
55
|
+
**Two marks, per row, not per narrative.** `sent_at` governs the wake — set on
|
|
56
|
+
delivery, so a mention wakes an agent exactly once. `seen_at` governs the
|
|
57
|
+
reminder — set only by evidence of attention, so a delivered-but-unread mention
|
|
58
|
+
keeps reappearing. They must be separate: if the only thing clearing a mention
|
|
59
|
+
were acknowledgment, an agent that never acked would get a `listen` returning
|
|
60
|
+
instantly forever. And they must be **per row**, because acknowledgment is per
|
|
61
|
+
*source*: a high-water mark over a shared id space settles every lower id when
|
|
62
|
+
one is acked, silently marking read a mention from a narrative the agent never
|
|
63
|
+
opened. `NULL → timestamp` also makes monotonicity structural — there is no
|
|
64
|
+
value that could rewind, which a cursor re-derived from `tail: 50` after a
|
|
65
|
+
restart otherwise can.
|
|
66
|
+
|
|
67
|
+
**Receiving the event is the acknowledgment.** Interpreting a mention means
|
|
68
|
+
reading its source, and an agent does that by naming the narrative in the
|
|
69
|
+
cursor it already passes. So the evidence and the attention are the same act —
|
|
70
|
+
strictly stronger than an ack tool, which an agent can call reflexively without
|
|
71
|
+
looking. It settles the seqs **actually returned**, never `seq > after`:
|
|
72
|
+
`before` truncates a range and `tail: N` skips the middle, so a bound would
|
|
73
|
+
settle mentions the agent was never shown.
|
|
74
|
+
|
|
75
|
+
**Settle in the same block as the send.** `mentionReceipt` is spread directly
|
|
76
|
+
into the object handed to `sendToolResult`, so nothing can run between marking
|
|
77
|
+
sent and sending. A resolve that doesn't advance durable state makes the next
|
|
78
|
+
`listen` return instantly, forever, at inference speed, with no operator in the
|
|
79
|
+
loop — the failure that would make the feature worse than not having it.
|
|
80
|
+
`src/test/mentions-listen.test.ts` is the regression guard. The shutdown flush
|
|
81
|
+
is the one deliberate exception: it replies without a receipt, leaving mentions
|
|
82
|
+
unsent rather than marking them delivered to an agent about to lose its bridge.
|
|
83
|
+
|
|
84
|
+
**Record before you wake, and in one place.** `notifyListenWaiters` resolves a
|
|
85
|
+
parked `listen` **synchronously** — the waiter builds its receipt and acks by
|
|
86
|
+
read inside that call. So an append that wakes before it inserts acks a row
|
|
87
|
+
that does not exist yet: zero rows updated, the mention lands `seen_at NULL`,
|
|
88
|
+
and the agent's cursor has now moved past the one event that could ever settle
|
|
89
|
+
it. It is delivered, read, and permanently unsettled — reappearing in every
|
|
90
|
+
later receipt until someone reads backwards or dismisses it. This is not a
|
|
91
|
+
race; it is deterministic for exactly the agent watching the narrative that
|
|
92
|
+
mentioned it, which is why the feature's own tests missed it: every one of them
|
|
93
|
+
records before any listener parks. `announceEvent()` is therefore the single
|
|
94
|
+
ordering, called from all six sites rather than open-coded at each — the bug
|
|
95
|
+
was six independent chances to get two lines wrong, and a seventh site would
|
|
96
|
+
have had nothing to remind it.
|
|
97
|
+
|
|
98
|
+
Delivery is at-most-once — `sendToolResult` drops silently on a closing socket
|
|
99
|
+
— so "settled" must never mean "unreachable": `whoami` and
|
|
100
|
+
`listen({mentions: "all"})` replay regardless of either mark. Knobs:
|
|
101
|
+
`mentions.enabled` (kill switch), `mentions.receipt_limit`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Multi-Brane
|
|
2
|
+
|
|
3
|
+
A single daemon can host multiple branes. The **home brane** is auto-generated on first run (`brane-<hex>`). Foreign branes are created by `brane absorb` when importing conversation history from other providers.
|
|
4
|
+
|
|
5
|
+
- **Narratives** have a `brane_id` column linking them to their brane
|
|
6
|
+
- **Folds** have a `brane_id` column — `brane spawn` inherits the fold's brane_id
|
|
7
|
+
- **Absorb** creates a deterministic foreign brane_id from `sha256(provider:identity_id)`, ensuring re-imports target the same brane
|
|
8
|
+
- **`brane list`** groups narratives by brane with per-brane limits
|
|
9
|
+
- **`GET /api/branes`** returns all branes with narrative counts for the web UI
|
|
10
|
+
- **BraneSwitcher** in the Expo app replaces the old DaemonSwitcher — shows branes in a flat list, filters narratives by selected brane
|
|
11
|
+
|
|
12
|
+
Brane_id resolution chain for new narratives: fold's `brane_id` → `config default.brane` → home brane (`getBraneId`).
|
|
13
|
+
|
|
14
|
+
Existing folds are backfilled to the home brane on upgrade (idempotent, runs on every `openDatabase()`).
|
package/docs/passkeys.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Passkeys
|
|
2
|
+
|
|
3
|
+
Passkeys are **designed, spiked and tested on real devices, but not built**.
|
|
4
|
+
This page is the design and the evidence behind it, for whoever builds
|
|
5
|
+
enrollment. The pieces already in the code are per-device tokens (#549,
|
|
6
|
+
#552), the lapse for unused tokens (#555), and the presence check
|
|
7
|
+
(`src/presence.ts`, [Presence Check](presence.md)).
|
|
8
|
+
|
|
9
|
+
## The rule
|
|
10
|
+
|
|
11
|
+
**A passkey guards minting a token, never using one.** Every request keeps
|
|
12
|
+
authenticating with its device token, so a browser that cannot do WebAuthn
|
|
13
|
+
— the plain-HTTP fallback, an in-app webview, one with a flaky ceremony —
|
|
14
|
+
keeps working once it holds a token. The ceremony happens only when a new
|
|
15
|
+
device is let in (claude-pbg4whll:21). The worst outcome of a passkey
|
|
16
|
+
failure is a slower way to add a device, never a browser locked out of
|
|
17
|
+
brane.
|
|
18
|
+
|
|
19
|
+
What else a passkey guards — "Sign out other devices", an agent asking for
|
|
20
|
+
a token — is **not decided** (claude-pbg4whll:40).
|
|
21
|
+
|
|
22
|
+
## Decisions
|
|
23
|
+
|
|
24
|
+
| Decision | Why | Where |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| The RP ID is the daemon's own hostname (`<machine>.<tailnet>.ts.net`) | The daemon works with nothing outside the machine and the tailnet | claude-0d36vrut:16, claude-0d36vrut:21 |
|
|
27
|
+
| The hosted shell (`brane.organelle.dev`) does not share the passkey | It hops to the daemon's own page for the rare ceremony. No `/.well-known/webauthn`, no port 443, no `tailscale serve`, no reliance on related-origins support | claude-pbg4whll:35 |
|
|
28
|
+
| No attestation, random user handle | The daemon learns a public key and nothing about the authenticator — except for the bootstrap key (below) | claude-82b6w9o3:12 |
|
|
29
|
+
| The first enrollment needs the presence check | A software authenticator sets "user verified" with nobody present, and without attestation the daemon cannot tell; an agent acting as this account could otherwise enroll its own passkey | claude-pbg4whll:42 |
|
|
30
|
+
| YubiKeys stay possible and the top tier | Attested hardware proves itself; a key also covers headless Linux | claude-pbg4whll:49, claude-pbg4whll:50 |
|
|
31
|
+
| Ship enrollment before requiring it | Enrolling changes nothing until the restriction is switched on, so it can ship and be lived with first | claude-pbg4whll:33 |
|
|
32
|
+
|
|
33
|
+
## Evidence
|
|
34
|
+
|
|
35
|
+
**Spike, headless Chromium** (claude-0d36vrut:21). A virtual authenticator
|
|
36
|
+
against a `node:crypto`-only verifier: single-use challenge, origin
|
|
37
|
+
allowlist, RP ID hash, user-verified flag, ES256 signature. A passkey on
|
|
38
|
+
the daemon's hostname enrolled and signed in on the daemon's page; a
|
|
39
|
+
replayed assertion and an unlisted origin were refused. An RP ID of the
|
|
40
|
+
whole tailnet (`<tailnet>.ts.net`) works on the daemon's page but nothing
|
|
41
|
+
can serve its `.well-known`, so it was dropped.
|
|
42
|
+
|
|
43
|
+
**Check 1, real devices** (claude-pbg4whll:29). The same verifier on a
|
|
44
|
+
spare port, tailnet-only, with the daemon's Tailscale certificate:
|
|
45
|
+
|
|
46
|
+
| Browser | Enroll | Sign in | Replay refused |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| iOS Safari | ✅ Face ID | ✅ | ✅ |
|
|
49
|
+
| iOS Chrome | ✅ | ✅ 5/5 after the fix below | not rerun |
|
|
50
|
+
| Mac Chrome 152 | — | ✅ through the phone QR (hybrid) | ✅ |
|
|
51
|
+
| Mac Safari, Firefox, Android, Windows | not tried | | |
|
|
52
|
+
|
|
53
|
+
- The passkey **synced to iCloud Keychain** (backup-eligible and
|
|
54
|
+
backed-up flags both set), and Face ID set user-verified.
|
|
55
|
+
- The origin carried the non-standard port (`:47443`) and verified; the
|
|
56
|
+
port is not part of the RP ID.
|
|
57
|
+
- Mac Chrome did not offer the iCloud passkey directly and went through
|
|
58
|
+
the phone. Whether a Chrome setting changes that is unverified.
|
|
59
|
+
|
|
60
|
+
## What the browser side must do
|
|
61
|
+
|
|
62
|
+
Learned on the phone, then confirmed by the research below:
|
|
63
|
+
|
|
64
|
+
1. **Have the challenge before the tap.** Fetching it between the tap and
|
|
65
|
+
`navigator.credentials.get/create` made iOS refuse with
|
|
66
|
+
`NotAllowedError` (2 of 7 sign-ins succeeded in iOS Chrome before the
|
|
67
|
+
fix, 5 of 5 after). Fetch the next challenge the moment one is taken.
|
|
68
|
+
2. **One ceremony at a time.** Disable the button and keep a single
|
|
69
|
+
`AbortController`, aborting the previous request before starting
|
|
70
|
+
another. A second tap while a prompt was open failed in testing.
|
|
71
|
+
3. **Treat `NotAllowedError` as routine.** It means cancel, timeout, no
|
|
72
|
+
credential, or no user activation, and since Safari 17.4 WebKit also
|
|
73
|
+
rate-limits ceremonies (unverified: a vendor blog, no Apple figures).
|
|
74
|
+
Reset quietly and let the operator tap again; never show it as a fault.
|
|
75
|
+
4. **If no challenge is ready, wait for one** with a spinner, then ask for
|
|
76
|
+
a fresh tap. Calling the prompt without one is what WebKit refuses.
|
|
77
|
+
|
|
78
|
+
## Daemon side
|
|
79
|
+
|
|
80
|
+
- **Challenges** are random, single-use, bound to the pending ceremony, and
|
|
81
|
+
expire with the ceremony's `timeout` (a few minutes; the exact lifetime is
|
|
82
|
+
a choice, not a sourced figure).
|
|
83
|
+
- **Verify the full origin, port included**, against the daemon's own list;
|
|
84
|
+
the RP ID hash; user-verified; the signature.
|
|
85
|
+
- **Ignore `signCount` when it is 0**, which is every synced passkey.
|
|
86
|
+
Enforce it only for a credential that has reported a non-zero count.
|
|
87
|
+
- **Store at registration:** credential id, public key, algorithm,
|
|
88
|
+
transports, AAGUID, sign count, and the backup-eligible / backed-up
|
|
89
|
+
flags. Record which presence method confirmed the enrollment.
|
|
90
|
+
- **When a device-bound key enrolls** (backup-eligible = 0, e.g. a
|
|
91
|
+
YubiKey), prompt for a second credential as its backup.
|
|
92
|
+
- **Bootstrap attestation:** ask for `attestation: "direct"` on the first
|
|
93
|
+
enrollment only. A YubiKey 5+ answers with a certificate the daemon can
|
|
94
|
+
verify against the FIDO Metadata Service by AAGUID; that counts as
|
|
95
|
+
presence by itself. iCloud passkeys send none and fall back to the
|
|
96
|
+
presence check.
|
|
97
|
+
|
|
98
|
+
## Research
|
|
99
|
+
|
|
100
|
+
From a web survey on making passkeys robust (claude-pbg4whll:70). Claims
|
|
101
|
+
from vendor blogs are marked.
|
|
102
|
+
|
|
103
|
+
- **`excludeCredentials` on create**, with stored transports; treat
|
|
104
|
+
`InvalidStateError` ("already registered") as success.
|
|
105
|
+
[web.dev](https://web.dev/articles/passkey-registration)
|
|
106
|
+
- **`hints: ["security-key", …]`** keeps YubiKeys first-class; without it
|
|
107
|
+
Chrome on Android may not offer an NFC key. Chrome 128+ honours hints;
|
|
108
|
+
Safari and Firefox are unverified.
|
|
109
|
+
[Chrome](https://developer.chrome.com/blog/passkeys-updates-chrome-129)
|
|
110
|
+
- **Discoverable credentials with an empty `allowCredentials`**: a list
|
|
111
|
+
whose transports are only `internal` makes Chrome hide the phone QR.
|
|
112
|
+
[Corbado](https://www.corbado.com/blog/webauthn-passkey-qr-code) (vendor)
|
|
113
|
+
- **The Signal API** cleans stale passkeys out of the password manager —
|
|
114
|
+
the remedy for the orphan the test page left on the operator's phone.
|
|
115
|
+
`signalAllAcceptedCredentials` after a device is added or removed, never
|
|
116
|
+
with an empty list (that hides every passkey). Chrome 132+; Safari 26
|
|
117
|
+
partial; Firefox none.
|
|
118
|
+
[Chrome](https://developer.chrome.com/docs/identity/webauthn-signal-api)
|
|
119
|
+
- **Feature-detect with `PublicKeyCredential.getClientCapabilities()`**
|
|
120
|
+
(Safari 17.4+, Chrome 133+, Firefox 135+) to choose which buttons to
|
|
121
|
+
show, never to block.
|
|
122
|
+
[web.dev](https://web.dev/articles/webauthn-client-capabilities)
|
|
123
|
+
- **Hybrid (phone QR) is the most fragile path**: Bluetooth on both
|
|
124
|
+
devices, close together; Chrome on macOS can fail to find the phone after
|
|
125
|
+
its Bluetooth permission prompt
|
|
126
|
+
([Chromium 349393380](https://issues.chromium.org/issues/349393380)).
|
|
127
|
+
- **Passkey autofill (conditional UI) adds little**: brane has no username
|
|
128
|
+
field and the ceremony is rare.
|
|
129
|
+
- **The RP ID cannot change.** Renaming the machine or the tailnet orphans
|
|
130
|
+
every passkey; recovery covers it.
|
|
131
|
+
- **WebAuthn refuses a page with a certificate error**, and the HTTP
|
|
132
|
+
fallback can never run a ceremony.
|
|
133
|
+
|
|
134
|
+
## Open questions
|
|
135
|
+
|
|
136
|
+
1. **What a passkey guards beyond adding a device** — the operator's call
|
|
137
|
+
(claude-pbg4whll:40).
|
|
138
|
+
2. **The flow for approving a new device from one already trusted**: the
|
|
139
|
+
new device shows a code, the trusted one approves with Face ID. Not
|
|
140
|
+
designed; includes how the approval returns to an app saved to the home
|
|
141
|
+
screen, which may open Safari.
|
|
142
|
+
3. **The hop from the hosted shell** to the daemon's page and back must
|
|
143
|
+
carry a short-lived, single-use approval, never a token.
|
|
144
|
+
4. **Renames**: should the daemon record each passkey's RP ID and warn
|
|
145
|
+
before a hostname change?
|
|
146
|
+
5. **Untried**: Mac Safari and Firefox; Android; the presence check's
|
|
147
|
+
Windows/WSL and polkit backends; a YubiKey's attestation and its
|
|
148
|
+
signature as a presence method (claude-pbg4whll:63).
|
|
149
|
+
|
|
150
|
+
## Build order
|
|
151
|
+
|
|
152
|
+
1. ~~Unused-token lapse~~ — #555, verified live: an unscanned meld token
|
|
153
|
+
was revoked by the sweep 31 s after its deadline (claude-pbg4whll:96).
|
|
154
|
+
2. ~~Devices list~~ — #558: each row records the browser, platform and address
|
|
155
|
+
it was first presented from, and revoke is a bodiless
|
|
156
|
+
`POST /api/tokens/:id/revoke` (claude-59qp4ud5:4). See [Token
|
|
157
|
+
Rotation](token-rotation.md#the-devices-list). Revoking is no more gated
|
|
158
|
+
than "Sign out other devices" yet; whether a passkey should guard it is
|
|
159
|
+
open question 1.
|
|
160
|
+
3. **Passkey enrollment and verification**, nothing yet required.
|
|
161
|
+
4. **Wire in `confirmPresence`** for the first enrollment and for
|
|
162
|
+
recovery, with `reason` built on the daemon.
|
|
163
|
+
5. **Switch the restriction on**: after the first passkey, this machine's
|
|
164
|
+
account (a tokenless request over `daemon.sock`) can no longer mint
|
|
165
|
+
device tokens, and meld tickets need a passkey.
|
package/docs/presence.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Presence Check
|
|
2
|
+
|
|
3
|
+
`confirmPresence(reason)` (`src/presence.ts`) asks the operator to prove
|
|
4
|
+
they are at this machine, using the operating system's own
|
|
5
|
+
owner-authentication prompt. It is the piece passkey enrollment and
|
|
6
|
+
recovery rest on, and what [the gate](#the-gate) holds meld, a device's
|
|
7
|
+
sign-out and rotation on. Passing it is what makes someone
|
|
8
|
+
the operator — see [Who the operator is](../SECURITY.md#who-the-operator-is).
|
|
9
|
+
|
|
10
|
+
## Why passkeys need it
|
|
11
|
+
|
|
12
|
+
A passkey's "user verified" flag says a person unlocked the authenticator,
|
|
13
|
+
but a software authenticator can set that flag with nobody present (the
|
|
14
|
+
passkey spike did exactly that with a virtual authenticator). iCloud
|
|
15
|
+
passkeys carry no attestation, so the daemon cannot tell one from a
|
|
16
|
+
script. An agent acting as this account could therefore enroll a passkey
|
|
17
|
+
of its own. The first enrollment, and recovery when no passkey works, are
|
|
18
|
+
confirmed here instead: an agent running as the operator can raise the
|
|
19
|
+
system prompt but cannot put a finger on the sensor or type a password it
|
|
20
|
+
does not know.
|
|
21
|
+
|
|
22
|
+
An attested hardware key (a YubiKey 5 or later) proves itself without this
|
|
23
|
+
check and remains the strongest option; see [Passkeys](passkeys.md).
|
|
24
|
+
|
|
25
|
+
## Backends
|
|
26
|
+
|
|
27
|
+
Each runs a tool the OS already ships, so brane carries no native module.
|
|
28
|
+
|
|
29
|
+
| Platform | Tool | Asks for | Tested |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| macOS | `osascript -l JavaScript` → `LAContext`, policy `deviceOwnerAuthentication` | Touch ID, Apple Watch, or the login password | yes, on a real prompt; and raised by the daemon's gate: approve, decline, cooldown, Ctrl-C (2026-10-05) |
|
|
32
|
+
| Windows, WSL | `powershell.exe` (5.1) → `UserConsentVerifier` | Windows Hello: face, fingerprint, or PIN | no |
|
|
33
|
+
| Linux desktop | `pkcheck` against `dev.brane.confirm-presence` | the user's own password (`auth_self`) | no |
|
|
34
|
+
| Headless Linux | — | `unavailable` | — |
|
|
35
|
+
|
|
36
|
+
Less obvious than it looks:
|
|
37
|
+
|
|
38
|
+
- **macOS has no approve button.** The dialog's only ways through are
|
|
39
|
+
biometrics or the password field, so synthetic clicks can cancel it but
|
|
40
|
+
never pass it.
|
|
41
|
+
- **Every command is a fixed absolute system path** (`/usr/bin/osascript`,
|
|
42
|
+
`/usr/bin/pkcheck`, `C:\Windows\System32\…\powershell.exe`), never a
|
|
43
|
+
bare name on the daemon's inherited `PATH` nor a path built from its
|
|
44
|
+
environment (`%SystemRoot%`). This is hygiene against a careless agent's
|
|
45
|
+
`PATH`, not a defence against a hostile one: a process running as the
|
|
46
|
+
operator can already edit `dist/` or `brane.db`, which brane accepts as
|
|
47
|
+
out of scope. The check raises the bar for agents that follow the rules.
|
|
48
|
+
- **The reason never enters a script.** On macOS it is an `argv` element;
|
|
49
|
+
on Windows it is a PowerShell single-quoted literal inside a script sent
|
|
50
|
+
as `-EncodedCommand` (UTF-16LE base64), which survives WSL interop's
|
|
51
|
+
quoting untouched.
|
|
52
|
+
- **WSL uses Windows Hello**, not polkit: the prompt appears on the Windows
|
|
53
|
+
desktop, out of reach of anything inside the Linux VM. PowerShell 7
|
|
54
|
+
(`pwsh`) lacks the WinRT projection, hence `powershell.exe`.
|
|
55
|
+
- **`canceled` means the operator said no, and nothing else.** A system
|
|
56
|
+
that took the dialog away, too many wrong attempts, or polkit refusing
|
|
57
|
+
without asking (a daemon outside the active desktop session, e.g. started
|
|
58
|
+
over SSH: pkcheck exit 1) are all `failed`.
|
|
59
|
+
- **Linux needs brane's polkit policy installed.** polkit's built-in
|
|
60
|
+
actions mostly ask for an administrator, which is the wrong question; this
|
|
61
|
+
one asks the operator to prove they are themselves (`auth_self`), every
|
|
62
|
+
time (no `_keep`). Without it the check reports `unavailable`; the "no
|
|
63
|
+
such action" message is matched before the exit code, since pkcheck
|
|
64
|
+
reports errors as 127 (untested against a real polkit). brane ships no
|
|
65
|
+
copy of the file; install it with:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
sudo tee /usr/share/polkit-1/actions/dev.brane.confirm-presence.policy >/dev/null <<'EOF'
|
|
69
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
70
|
+
<!DOCTYPE policyconfig PUBLIC
|
|
71
|
+
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
|
72
|
+
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
|
73
|
+
<policyconfig>
|
|
74
|
+
<vendor>brane</vendor>
|
|
75
|
+
<vendor_url>https://github.com/benjamn/brane</vendor_url>
|
|
76
|
+
<action id="dev.brane.confirm-presence">
|
|
77
|
+
<description>Confirm you are at this machine for brane</description>
|
|
78
|
+
<message>brane wants to confirm you are at this machine before it trusts a new passkey or device</message>
|
|
79
|
+
<defaults>
|
|
80
|
+
<allow_any>no</allow_any>
|
|
81
|
+
<allow_inactive>no</allow_inactive>
|
|
82
|
+
<allow_active>auth_self</allow_active>
|
|
83
|
+
</defaults>
|
|
84
|
+
</action>
|
|
85
|
+
</policyconfig>
|
|
86
|
+
EOF
|
|
87
|
+
```
|
|
88
|
+
- **Headless Linux is `unavailable` on purpose.** polkit would fall back to
|
|
89
|
+
a password prompt in the terminal, and agents run in terminals.
|
|
90
|
+
|
|
91
|
+
## The gate
|
|
92
|
+
|
|
93
|
+
`PresenceGate` (`src/daemon/presence-gate.ts`) is where the daemon calls
|
|
94
|
+
it, and `src/daemon/gated-routes.ts` holds the routes that ask it. Four
|
|
95
|
+
acts, asked from this machine's shell (a tokenless request over
|
|
96
|
+
`daemon.sock`), wait in the route until the operator answers:
|
|
97
|
+
|
|
98
|
+
| Act | Route | CLI | The prompt says |
|
|
99
|
+
|---|---|---|---|
|
|
100
|
+
| `meld` | `POST /api/tokens` | `brane meld`, `brane spawn --meld` | add a device to brane |
|
|
101
|
+
| `meld-remote` | `POST /api/tokens/remote` | `brane meld --remote` | add a device to brane on another machine |
|
|
102
|
+
| `revoke-device` | `POST /api/tokens/:id/revoke`, when it signs out a device | `brane token revoke <device>` | sign out a device from brane |
|
|
103
|
+
| `rotate` | `POST /api/token/rotate` | `brane token rotate` | sign out every other device from brane |
|
|
104
|
+
|
|
105
|
+
A device token is never prompted: the app's Meld sheet, Revoke and "Sign
|
|
106
|
+
out other devices" are the operator's delegate acting, and work from
|
|
107
|
+
anywhere. **An agent's token is refused all three, unasked** (403,
|
|
108
|
+
`presence: "agent"`; `gateStanding`): they are the operator's acts, and an
|
|
109
|
+
agent that follows the rules should never put a dialog in front of the
|
|
110
|
+
operator — one that read exactly like the prompt their own `brane meld`
|
|
111
|
+
raises would be approved out of habit. So an agent cannot `brane spawn
|
|
112
|
+
--meld`; the operator scans from the Meld sheet instead. Who is asking is
|
|
113
|
+
read from the token's kind, never from the address. Revoking an agent is
|
|
114
|
+
not gated, for an agent or the shell; stopping an agent the account
|
|
115
|
+
started is the account's to do.
|
|
116
|
+
|
|
117
|
+
Less obvious than it looks:
|
|
118
|
+
|
|
119
|
+
- **The prompt's words are fixed per act.** Nothing from a request reaches
|
|
120
|
+
them, not a device's label nor its user agent: whoever minted a token
|
|
121
|
+
chose those, and an agent that could write the prompt could make it read
|
|
122
|
+
like the one the operator is expecting.
|
|
123
|
+
- **Check, ask, check again.** A request the route would refuse (a bad
|
|
124
|
+
mint body; revoking this machine's own row, the requester's own, or a row that
|
|
125
|
+
is not live) is refused before anyone is asked, so the operator never
|
|
126
|
+
approves something that then fails. The revoke re-checks after the
|
|
127
|
+
prompt, since rows can change during it.
|
|
128
|
+
- **Rotation asks before `beginRotation`.** A second begin replaces a
|
|
129
|
+
pending rotation, so a refused request must never reach it: a loop could
|
|
130
|
+
otherwise void the operator's own rotation without passing a prompt.
|
|
131
|
+
- **One prompt at a time, no queue.** A second gated request while one is
|
|
132
|
+
up is refused at once (409). After one that was not approved (canceled,
|
|
133
|
+
timed out, failed, canceled from a device, or withdrawn by its asker) the
|
|
134
|
+
gate refuses for 15 s (429, with `Retry-After`), so a loop cannot keep a
|
|
135
|
+
dialog on screen until it is approved out of habit; giving up and asking
|
|
136
|
+
again is that loop, so a withdrawal counts. Both are global, but only the
|
|
137
|
+
shell can reach the gate (an agent is refused before it), so only another
|
|
138
|
+
shell's request can make the operator's `brane meld` wait; the Meld
|
|
139
|
+
sheet still works regardless. The 409 names the prompt that is up and who asked, since its
|
|
140
|
+
dialog may read exactly like the one the operator expected.
|
|
141
|
+
- **No dialog outlives its request.** A gated request waits 45 s; if its
|
|
142
|
+
client goes away first (Ctrl-C), the helper is killed (`runPlan`'s
|
|
143
|
+
`signal`), which takes the dialog down with it: seen live on macOS
|
|
144
|
+
(2026-10-05), logged as `aborted`, and followed by the cooldown like any
|
|
145
|
+
prompt not approved.
|
|
146
|
+
- **Every device hears of it, and can cancel; nothing can approve.** The
|
|
147
|
+
control socket carries `{ type: "presence", pending }` when a prompt
|
|
148
|
+
opens (`pending` names the act, the prompt, who asked, and when it
|
|
149
|
+
expires) and `pending: null` when it closes; a socket that connects
|
|
150
|
+
mid-prompt is told at once. `POST /api/presence/cancel?id=` closes it,
|
|
151
|
+
and only a device token may call it: anything on this machine that could
|
|
152
|
+
cancel could cancel every prompt as it appeared and, with the cooldown,
|
|
153
|
+
keep `brane token rotate` from ever completing. The asker withdraws by
|
|
154
|
+
disconnecting. A remote approve would be a device minting a token, which
|
|
155
|
+
the Meld sheet already does in the open.
|
|
156
|
+
- **Every attempt is a log line**: `[presence] <act> for <who>: approved`,
|
|
157
|
+
`canceled`, `remote-cancel`, `timeout`, `aborted`, `unavailable`, or
|
|
158
|
+
refused as busy, cooling down, or `an agent's token` — an agent's attempt
|
|
159
|
+
raises no prompt but is recorded like one that did.
|
|
160
|
+
- **No prompt, no act.** Where the platform cannot show one (headless
|
|
161
|
+
Linux, a container), the gated acts are refused with `unavailable`; a
|
|
162
|
+
device token still does them. The first device of such a machine comes
|
|
163
|
+
from elsewhere: `brane meld --remote` has the operator's own daemon mint
|
|
164
|
+
the token, behind this machine's prompt (`meld-remote`), and sends the
|
|
165
|
+
remote only its hash, which its daemon adopts at startup
|
|
166
|
+
(`BRANE_DEVICE_TOKEN_SHA256`; see `protocols/fly.md`). The minting daemon
|
|
167
|
+
keeps nothing: the token is for another daemon, and the response is its
|
|
168
|
+
only copy. There is no switch that turns the gate off.
|
|
169
|
+
- **Not containment.** `meld-remote` gates the sanctioned way to reach a
|
|
170
|
+
remote daemon, not the remote: a process that can run `fly secrets set`
|
|
171
|
+
(or whatever `--send` runs) can hand that daemon a hash of its own
|
|
172
|
+
making, no brane involved. A device token is never prompted, whoever holds it,
|
|
173
|
+
so a local process that minted itself one before the gate existed skips
|
|
174
|
+
it; so does one that restarts the daemon with `--token` or
|
|
175
|
+
`BRANE_DEVICE_TOKEN_SHA256` set, which adopt a device token at startup. Check the Devices list for devices added by "this machine" or by an
|
|
176
|
+
agent that you did not add.
|
|
177
|
+
|
|
178
|
+
## Results
|
|
179
|
+
|
|
180
|
+
`{ ok: true, method }`, or `{ ok: false, method, reason, detail }` with
|
|
181
|
+
`reason` one of `canceled` (the operator said no), `unavailable` (no prompt
|
|
182
|
+
can be shown here), `failed`, `timeout` (default 120 s), or `aborted` (the
|
|
183
|
+
caller's `signal` withdrew the question, and the helper was killed). It
|
|
184
|
+
never throws.
|
|
185
|
+
|
|
186
|
+
## Tests
|
|
187
|
+
|
|
188
|
+
`src/test/presence.test.ts` checks each backend's plan as data — command,
|
|
189
|
+
arguments, quoting, and how each tool's answer is read — so it runs on any
|
|
190
|
+
platform and never raises a prompt. The macOS cancel result it parses was
|
|
191
|
+
recorded from a real dialog.
|
|
192
|
+
|
|
193
|
+
## For whoever wires it in, or first runs it on Windows
|
|
194
|
+
|
|
195
|
+
- **Build `reason` on the daemon** from what is being approved, never from
|
|
196
|
+
text in a request, or an agent writes the prompt's words itself ("Unlock
|
|
197
|
+
brane"). The prompt naming what it approves is half of what makes it safe.
|
|
198
|
+
- **Windows Hello from a hidden console may open behind other windows**;
|
|
199
|
+
the desktop API is `RequestVerificationForWindowAsync(hwnd)`. And a
|
|
200
|
+
timeout that kills `powershell.exe` (or WSL's interop proxy) may leave the
|
|
201
|
+
dialog open with nobody listening. Both are the first things to watch in
|
|
202
|
+
a real Windows run.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Publishing to npm is done by `.github/workflows/release.yml`, and only by it.
|
|
4
|
+
No npm token exists in this repository or its secrets: the job authenticates
|
|
5
|
+
with a short-lived OIDC token ([npm trusted
|
|
6
|
+
publishing](https://docs.npmjs.com/trusted-publishers)). **Agents never run
|
|
7
|
+
`npm publish`**, from a checkout or anywhere else.
|
|
8
|
+
|
|
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 was published while the repository was still
|
|
13
|
+
private, so it is the one release with **no provenance attestation**; every
|
|
14
|
+
release since the repository went public carries it, with no workflow
|
|
15
|
+
change.
|
|
16
|
+
|
|
17
|
+
The trigger is a **published GitHub release**, not a tag push, so the release
|
|
18
|
+
notes and the publish are one act and a stray tag cannot publish anything.
|
|
19
|
+
|
|
20
|
+
1. Land the version bump on `main` by pull request, with `npm version <v>
|
|
21
|
+
--no-git-tag-version` so `package-lock.json` moves with it.
|
|
22
|
+
2. Create a GitHub release on `main` tagged `v<version>`, exactly the
|
|
23
|
+
`package.json` version with a `v` prefix. The workflow refuses any other
|
|
24
|
+
tag, and refuses a version that is already on the registry.
|
|
25
|
+
3. The **"Set as a pre-release"** checkbox chooses the dist-tag: unchecked
|
|
26
|
+
publishes under `latest`, checked publishes under `next`. That is the only
|
|
27
|
+
place the choice is made.
|
|
28
|
+
|
|
29
|
+
The job builds from scratch (`npm run build` plus the precache check, since
|
|
30
|
+
`dist/` and `expo/dist/` are gitignored and there is no `prepare` script),
|
|
31
|
+
runs the full test suite, prints the `npm pack --dry-run` manifest to the log,
|
|
32
|
+
and then publishes. A failure anywhere before the last step is a red run and
|
|
33
|
+
nothing on the registry.
|
|
34
|
+
|
|
35
|
+
Trusted publishing needs npm 11.5.1 or newer, which no runner ships, so the job
|
|
36
|
+
runs `npm install -g npm@latest` first. The npm-side configuration lives at
|
|
37
|
+
`https://www.npmjs.com/package/brane/access`, is per package, and is the
|
|
38
|
+
operator's: the trusted publisher entry (`benjamn` / `brane` / `release.yml`,
|
|
39
|
+
no environment), and "Require two-factor authentication and disallow tokens",
|
|
40
|
+
which is what actually retires tokens as a publish path.
|
|
41
|
+
|
|
42
|
+
A newly created trusted-publisher entry reads **Pending validation** until
|
|
43
|
+
its first successful publish, and npm deletes it if none happens within
|
|
44
|
+
about 48 hours of creation; the access page shows the deadline. So either
|
|
45
|
+
create the entry on the day of the release, or validate a fresh one with a
|
|
46
|
+
release marked pre-release, which publishes to `next` and leaves `latest`
|
|
47
|
+
where it was.
|
|
48
|
+
|
|
49
|
+
`src/cli.ts` reads its version from `package.json` at startup, so
|
|
50
|
+
`brane --version` reports whatever was published rather than a string that
|
|
51
|
+
has to be bumped by hand.
|
|
52
|
+
|
|
53
|
+
## The app ships with the release
|
|
54
|
+
|
|
55
|
+
The shared app (the `brane` worker in `worker/wrangler.toml`, at
|
|
56
|
+
`app_domain`'s default host) serves the web app of the version npm calls
|
|
57
|
+
`latest`, and nothing else. A merge to `main` deploys nothing: `ci.yml` only
|
|
58
|
+
tests.
|
|
59
|
+
|
|
60
|
+
- After an ordinary release publishes, `release.yml`'s `deploy-app` job runs
|
|
61
|
+
`.github/workflows/deploy-app.yml` with the release's version. A pre-release
|
|
62
|
+
deploys nothing.
|
|
63
|
+
- `deploy-app.yml` does not build. It waits until the registry has the version
|
|
64
|
+
and names it `latest` (right after a publish either can lag by up to a
|
|
65
|
+
minute), unpacks `npm pack <name>@<version>`, checks the tarball's name,
|
|
66
|
+
version and `expo/dist/index.html`, runs the precache check, and
|
|
67
|
+
`wrangler deploy`s that `expo/dist`. The shared app is therefore the same
|
|
68
|
+
bytes every daemon of that version installs and serves itself, by
|
|
69
|
+
construction rather than by a second build that is supposed to match.
|
|
70
|
+
- It is its own job because a publish cannot be undone and a deploy can be
|
|
71
|
+
repeated: a Cloudflare failure is a re-run of that job, never a second
|
|
72
|
+
release.
|
|
73
|
+
- **Redeploy or roll back** by dispatching `deploy-app.yml` from the Actions
|
|
74
|
+
tab with a version. It refuses a version that is not `latest`, so a rollback
|
|
75
|
+
is `npm dist-tag add brane@<version> latest` (the operator's, like everything
|
|
76
|
+
on the registry) and then the dispatch. The dispatch checks out the branch it
|
|
77
|
+
is run from, so an old version's bundle is deployed with *that branch's*
|
|
78
|
+
`wrangler.toml` and precache check, not its tag's: the route is where the
|
|
79
|
+
app lives whatever its version. A version that isn't `latest` is refused
|
|
80
|
+
only after the registry-lag retries, about four minutes in.
|
|
81
|
+
- **A `wrangler.toml` change is first exercised by the next release's
|
|
82
|
+
deploy.** A merge deploys nothing and `wrangler deploy --dry-run` doesn't
|
|
83
|
+
talk to Cloudflare, so watch that release's `deploy-app` job. Declaring the
|
|
84
|
+
custom domain needs zone permissions on `CLOUDFLARE_API_TOKEN`, not only
|
|
85
|
+
Workers ones; if the job fails there, the fix is the token and a re-run, and
|
|
86
|
+
the publish before it stands.
|
|
87
|
+
|
|
88
|
+
**Staging is Isolate.** A daemon running `main` (after `/rrr`) serves its own
|
|
89
|
+
bundle, built from that checkout, so Isolate on it shows everything merged
|
|
90
|
+
since the last release, together, before a release promotes it to the shared
|
|
91
|
+
app. Every daemon serves its own version's app the same way, which is also
|
|
92
|
+
what an older install has: `brane meld --local`, or Isolate, gives it the app
|
|
93
|
+
it shipped with. Between releases, then, a daemon running `main` is newer than
|
|
94
|
+
the shared app, the ordinary case of an older app on a newer daemon.
|
|
95
|
+
|
|
96
|
+
The deploy names no host and no package: the package name comes from
|
|
97
|
+
`package.json`, and the route is declared in `worker/wrangler.toml` as a
|
|
98
|
+
custom domain, with `workers_dev = false` so it is the only place the shared
|
|
99
|
+
app is served and the Cloudflare dashboard holds nothing the repository
|
|
100
|
+
doesn't. That route is `CONFIG_DEFAULTS.app_domain`, the host `brane meld`
|
|
101
|
+
points at, and `src/test/wrangler-route.test.ts` keeps the two equal. A fork
|
|
102
|
+
that publishes under its own name and serves its own app changes `name` in
|
|
103
|
+
`package.json`, `name` and the route in `wrangler.toml`, and
|
|
104
|
+
`CONFIG_DEFAULTS.app_domain`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Rendering Warnings
|
|
2
|
+
|
|
3
|
+
Every renderer here fails quietly, on purpose. Unparseable LaTeX comes back as its own source rather than KaTeX's red error markup; a `$…$` run that trips the currency guard is escaped into prose; an unclosed fence swallows the rest of the message. That is right for the **reader**, who wants a document and not a diagnostic — and exactly wrong for the **writer**, who otherwise never learns the equation did not set.
|
|
4
|
+
|
|
5
|
+
`narrate` closes that gap without reopening it for the reader: rendering is unchanged, and the reasoning the renderers were already doing is reported back instead of discarded. `diagnoseMarkdown()` runs on `say`, and the result grows a `warnings` array **only when non-empty** — a narration that renders as written gets the byte-identical receipt it always did.
|
|
6
|
+
|
|
7
|
+
| kind | fires on | what the operator sees instead |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `math_parse_error` | KaTeX refused the LaTeX | the literal source with its delimiters |
|
|
10
|
+
| `math_demoted` | a `$…$` run the guard rejected, whose content is TeX-ish (`\cmd`, `^{`, `_{`) | the source verbatim, dollars and all |
|
|
11
|
+
| `math_unclosed` | `\(` or `\[` with no partner, or a `$$` that opens a line and never closes | literal text — or, for `$$`, everything after it as one math block. A mid-sentence `$$` or a `$$$` is literal and stays quiet |
|
|
12
|
+
| `fence_unclosed` | a code fence that never closes | everything after it as one code block |
|
|
13
|
+
|
|
14
|
+
Two properties make the warnings worth acting on:
|
|
15
|
+
|
|
16
|
+
**It is the real parse, not a lookalike.** The runs come from the same `prepareMathMarkdown` and the same micromark pass the export uses — `mathHtmlExtension(onError)` and `prepareMathMarkdown(md, onIssue)` grew optional collectors rather than gaining a second scanner beside them. So a warning fires *iff* the operator's view differs from intent. A parallel scanner would eventually disagree with the renderer, and a linter that cries wolf is worse than none.
|
|
17
|
+
|
|
18
|
+
**Every warning carries its `fallback`** — the literal text that will appear. Whether a failure *matters* is a judgment only the author can make and it turns almost entirely on that text: `$E = mc^2$` shown as source still communicates, a `pmatrix` shown as source is line noise. `protocols/markdown.md` tells agents to make exactly that call. `fence_unclosed` is the one that is never benign.
|
|
19
|
+
|
|
20
|
+
**Advisory by default, refusing only on request.** A warning never blocks a narration. The protocol tells agents to apply the fix *forward* — write the next one better — rather than to correct the last one, because a live loop that pauses to re-render a blemish has traded the thing narration is for.
|
|
21
|
+
|
|
22
|
+
`strict: true` is the opt-in exception, shaped after `wait: false` on `listen`: the narration is refused rather than posted, the call comes back as a tool error carrying the same diagnostics, and **nothing is written** — which is why `diagnoseMarkdown` runs before the summary update and before payloads are copied into managed storage. A refusal that had already copied files would leave orphans, and the golden rule forbids cleaning them up. Agents are told to reach for it deliberately (a derivation, a table the message builds on), never by habit: every refusal is a round trip the operator waits through.
|