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,886 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import WebSocket from "ws";
|
|
7
|
+
import { connect as netConnect } from "node:net";
|
|
8
|
+
const TOOLS = [
|
|
9
|
+
{
|
|
10
|
+
name: "narrate",
|
|
11
|
+
description: "Post a narration to your narrative (or a foreign narrative). " +
|
|
12
|
+
"Returns events based on cursor parameters, minus the narration just written (`seq` says where it landed) " +
|
|
13
|
+
"— but those events are a RECEIPT, not a poll: " +
|
|
14
|
+
"anything handed back arrived too late to shape the words you just committed. " +
|
|
15
|
+
"Before composing anything substantive, read first with listen({ wait: false, after: <your cursor> }) " +
|
|
16
|
+
"and interpret what it returns. " +
|
|
17
|
+
"Only narrate when you have meaningful content (answers, questions, status updates). " +
|
|
18
|
+
"Do NOT narrate filler like 'In loop' or 'Listening'. " +
|
|
19
|
+
"When a narration ends your turn (an answer, a question, a report) pass `wait: true`: it posts, " +
|
|
20
|
+
"then blocks exactly as listen would, in one call instead of two. Otherwise call listen immediately after. " +
|
|
21
|
+
"While working, send a progress narration as a parallel tool call in the same turn as the work it " +
|
|
22
|
+
"describes (the Bash, Read, Edit, …), never as a turn of its own: every turn re-reads your whole context. " +
|
|
23
|
+
"If the narration would not render as written — unparseable LaTeX, a `$…$` run that is not " +
|
|
24
|
+
"treated as math, an unclosed fence — the result carries a `warnings` array, each entry with a " +
|
|
25
|
+
"`message` and the `fallback` text the operator will actually see. Warnings never block the " +
|
|
26
|
+
"narration: judge from the fallback whether the meaning still arrived, and apply the fix to " +
|
|
27
|
+
"what you write next rather than correcting the last one. Pass `strict: true` to refuse instead " +
|
|
28
|
+
"of posting, when a particular narration must render exactly.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
say: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "The narration text (what you want to say)",
|
|
35
|
+
},
|
|
36
|
+
after: {
|
|
37
|
+
oneOf: [
|
|
38
|
+
{ type: "number" },
|
|
39
|
+
{ type: "object", additionalProperties: { type: "number" } },
|
|
40
|
+
],
|
|
41
|
+
description: "Cursor — the seq you've already seen, exclusive (returns events with seq > it). A cursor is a per-narrative watermark: pass a number to track your own narrative (0 = from the beginning), or an object mapping narrative IDs to seq to watch several at once, where the key \"self\" means your own narrative. before and tail accept the same two forms.",
|
|
42
|
+
},
|
|
43
|
+
before: {
|
|
44
|
+
oneOf: [
|
|
45
|
+
{ type: "number" },
|
|
46
|
+
{ type: "object", additionalProperties: { type: "number" } },
|
|
47
|
+
],
|
|
48
|
+
description: "Upper bound, exclusive (returns events with seq < it). Same forms as after; pair with after for a bounded window.",
|
|
49
|
+
},
|
|
50
|
+
tail: {
|
|
51
|
+
oneOf: [
|
|
52
|
+
{ type: "number" },
|
|
53
|
+
{ type: "object", additionalProperties: { type: "number" } },
|
|
54
|
+
],
|
|
55
|
+
description: "Return only the last N events. Same forms as after; pair with before to page backward.",
|
|
56
|
+
},
|
|
57
|
+
nid: {
|
|
58
|
+
type: "string",
|
|
59
|
+
description: "Target narrative ID for foreign narration (default: self)",
|
|
60
|
+
},
|
|
61
|
+
re: {
|
|
62
|
+
type: "array",
|
|
63
|
+
items: { oneOf: [{ type: "number" }, { type: "string" }] },
|
|
64
|
+
description: "Back-references: array of seq numbers (same narrative) or 'narrative-id:seq' strings",
|
|
65
|
+
},
|
|
66
|
+
meta: {
|
|
67
|
+
type: "object",
|
|
68
|
+
description: "Optional metadata. Key: summary (what tells this session apart). Session IDs are " +
|
|
69
|
+
"owned by the daemon and set at spawn/revive — there is no agent-set " +
|
|
70
|
+
"session field.",
|
|
71
|
+
properties: {
|
|
72
|
+
summary: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "What tells this session apart for the operator, then where it stands: " +
|
|
75
|
+
"name the task, not a bare outcome like \"PR merged\"; newest theme first. " +
|
|
76
|
+
"Reread it at every report.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
payloads: {
|
|
81
|
+
type: "array",
|
|
82
|
+
items: { type: "string" },
|
|
83
|
+
description: "File paths to attach as payloads. Absolute paths on the local filesystem. " +
|
|
84
|
+
"Files are copied into managed storage and served via the payloads API.",
|
|
85
|
+
},
|
|
86
|
+
strict: {
|
|
87
|
+
type: "boolean",
|
|
88
|
+
description: "Refuse to post this narration if it would not render as written (default: false). " +
|
|
89
|
+
"The call fails with the same diagnostics `warnings` carries and nothing is appended, " +
|
|
90
|
+
"so you can fix the source and narrate again. Opt in per narration — for an equation " +
|
|
91
|
+
"or a table that has to land right — not as a habit: gating every narration on " +
|
|
92
|
+
"typography costs the conversation its pace.",
|
|
93
|
+
},
|
|
94
|
+
wait: {
|
|
95
|
+
type: "boolean",
|
|
96
|
+
description: "After posting, block until new signals arrive, exactly as listen does (default: false), " +
|
|
97
|
+
"and return listen's result with this narration left out. The mirror of listen's " +
|
|
98
|
+
"`wait: false`. `after` takes any cursor listen does, several narratives included; anything " +
|
|
99
|
+
"that landed since it comes back at once, as a receipt would. Omit `after` to wait for what " +
|
|
100
|
+
"arrives next in your own narrative, and in the target of a foreign `nid`. With `before` or " +
|
|
101
|
+
"`tail` it does not wait: the result says `wait_ignored`, and you call listen next.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
required: ["say"],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "listen",
|
|
109
|
+
description: "Block and wait for new signals. Stateless cursor-based long-polling. " +
|
|
110
|
+
"Also your read-before-you-speak step: call with wait: false for an instant non-blocking check " +
|
|
111
|
+
"of everything that landed since your cursor, and interpret it BEFORE you compose a narration. " +
|
|
112
|
+
"Your own narrations never wake a waiting listen, but are returned with whatever does. " +
|
|
113
|
+
"IMPORTANT: When this times out with no events, call listen again immediately — do NOT narrate. " +
|
|
114
|
+
"You MUST keep calling listen in a loop. Never exit to your prompt while the session is active.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
after: {
|
|
119
|
+
oneOf: [
|
|
120
|
+
{ type: "number" },
|
|
121
|
+
{ type: "object", additionalProperties: { type: "number" } },
|
|
122
|
+
],
|
|
123
|
+
description: "Cursor — the seq you've already seen, exclusive (returns events with seq > it). A cursor is a per-narrative watermark: pass a number to track your own narrative (0 = from the beginning), or an object mapping narrative IDs to seq to watch several at once, where the key \"self\" means your own narrative. before and tail accept the same two forms.",
|
|
124
|
+
},
|
|
125
|
+
before: {
|
|
126
|
+
oneOf: [
|
|
127
|
+
{ type: "number" },
|
|
128
|
+
{ type: "object", additionalProperties: { type: "number" } },
|
|
129
|
+
],
|
|
130
|
+
description: "Upper bound, exclusive (returns events with seq < it). Same forms as after; pair with after for a bounded window.",
|
|
131
|
+
},
|
|
132
|
+
tail: {
|
|
133
|
+
oneOf: [
|
|
134
|
+
{ type: "number" },
|
|
135
|
+
{ type: "object", additionalProperties: { type: "number" } },
|
|
136
|
+
],
|
|
137
|
+
description: "Return only the last N events. Same forms as after; pair with before to page backward.",
|
|
138
|
+
},
|
|
139
|
+
wait: {
|
|
140
|
+
type: "boolean",
|
|
141
|
+
description: "Whether to block and wait for signals (default: true). " +
|
|
142
|
+
"Set to false for a non-blocking read that returns immediately.",
|
|
143
|
+
},
|
|
144
|
+
mentions: {
|
|
145
|
+
enum: ["all", "dismiss"],
|
|
146
|
+
description: "Rarely needed. \"all\" replays recent mentions of you whether or not they were settled — the recovery read, for when you suspect one was missed. " +
|
|
147
|
+
"\"dismiss\" settles everything outstanding: use it for a mention you have judged irrelevant and do not intend to read, which would otherwise reappear in every receipt. " +
|
|
148
|
+
"Omit for normal operation, where a mention settles by your reading the narrative it was written in.",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: [],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "whoami",
|
|
156
|
+
description: "Get your narrative ID, brane ID, fold directory, agent type, and role.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "revive",
|
|
164
|
+
description: "Restart a dead agent session. Idempotent — if already running, optionally delivers a prompt.",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
session: {
|
|
169
|
+
type: "string",
|
|
170
|
+
description: "Narrative/session ID to revive",
|
|
171
|
+
},
|
|
172
|
+
prompt: {
|
|
173
|
+
type: "string",
|
|
174
|
+
description: "Custom prompt to send to the revived agent",
|
|
175
|
+
},
|
|
176
|
+
no_prompt: {
|
|
177
|
+
type: "boolean",
|
|
178
|
+
description: "If true, suppress the initial prompt entirely",
|
|
179
|
+
},
|
|
180
|
+
agent: {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "Switch to a different agent type (e.g. claude, goose, gemini). Launches a fresh agent session continuing the same narrative.",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
required: ["session"],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "narrative_search",
|
|
190
|
+
description: "Semantic search across narrative history. Returns events ranked by relevance. " +
|
|
191
|
+
"Auto-indexes unindexed events on first call (may be slow if embedding model not cached).",
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: {
|
|
195
|
+
query: {
|
|
196
|
+
type: "string",
|
|
197
|
+
description: "Search query (short phrases work best)",
|
|
198
|
+
},
|
|
199
|
+
limit: {
|
|
200
|
+
type: "number",
|
|
201
|
+
description: "Maximum number of results (default: 10)",
|
|
202
|
+
},
|
|
203
|
+
brane_ids: {
|
|
204
|
+
type: "array",
|
|
205
|
+
items: { type: "string" },
|
|
206
|
+
description: "Brane IDs to search within (default: caller's brane only). " +
|
|
207
|
+
"Pass multiple to search across branes.",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
required: ["query"],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "narrative_info",
|
|
215
|
+
description: "Get info about narratives. Without nid: browse recently active narratives " +
|
|
216
|
+
"(time-descending list with optional filtering). With nid: get detailed info " +
|
|
217
|
+
"on specific narratives including event count, metadata, timestamps, and status. " +
|
|
218
|
+
"The since/search/status/fold/archived filters apply to browse mode only (ignored when nid is given).",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: "object",
|
|
221
|
+
properties: {
|
|
222
|
+
nid: {
|
|
223
|
+
oneOf: [
|
|
224
|
+
{ type: "string" },
|
|
225
|
+
{ type: "array", items: { type: "string" } },
|
|
226
|
+
],
|
|
227
|
+
description: "Narrative ID(s) to get detailed info for. " +
|
|
228
|
+
"When provided, returns full metadata for those specific narratives.",
|
|
229
|
+
},
|
|
230
|
+
since: {
|
|
231
|
+
type: "string",
|
|
232
|
+
description: "Time window for recent activity (e.g. 30m, 1h, 2d, 1w). Default: 24h.",
|
|
233
|
+
},
|
|
234
|
+
search: {
|
|
235
|
+
type: "string",
|
|
236
|
+
description: "Filter to narratives containing matching event content.",
|
|
237
|
+
},
|
|
238
|
+
status: {
|
|
239
|
+
type: "string",
|
|
240
|
+
description: "Filter by narrative status (active, ended).",
|
|
241
|
+
},
|
|
242
|
+
archived: {
|
|
243
|
+
type: "boolean",
|
|
244
|
+
description: "Include archived narratives (default: false).",
|
|
245
|
+
},
|
|
246
|
+
fold: {
|
|
247
|
+
type: "string",
|
|
248
|
+
description: "Filter by fold path (e.g. ~/dev/brane).",
|
|
249
|
+
},
|
|
250
|
+
limit: {
|
|
251
|
+
type: "number",
|
|
252
|
+
description: "Maximum number of narratives to return (default: 20, 0 = unlimited)",
|
|
253
|
+
},
|
|
254
|
+
brane_ids: {
|
|
255
|
+
type: "array",
|
|
256
|
+
items: { type: "string" },
|
|
257
|
+
description: "Brane IDs to browse within (default: caller's brane only). " +
|
|
258
|
+
"Pass multiple to browse across branes.",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "list_peers",
|
|
265
|
+
description: "List active LIVE-loop agent peers. Returns peers grouped by fold: " +
|
|
266
|
+
"same-fold peers first, then others. Excludes the caller.",
|
|
267
|
+
inputSchema: {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: {
|
|
270
|
+
include_inactive: {
|
|
271
|
+
type: "boolean",
|
|
272
|
+
description: "Include inactive/dead peers (default: false)",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "list_branes",
|
|
279
|
+
description: "List all branes hosted by this daemon. Returns brane IDs with narrative " +
|
|
280
|
+
"counts and last-activity timestamps. The caller's own brane is listed first; " +
|
|
281
|
+
"remaining branes are ordered by recency. Use the returned brane_ids with " +
|
|
282
|
+
"narrative_search or narrative_info to scope queries across branes.",
|
|
283
|
+
inputSchema: {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "list_protocols",
|
|
290
|
+
description: "List available protocol documents.",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: "object",
|
|
293
|
+
properties: {},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: "read_protocol",
|
|
298
|
+
description: "Read a protocol document by name.",
|
|
299
|
+
inputSchema: {
|
|
300
|
+
type: "object",
|
|
301
|
+
properties: {
|
|
302
|
+
name: {
|
|
303
|
+
type: "string",
|
|
304
|
+
description: "Protocol name (e.g., 'LIVE_loop', 'ramify')",
|
|
305
|
+
},
|
|
306
|
+
no_prelude: {
|
|
307
|
+
type: "boolean",
|
|
308
|
+
description: "Skip the common protocol prelude (default: false). " +
|
|
309
|
+
"Set to true if you have already read it in this session.",
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
required: ["name"],
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "list_wiki",
|
|
317
|
+
description: "List wiki pages reachable from the caller's fold. Returns each page's " +
|
|
318
|
+
"slug (its path under wiki/, with .md), title, human-authored description " +
|
|
319
|
+
"(from frontmatter, else the first prose line), source, and whether it is " +
|
|
320
|
+
"shared. A fold's wiki is `wiki/` plus an optional `wiki/shared/`: pages " +
|
|
321
|
+
"under shared/ (slug `shared/foo.md`) are visible from every fold; pages " +
|
|
322
|
+
"directly under wiki/ are visible only to the fold's own agents. The " +
|
|
323
|
+
"install directory is a fold like any other, so brane's own shipped pages " +
|
|
324
|
+
"arrive as that fold's shared/ pages. `source` is the fold's basename " +
|
|
325
|
+
"(`~2`, `~3` when two folds share one) — provenance is always explicit. " +
|
|
326
|
+
"Defaults to the caller's fold (all of it) plus the install fold's shared " +
|
|
327
|
+
"pages; pass `all: true` to reach every registered fold's shared pages. " +
|
|
328
|
+
"Companion to read_wiki and search_wiki.",
|
|
329
|
+
inputSchema: {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
source: {
|
|
333
|
+
type: "string",
|
|
334
|
+
description: "Restrict to one source label (a fold basename), or 'all'. Naming a " +
|
|
335
|
+
"non-default source widens the reachable set to include its shared pages.",
|
|
336
|
+
},
|
|
337
|
+
order: {
|
|
338
|
+
type: "string",
|
|
339
|
+
enum: ["slug", "title", "mtime"],
|
|
340
|
+
description: "Sort key (default: slug).",
|
|
341
|
+
},
|
|
342
|
+
filter: {
|
|
343
|
+
type: "string",
|
|
344
|
+
description: "Case-insensitive substring filter over slug, title, and description.",
|
|
345
|
+
},
|
|
346
|
+
all: {
|
|
347
|
+
type: "boolean",
|
|
348
|
+
description: "Include every registered fold's shared pages, not just the caller's fold.",
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "read_wiki",
|
|
355
|
+
description: "Read a wiki page by slug, stamped with its source for transparent " +
|
|
356
|
+
"provenance. Returns the page (or a single section via `anchor`), the " +
|
|
357
|
+
"table of contents (heading anchors), and a citation string of the form " +
|
|
358
|
+
"`slug` or `slug#anchor` — the wiki analogue of an `nid:seq` reference. " +
|
|
359
|
+
"Paginates over the page's markdown chunks via `offset`/`limit`. If " +
|
|
360
|
+
"`source` is omitted and the slug is unique across reachable wikis, it is " +
|
|
361
|
+
"resolved automatically.",
|
|
362
|
+
inputSchema: {
|
|
363
|
+
type: "object",
|
|
364
|
+
properties: {
|
|
365
|
+
slug: {
|
|
366
|
+
type: "string",
|
|
367
|
+
description: "Page slug, with or without the .md extension (e.g. 'live-loop', " +
|
|
368
|
+
"'shared/premature-ontologies'). A bare name that isn't a local page " +
|
|
369
|
+
"falls through to shared/<name>.md. Reading another fold's local page " +
|
|
370
|
+
"errors with 'local, not shared' rather than not-found.",
|
|
371
|
+
},
|
|
372
|
+
source: {
|
|
373
|
+
type: "string",
|
|
374
|
+
description: "Source label (a fold basename). Optional when the slug is " +
|
|
375
|
+
"unambiguous; required to disambiguate a slug present in several sources.",
|
|
376
|
+
},
|
|
377
|
+
anchor: {
|
|
378
|
+
type: "string",
|
|
379
|
+
description: "Return only the section under this heading anchor (GitHub-style slug, " +
|
|
380
|
+
"with or without a leading '#').",
|
|
381
|
+
},
|
|
382
|
+
offset: {
|
|
383
|
+
type: "number",
|
|
384
|
+
description: "Chunk offset for pagination (default: 0).",
|
|
385
|
+
},
|
|
386
|
+
limit: {
|
|
387
|
+
type: "number",
|
|
388
|
+
description: "Max chunks to return (default: the whole page/section).",
|
|
389
|
+
},
|
|
390
|
+
window: {
|
|
391
|
+
type: "number",
|
|
392
|
+
description: "Opt into neighbor-aware reading: return one section's content plus " +
|
|
393
|
+
"this many heading-only neighbors on each side (prev/next, with a " +
|
|
394
|
+
"1-line preview) so you can walk the page section-by-section. " +
|
|
395
|
+
"Pairs with `anchor` (omit `anchor` to start at the page top). " +
|
|
396
|
+
"Default 3 when set, max 10. Overrides offset/limit.",
|
|
397
|
+
},
|
|
398
|
+
all: {
|
|
399
|
+
type: "boolean",
|
|
400
|
+
description: "Widen source resolution to every registered fold's wiki.",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
required: ["slug"],
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: "search_wiki",
|
|
408
|
+
description: "Semantic + keyword search across wiki pages, like narrative_search but " +
|
|
409
|
+
"over the curated wikis instead of the narrative event log. Returns the " +
|
|
410
|
+
"top matching chunks (sections) grouped by article (page), the way " +
|
|
411
|
+
"narrative_search returns messages that belong to a narrative: each " +
|
|
412
|
+
"article carries its source, slug, and best-hit score, with a `hits` " +
|
|
413
|
+
"array of the matching sections — each a chunk with its anchor and a " +
|
|
414
|
+
"`slug#anchor` citation (the wiki analogue of an `nid:seq` reference). " +
|
|
415
|
+
"`limit` bounds the number of chunk hits, not the number of articles. " +
|
|
416
|
+
"The index self-syncs on call (re-embedding only changed sections). " +
|
|
417
|
+
"Defaults to the caller's fold (local and shared pages) plus the install " +
|
|
418
|
+
"fold's shared pages; pass `all: true` to search every registered fold's " +
|
|
419
|
+
"shared pages. Another fold's local pages are never searchable from here.",
|
|
420
|
+
inputSchema: {
|
|
421
|
+
type: "object",
|
|
422
|
+
properties: {
|
|
423
|
+
query: {
|
|
424
|
+
type: "string",
|
|
425
|
+
description: "Search query (short phrases work best).",
|
|
426
|
+
},
|
|
427
|
+
limit: {
|
|
428
|
+
type: "number",
|
|
429
|
+
description: "Maximum number of chunk hits (sections) to return, grouped by " +
|
|
430
|
+
"article (default: 10).",
|
|
431
|
+
},
|
|
432
|
+
all: {
|
|
433
|
+
type: "boolean",
|
|
434
|
+
description: "Search every registered fold's shared pages, not just the caller's fold.",
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
required: ["query"],
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
];
|
|
441
|
+
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
442
|
+
const MAX_RECONNECT_TIME_MS = 5 * 60_000; // Give up after 5 minutes
|
|
443
|
+
/**
|
|
444
|
+
* Thin MCP stdio bridge. Handles MCP JSON-RPC protocol locally
|
|
445
|
+
* and forwards tool calls to the daemon via WebSocket.
|
|
446
|
+
*
|
|
447
|
+
* Automatically reconnects to the daemon on disconnect with
|
|
448
|
+
* exponential backoff, re-resolving the URL each attempt.
|
|
449
|
+
*/
|
|
450
|
+
/** Look up a pid's parent pid via `ps`. Returns 0 if unavailable. */
|
|
451
|
+
function parentPid(pid) {
|
|
452
|
+
try {
|
|
453
|
+
const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
|
|
454
|
+
return parseInt(out.trim(), 10) || 0;
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Pure parent-chain walk: starting at `startPid`, ask `lookupSession` for a
|
|
462
|
+
* session id at each pid, climbing via `getParent` (up to `maxHops`) until one
|
|
463
|
+
* is found or the chain ends. Extracted from IO so it can be unit-tested with
|
|
464
|
+
* injected fakes.
|
|
465
|
+
*/
|
|
466
|
+
export function walkProcessTreeForSession(startPid, lookupSession, getParent, maxHops = 6) {
|
|
467
|
+
let pid = startPid;
|
|
468
|
+
for (let hop = 0; hop < maxHops && pid > 1; hop++) {
|
|
469
|
+
const sessionId = lookupSession(pid);
|
|
470
|
+
if (sessionId)
|
|
471
|
+
return sessionId;
|
|
472
|
+
pid = getParent(pid);
|
|
473
|
+
}
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Parse a `~/.claude/sessions/<pid>.json` registry entry and return its
|
|
478
|
+
* `sessionId` only if the entry's `cwd` matches `expectedCwd`. The cwd guard
|
|
479
|
+
* defends against pid reuse: that registry holds stale files for since-exited
|
|
480
|
+
* processes, so a recycled pid could otherwise point a bridge at an unrelated
|
|
481
|
+
* session in a different directory — the exact wrong-tag the design avoids.
|
|
482
|
+
* The bridge runs in the fold (the same cwd the Claude process recorded), so a
|
|
483
|
+
* genuine ancestor always matches.
|
|
484
|
+
*/
|
|
485
|
+
export function sessionIdFromRegistryEntry(rawJson, expectedCwd) {
|
|
486
|
+
let entry;
|
|
487
|
+
try {
|
|
488
|
+
entry = JSON.parse(rawJson);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
if (!entry.sessionId || !entry.cwd)
|
|
494
|
+
return undefined;
|
|
495
|
+
if (resolve(entry.cwd) !== resolve(expectedCwd))
|
|
496
|
+
return undefined;
|
|
497
|
+
return entry.sessionId;
|
|
498
|
+
}
|
|
499
|
+
/** Read a Claude session id out of `~/.claude/sessions/<pid>.json`, if present. */
|
|
500
|
+
function lookupClaudeSession(pid) {
|
|
501
|
+
try {
|
|
502
|
+
const path = join(homedir(), ".claude", "sessions", `${pid}.json`);
|
|
503
|
+
return sessionIdFromRegistryEntry(readFileSync(path, "utf8"), process.cwd());
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Fallback for agent versions that don't export a session id into the
|
|
511
|
+
* environment (e.g. Claude Code older than CLAUDE_CODE_SESSION_ID): recover the
|
|
512
|
+
* session id by walking up the bridge's parent-process chain and looking each
|
|
513
|
+
* ancestor pid up in Claude's live session registry
|
|
514
|
+
* (`~/.claude/sessions/<pid>.json`). The bridge is spawned as a child of the
|
|
515
|
+
* Claude Code process, so the owning pid is an ancestor — usually the immediate
|
|
516
|
+
* parent, making this a zero-subprocess lookup in the common case.
|
|
517
|
+
*
|
|
518
|
+
* Best-effort and Claude-specific: for other agents the ancestor pids simply
|
|
519
|
+
* aren't in this registry, so it returns undefined and the bridge degrades to
|
|
520
|
+
* "no session id" exactly as before. Any error is swallowed.
|
|
521
|
+
*/
|
|
522
|
+
function resolveClaudeSessionFromProcessTree(maxHops = 6) {
|
|
523
|
+
return walkProcessTreeForSession(process.ppid, lookupClaudeSession, parentPid, maxHops);
|
|
524
|
+
}
|
|
525
|
+
export function startMcpBridge(resolveUrl) {
|
|
526
|
+
// Best-effort initial guess. For spawned agents this is the real id (the
|
|
527
|
+
// daemon set BRANE_NARRATIVE_ID in the tmux env). For unspawned clients it's
|
|
528
|
+
// a placeholder the daemon will *replace* with a server-minted id, delivered
|
|
529
|
+
// back via the `welcome` message — see the welcome handler below. We adopt
|
|
530
|
+
// that authoritative id so reconnects reuse the same narrative.
|
|
531
|
+
let narrativeId = process.env.BRANE_NARRATIVE_ID ?? `external-${Date.now().toString(36)}`;
|
|
532
|
+
// The agent's own session ID, inherited from the parent agent process
|
|
533
|
+
// (e.g. CLAUDE_CODE_SESSION_ID). Forwarded to the daemon so it can stamp
|
|
534
|
+
// per-message provenance (meta.session) on every event this bridge writes.
|
|
535
|
+
// When the env var is absent (older CLIs that predate it), fall back to the
|
|
536
|
+
// process-tree + session-registry lookup so live capture still works.
|
|
537
|
+
const agentSessionId = process.env.CLAUDE_CODE_SESSION_ID ?? resolveClaudeSessionFromProcessTree();
|
|
538
|
+
// Per-narrative bind secret (set by the daemon in the tmux env at spawn/revive,
|
|
539
|
+
// next to BRANE_NARRATIVE_ID). Forwarded as `&secret=` so the daemon can verify
|
|
540
|
+
// this bridge genuinely owns `narrativeId` before binding to it.
|
|
541
|
+
//
|
|
542
|
+
// Absent on an unspawned client's *first* dial, which is the one connection
|
|
543
|
+
// that has nothing to prove — the daemon mints it a narrative and returns
|
|
544
|
+
// that narrative's secret in `welcome`, which we adopt below. Every
|
|
545
|
+
// reconnect after that is authenticated like any other, which is what keeps
|
|
546
|
+
// an external client on one narrative now that the unauthenticated grace
|
|
547
|
+
// path is gone.
|
|
548
|
+
let bindSecret = process.env.BRANE_NARRATIVE_SECRET;
|
|
549
|
+
// Set once the daemon has *revoked* this session's token: a 4401 close,
|
|
550
|
+
// which it sends only when a revoke or rotation retires the token. Nothing
|
|
551
|
+
// this bridge holds can get it back in, so it stops dialing and says so on
|
|
552
|
+
// every call. A bare 401 on the upgrade is not proof — a bridge that dialed
|
|
553
|
+
// a daemon still running older code, or between a rebuild and a restart,
|
|
554
|
+
// gets one too, and the next daemon may accept the same token — so that
|
|
555
|
+
// only marks `refused`, and the bridge keeps retrying (claude-jrjh1e8w:13 #5).
|
|
556
|
+
let revoked = false;
|
|
557
|
+
let refused = false;
|
|
558
|
+
const REFUSED_TEXT = "Error: the brane daemon is refusing this session's token (401); retrying. " +
|
|
559
|
+
"If it was revoked, the operator can revive the session.";
|
|
560
|
+
const REVOKED_TEXT = "Error: this session's brane token was revoked, so its tools no longer work. " +
|
|
561
|
+
"Ask the operator to revive the session if it should continue.";
|
|
562
|
+
// Pending tool calls: daemon callId → MCP request id
|
|
563
|
+
const pendingCalls = new Map();
|
|
564
|
+
// …and which of them are narrates, whose failure needs different advice.
|
|
565
|
+
const pendingNarrates = new Set();
|
|
566
|
+
let callCounter = 0;
|
|
567
|
+
let ws = null;
|
|
568
|
+
let wsReady = false;
|
|
569
|
+
let stdinClosed = false;
|
|
570
|
+
// Buffer messages until WS is open. A queued tool call carries its callId,
|
|
571
|
+
// because a call that was never sent is not ambiguous the way a sent one is.
|
|
572
|
+
const sendQueue = [];
|
|
573
|
+
// Calls handed to a socket, whose fate a lost connection makes unknowable.
|
|
574
|
+
const inFlight = new Set();
|
|
575
|
+
// Reconnection state
|
|
576
|
+
let reconnectDelay = 1000;
|
|
577
|
+
let reconnectStart = 0;
|
|
578
|
+
let reconnectTimer = null;
|
|
579
|
+
function wsSend(msg, callId) {
|
|
580
|
+
if (wsReady && ws) {
|
|
581
|
+
ws.send(msg);
|
|
582
|
+
if (callId !== undefined)
|
|
583
|
+
inFlight.add(callId);
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
sendQueue.push({ msg, callId });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** Answers every call a lost connection has made ambiguous — those already
|
|
590
|
+
* handed to the socket — and nothing else. A call still in `sendQueue` was
|
|
591
|
+
* never sent: it stays queued and goes out on reconnect, exactly once.
|
|
592
|
+
* Failing it too, as this used to, answered the agent with an error *and*
|
|
593
|
+
* sent the call anyway on the next connect: a "please retry" that, taken
|
|
594
|
+
* up, ran it twice, and a waiting narrate parked on the daemon with nobody
|
|
595
|
+
* left to answer. */
|
|
596
|
+
function failPendingCalls() {
|
|
597
|
+
for (const [callId, mcpId] of pendingCalls) {
|
|
598
|
+
if (!inFlight.has(callId))
|
|
599
|
+
continue;
|
|
600
|
+
// A narrate may have been posted before the connection went — and one
|
|
601
|
+
// with `wait: true` certainly was, since it can be parked for up to an
|
|
602
|
+
// hour after writing. "Retry" would post it twice.
|
|
603
|
+
const text = pendingNarrates.has(callId)
|
|
604
|
+
? "Error: daemon connection lost (reconnecting). Your narration may already have been posted — " +
|
|
605
|
+
"check with listen({ wait: false }) before narrating it again."
|
|
606
|
+
: "Error: daemon connection lost (reconnecting — please retry)";
|
|
607
|
+
respond(mcpId, { content: [{ type: "text", text }], isError: true });
|
|
608
|
+
pendingCalls.delete(callId);
|
|
609
|
+
pendingNarrates.delete(callId);
|
|
610
|
+
}
|
|
611
|
+
inFlight.clear();
|
|
612
|
+
}
|
|
613
|
+
/** The client gave up on a call (MCP `notifications/cancelled`: Esc, or a
|
|
614
|
+
* killed background task). Forget it, so a late result is never sent to a
|
|
615
|
+
* client that has stopped expecting one, and tell the daemon — a parked
|
|
616
|
+
* listen otherwise stays parked, and its narrative keeps showing as waiting
|
|
617
|
+
* for direction, until the next event or the hour-long timeout. A call
|
|
618
|
+
* still queued was never sent, so it is simply dropped. */
|
|
619
|
+
function cancelCall(mcpId) {
|
|
620
|
+
if (mcpId === undefined)
|
|
621
|
+
return;
|
|
622
|
+
let callId;
|
|
623
|
+
for (const [c, m] of pendingCalls) {
|
|
624
|
+
if (m === mcpId) {
|
|
625
|
+
callId = c;
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (callId === undefined)
|
|
630
|
+
return;
|
|
631
|
+
pendingCalls.delete(callId);
|
|
632
|
+
pendingNarrates.delete(callId);
|
|
633
|
+
const queued = sendQueue.findIndex((q) => q.callId === callId);
|
|
634
|
+
if (queued >= 0) {
|
|
635
|
+
sendQueue.splice(queued, 1);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (inFlight.delete(callId)) {
|
|
639
|
+
wsSend(JSON.stringify({ type: "tool_cancel", id: callId }));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function connect(target) {
|
|
643
|
+
const { url, socketPath } = typeof target === "string" ? { url: target, socketPath: undefined } : target;
|
|
644
|
+
wsReady = false;
|
|
645
|
+
const sessionParam = agentSessionId
|
|
646
|
+
? `&session=${encodeURIComponent(agentSessionId)}`
|
|
647
|
+
: "";
|
|
648
|
+
const secretParam = bindSecret
|
|
649
|
+
? `&secret=${encodeURIComponent(bindSecret)}`
|
|
650
|
+
: "";
|
|
651
|
+
let fullUrl = `${url}&nid=${encodeURIComponent(narrativeId)}${sessionParam}${secretParam}`;
|
|
652
|
+
// The session's own token, when it has one, is what authenticates it —
|
|
653
|
+
// whatever the resolver put in the URL. That is what
|
|
654
|
+
// makes this agent its own revocable row. (An unspawned client's first
|
|
655
|
+
// dial has none and presents nothing over the daemon's socket, as this
|
|
656
|
+
// account; `welcome` then hands it one.)
|
|
657
|
+
if (bindSecret) {
|
|
658
|
+
const u = new URL(fullUrl);
|
|
659
|
+
u.searchParams.set("token", bindSecret);
|
|
660
|
+
fullUrl = u.toString();
|
|
661
|
+
}
|
|
662
|
+
// When connecting to wss://localhost with a Tailscale cert, the hostname
|
|
663
|
+
// won't match — skip TLS verification for local daemon connections
|
|
664
|
+
const wsOptions = socketPath
|
|
665
|
+
? { createConnection: () => netConnect(socketPath) }
|
|
666
|
+
: fullUrl.startsWith("wss://localhost") ? { rejectUnauthorized: false } : {};
|
|
667
|
+
ws = new WebSocket(fullUrl, wsOptions);
|
|
668
|
+
ws.on("open", () => {
|
|
669
|
+
wsReady = true;
|
|
670
|
+
refused = false;
|
|
671
|
+
reconnectDelay = 1000; // Reset backoff on success
|
|
672
|
+
reconnectStart = 0;
|
|
673
|
+
process.stderr.write("MCP bridge: connected to daemon\n");
|
|
674
|
+
for (const { msg, callId } of sendQueue) {
|
|
675
|
+
ws.send(msg);
|
|
676
|
+
if (callId !== undefined)
|
|
677
|
+
inFlight.add(callId);
|
|
678
|
+
}
|
|
679
|
+
sendQueue.length = 0;
|
|
680
|
+
});
|
|
681
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
682
|
+
// A 401 on the upgrade: the token is not live. Only an agent token can
|
|
683
|
+
// be revoked out from under a running bridge, so that is what it means.
|
|
684
|
+
if (res.statusCode === 401)
|
|
685
|
+
refused = true;
|
|
686
|
+
ws?.terminate();
|
|
687
|
+
});
|
|
688
|
+
ws.on("error", (err) => {
|
|
689
|
+
process.stderr.write(`MCP bridge: WebSocket error: ${err.message}\n`);
|
|
690
|
+
// close event will fire next, triggering reconnection
|
|
691
|
+
});
|
|
692
|
+
ws.on("close", (code) => {
|
|
693
|
+
wsReady = false;
|
|
694
|
+
ws = null;
|
|
695
|
+
// 4401: the daemon closed this socket because its token was revoked.
|
|
696
|
+
if (code === 4401)
|
|
697
|
+
revoked = true;
|
|
698
|
+
if (stdinClosed && pendingCalls.size === 0) {
|
|
699
|
+
process.exit(0);
|
|
700
|
+
}
|
|
701
|
+
failPendingCalls();
|
|
702
|
+
if (revoked) {
|
|
703
|
+
process.stderr.write("MCP bridge: this session's token was revoked; not reconnecting\n");
|
|
704
|
+
for (const [callId, mcpId] of pendingCalls) {
|
|
705
|
+
respond(mcpId, { content: [{ type: "text", text: REVOKED_TEXT }], isError: true });
|
|
706
|
+
pendingCalls.delete(callId);
|
|
707
|
+
pendingNarrates.delete(callId);
|
|
708
|
+
}
|
|
709
|
+
sendQueue.length = 0;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
scheduleReconnect();
|
|
713
|
+
});
|
|
714
|
+
ws.on("message", (data) => {
|
|
715
|
+
try {
|
|
716
|
+
const msg = JSON.parse(data.toString());
|
|
717
|
+
if (msg.type === "welcome" && typeof msg.narrative_id === "string") {
|
|
718
|
+
// The daemon's id is authoritative — it may have minted a fresh
|
|
719
|
+
// server-side id for an unspawned client. Adopt it so subsequent
|
|
720
|
+
// reconnects (which carry narrativeId in the URL) reuse this narrative
|
|
721
|
+
// instead of fragmenting into a new one each time.
|
|
722
|
+
narrativeId = msg.narrative_id;
|
|
723
|
+
// And its secret, for the same reason: without it the reconnect
|
|
724
|
+
// carrying that id would be refused and minted a *different*
|
|
725
|
+
// narrative, one per reconnect. Only overwrite from a string, so a
|
|
726
|
+
// daemon too old to send the field leaves the env-supplied secret
|
|
727
|
+
// (a spawned agent's) alone rather than clearing it.
|
|
728
|
+
if (typeof msg.bind_secret === "string")
|
|
729
|
+
bindSecret = msg.bind_secret;
|
|
730
|
+
}
|
|
731
|
+
if (msg.type === "tool_result") {
|
|
732
|
+
const mcpId = pendingCalls.get(msg.id);
|
|
733
|
+
if (mcpId !== undefined) {
|
|
734
|
+
pendingCalls.delete(msg.id);
|
|
735
|
+
pendingNarrates.delete(msg.id);
|
|
736
|
+
inFlight.delete(msg.id);
|
|
737
|
+
respond(mcpId, {
|
|
738
|
+
content: [{ type: "text", text: JSON.stringify(msg.result, null, 2) }],
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (msg.type === "tool_error") {
|
|
743
|
+
const mcpId = pendingCalls.get(msg.id);
|
|
744
|
+
if (mcpId !== undefined) {
|
|
745
|
+
pendingCalls.delete(msg.id);
|
|
746
|
+
pendingNarrates.delete(msg.id);
|
|
747
|
+
inFlight.delete(msg.id);
|
|
748
|
+
respond(mcpId, {
|
|
749
|
+
content: [{ type: "text", text: `Error: ${msg.error}` }],
|
|
750
|
+
isError: true,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// Ignore malformed daemon messages
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
function scheduleReconnect() {
|
|
761
|
+
if (stdinClosed)
|
|
762
|
+
return;
|
|
763
|
+
if (reconnectStart === 0) {
|
|
764
|
+
reconnectStart = Date.now();
|
|
765
|
+
}
|
|
766
|
+
else if (Date.now() - reconnectStart > MAX_RECONNECT_TIME_MS) {
|
|
767
|
+
process.stderr.write("MCP bridge: reconnection timed out after 5 minutes, exiting\n");
|
|
768
|
+
process.exit(1);
|
|
769
|
+
}
|
|
770
|
+
process.stderr.write(`MCP bridge: reconnecting in ${Math.round(reconnectDelay / 1000)}s...\n`);
|
|
771
|
+
reconnectTimer = setTimeout(async () => {
|
|
772
|
+
reconnectTimer = null;
|
|
773
|
+
try {
|
|
774
|
+
const url = await resolveUrl();
|
|
775
|
+
if (url) {
|
|
776
|
+
connect(url);
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
// Daemon not available yet, retry with more backoff
|
|
780
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
781
|
+
scheduleReconnect();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
786
|
+
scheduleReconnect();
|
|
787
|
+
}
|
|
788
|
+
}, reconnectDelay);
|
|
789
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
790
|
+
}
|
|
791
|
+
// Initial connection
|
|
792
|
+
resolveUrl().then((url) => {
|
|
793
|
+
if (!url) {
|
|
794
|
+
process.stderr.write("MCP bridge: daemon not available\n");
|
|
795
|
+
process.exit(1);
|
|
796
|
+
}
|
|
797
|
+
connect(url);
|
|
798
|
+
});
|
|
799
|
+
const rl = createInterface({ input: process.stdin });
|
|
800
|
+
rl.on("line", (line) => {
|
|
801
|
+
let request;
|
|
802
|
+
try {
|
|
803
|
+
request = JSON.parse(line);
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (request.method === "initialize") {
|
|
809
|
+
respond(request.id, {
|
|
810
|
+
protocolVersion: "2024-11-05",
|
|
811
|
+
capabilities: { tools: {} },
|
|
812
|
+
serverInfo: {
|
|
813
|
+
name: "brane",
|
|
814
|
+
version: "0.1.0",
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (request.method === "notifications/initialized") {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (request.method === "tools/list") {
|
|
823
|
+
respond(request.id, { tools: TOOLS });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (request.method === "tools/call") {
|
|
827
|
+
const params = request.params;
|
|
828
|
+
const toolName = params.name;
|
|
829
|
+
const args = params.arguments ?? {};
|
|
830
|
+
if (revoked || refused) {
|
|
831
|
+
respond(request.id, { content: [{ type: "text", text: revoked ? REVOKED_TEXT : REFUSED_TEXT }], isError: true });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// Forward to daemon via WebSocket (buffered until connected)
|
|
835
|
+
const callId = String(++callCounter);
|
|
836
|
+
pendingCalls.set(callId, request.id);
|
|
837
|
+
if (toolName === "narrate")
|
|
838
|
+
pendingNarrates.add(callId);
|
|
839
|
+
wsSend(JSON.stringify({ type: "tool_call", id: callId, tool: toolName, args }), callId);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (request.method === "notifications/cancelled") {
|
|
843
|
+
cancelCall(request.params?.requestId);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
// A notification is never answered, known or not. Answering this one used
|
|
847
|
+
// to hand the client an error with no id, which Claude Code logs as a
|
|
848
|
+
// connection error ("Received a response for an unknown message ID").
|
|
849
|
+
if (request.id === undefined || request.id === null)
|
|
850
|
+
return;
|
|
851
|
+
// Unknown method
|
|
852
|
+
respondError(request.id, -32601, `Method not found: ${request.method}`);
|
|
853
|
+
});
|
|
854
|
+
rl.on("close", () => {
|
|
855
|
+
stdinClosed = true;
|
|
856
|
+
if (reconnectTimer) {
|
|
857
|
+
clearTimeout(reconnectTimer);
|
|
858
|
+
}
|
|
859
|
+
if (pendingCalls.size === 0) {
|
|
860
|
+
if (ws)
|
|
861
|
+
ws.close();
|
|
862
|
+
process.exit(0);
|
|
863
|
+
}
|
|
864
|
+
const drainCheck = setInterval(() => {
|
|
865
|
+
if (pendingCalls.size === 0) {
|
|
866
|
+
clearInterval(drainCheck);
|
|
867
|
+
if (ws)
|
|
868
|
+
ws.close();
|
|
869
|
+
process.exit(0);
|
|
870
|
+
}
|
|
871
|
+
}, 50);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
function respond(id, result) {
|
|
875
|
+
const response = JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
876
|
+
process.stdout.write(response + "\n");
|
|
877
|
+
}
|
|
878
|
+
function respondError(id, code, message) {
|
|
879
|
+
const response = JSON.stringify({
|
|
880
|
+
jsonrpc: "2.0",
|
|
881
|
+
id,
|
|
882
|
+
error: { code, message },
|
|
883
|
+
});
|
|
884
|
+
process.stdout.write(response + "\n");
|
|
885
|
+
}
|
|
886
|
+
//# sourceMappingURL=server.js.map
|