@sjawhar/claude-envoy 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/plugin.json +6 -0
  4. package/AGENTS.md +343 -0
  5. package/README.md +639 -0
  6. package/bin/dispatch +6 -0
  7. package/bin/envoy-channel.ts +9 -0
  8. package/bin/envoy-send.ts +33 -0
  9. package/dist/THIRD_PARTY_NOTICES +816 -0
  10. package/dist/dispatch.js +27707 -0
  11. package/dist/envoy-channel.js +57695 -0
  12. package/dist/session-hook.js +26328 -0
  13. package/dist/session-title-hook.js +116 -0
  14. package/hooks/hooks.json +60 -0
  15. package/hooks/session-hook.ts +512 -0
  16. package/hooks/session-title-hook.ts +46 -0
  17. package/legion/.claude-plugin/plugin.json +4 -0
  18. package/legion/agents/deep-worker.md +68 -0
  19. package/legion/agents/oracle.md +35 -0
  20. package/legion/agents/plan-gap-analyst.md +65 -0
  21. package/legion/agents/plan-reviewer.md +104 -0
  22. package/legion/agents/thermonuclear-code-quality.md +30 -0
  23. package/legion/agents/thermonuclear-deep-review.md +30 -0
  24. package/legion/hooks/hooks.json +89 -0
  25. package/legion/skills/ce-simplify-code/LICENSE +21 -0
  26. package/legion/skills/ce-simplify-code/SKILL.md +66 -0
  27. package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  28. package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  29. package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  30. package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
  31. package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
  32. package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
  33. package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
  34. package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
  35. package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
  36. package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
  37. package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
  38. package/legion/skills/legion-architect/SKILL.md +86 -0
  39. package/legion/skills/legion-architect/references/acceptance.md +208 -0
  40. package/legion/skills/legion-architect/references/decomposition.md +129 -0
  41. package/legion/skills/legion-architect/references/design-gate.md +206 -0
  42. package/legion/skills/legion-architect/references/merge-notices.md +91 -0
  43. package/legion/skills/legion-architect/references/wake-routing.md +41 -0
  44. package/legion/skills/legion-controller/SKILL.md +151 -0
  45. package/legion/skills/legion-controller/references/admission.md +22 -0
  46. package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
  47. package/legion/skills/legion-controller/references/daily-report.md +75 -0
  48. package/legion/skills/legion-controller/references/escalation.md +82 -0
  49. package/legion/skills/legion-controller/references/finding-asks.md +181 -0
  50. package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
  51. package/legion/skills/legion-controller/references/session.md +100 -0
  52. package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
  53. package/legion/skills/legion-controller/references/triage.md +96 -0
  54. package/legion/skills/legion-controller/references/wakes.md +132 -0
  55. package/legion/skills/legion-oracle/SKILL.md +48 -0
  56. package/legion/skills/legion-retro/SKILL.md +112 -0
  57. package/legion/skills/legion-retro/references/learnings.md +60 -0
  58. package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
  59. package/legion/skills/legion-worker/SKILL.md +69 -0
  60. package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
  61. package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
  62. package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
  63. package/legion/skills/legion-worker/references/findings.md +199 -0
  64. package/legion/skills/legion-worker/references/merge-gate.md +353 -0
  65. package/legion/skills/legion-worker/references/pr-body.md +218 -0
  66. package/legion/skills/legion-worker/references/review-threads.md +77 -0
  67. package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
  68. package/legion/skills/legion-worker/references/tools.md +708 -0
  69. package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
  70. package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
  71. package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
  72. package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
  73. package/package.json +49 -1
  74. package/scripts/build-inputs.txt +84 -0
  75. package/scripts/build.ts +415 -0
  76. package/scripts/check-packed-skills.sh +50 -0
  77. package/scripts/pack-skills.sh +146 -0
  78. package/scripts/smoke-channel.sh +178 -0
  79. package/scripts/smoke-clear-rebind.sh +164 -0
  80. package/scripts/smoke-session-title.sh +245 -0
  81. package/scripts/verify-build.sh +88 -0
  82. package/skills/agent-secrets/SKILL.md +180 -0
  83. package/skills/asd-ste100/LICENSE +21 -0
  84. package/skills/asd-ste100/SKILL.md +140 -0
  85. package/skills/asd-ste100/examples/before-after.md +92 -0
  86. package/skills/asd-ste100/references/writing-rules.md +59 -0
  87. package/skills/dispatch/SKILL.md +89 -0
  88. package/skills/dispatch-first/SKILL.md +46 -0
  89. package/skills/dispatch-writing-specs/SKILL.md +80 -0
  90. package/skills/envoy/SKILL.md +82 -0
  91. package/skills/envoy/references/check-settlement.md +62 -0
  92. package/skills/envoy/references/deliveries.md +67 -0
  93. package/skills/envoy/references/legion.md +14 -0
  94. package/skills/envoy/references/roles.md +12 -0
  95. package/skills/envoy/references/sources.md +41 -0
  96. package/skills/outside-message/SKILL.md +111 -0
  97. package/skills/plain-words/SKILL.md +88 -0
  98. package/src/channel-forwarder.ts +262 -0
  99. package/src/claude-channel.ts +705 -0
  100. package/src/claude-session.ts +38 -0
  101. package/src/envoy-channel-server.ts +2115 -0
  102. package/src/envoy-client.ts +26 -0
  103. package/src/envoy-off.ts +62 -0
  104. package/src/legion-claim.ts +211 -0
  105. package/src/legion-inbox.ts +110 -0
  106. package/src/legion-phase-hook.ts +554 -0
  107. package/src/legion-tool.ts +69 -0
  108. package/src/replay-state.ts +97 -0
  109. package/src/send-arguments.ts +13 -0
  110. package/src/session-identity.ts +165 -0
  111. package/src/session-state.ts +142 -0
  112. package/src/session-title.ts +251 -0
  113. package/src/subscription-topics.ts +16 -0
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "claude-envoy",
3
+ "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
4
+ "mcpServers": "./.mcp.json",
5
+ "version": "1.0.0"
6
+ }
package/.mcp.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "envoy": {
4
+ "command": "bun",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/envoy-channel.js"],
6
+ "env": {
7
+ "CLAUDE_PROJECT_DIR": "${CLAUDE_PROJECT_DIR}"
8
+ }
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "claude-envoy",
3
+ "version": "1.0.0",
4
+ "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
5
+ "mcpServers": {}
6
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,343 @@
1
+ # Claude Envoy Plugin Package
2
+
3
+ Claude Code channel plugin package for Legion's Envoy subsystem.
4
+
5
+ ## Overview
6
+
7
+ One MCP stdio process is the channel ingress. It subscribes NATS directly to the session's
8
+ `notifications.agent.<session_id>` subject and every topic the session follows — the lane receipts,
9
+ the live team topics and the core fallback ride those subscriptions, not the session's messages,
10
+ which come from the relay below. It renders each
11
+ envelope through `@legion/envoy-client/delivery`, then writes a supported
12
+ `notifications/claude/channel` notification. A core-NATS direct delivery gets its empty receipt
13
+ when the server enqueues the notification; that means adapter-accepted, not processed by Claude.
14
+ There is no Monitor and no native Claude messaging socket path. The server's tools are the
15
+ `envoyToolSpecs` alone, the ten `envoy_*` tools and `slack_post`: agents reach Dispatch through the
16
+ `dispatch` command, which `bin/dispatch` puts on the Bash tool's `PATH`.
17
+
18
+ The listener holds a durable consumer for the session and
19
+ relays it on `notifications.envoy.deliver.<session_id>`, which is where the session's messages
20
+ come from ("Session consumers" in
21
+ `packages/envoy/AGENTS.md` states the rule), with the protocol both plugins share,
22
+ `@legion/envoy-client/consumer-delivery`. The server subscribes the current id's relay subject
23
+ before it registers, and every registration asks for consumer delivery with the relay's epoch
24
+ (one per NATS connection; a nats.js `reconnect` begins another and registers at once,
25
+ `renewRelayEpoch`) and its resume point. Answered `consumer`, the relay carries the agent subject
26
+ and every followed topic the stream stores, in stream order: each frame is answered `received`
27
+ as it decodes, goes to the same write queue a live frame takes, and is answered `done` once the
28
+ notifier resolved its write and the ledger recorded it (`deliverRelayed`), at once for a frame
29
+ skipped, already shown or a late BTW, which is passed over. In a Legion pane the notifier
30
+ resolves when `legion claude-bridge` answers its inbox, which it does once Claude Code re-emits
31
+ the delivery as the user message it took (`--replay-user-messages`); in channel mode it resolves
32
+ when the MCP notification write does, the last signal Claude Code gives there. A write that fails
33
+ answers `nak`, and from the frame's third delivery `done` with a notice naming it; a frame that
34
+ arrives after a `/clear` moved the session to another id is answered `nak`. The core copies of
35
+ those subjects answer only a lane's receipt (`relaysCoreTopic`), and the replay engine makes no
36
+ `/v1/replay` call. The relay's snapshot (the resume point and each topic's follow time) is the
37
+ `consumer` part of the id's state file, written whenever it changes. An id with no snapshot of
38
+ its own that has replay cursors — one coming from a release before this relay, or a session whose
39
+ listener gains the grant later — registers the lowest cursor the engine holds over its agent
40
+ subject and its followed stored topics, so the listener creates the consumer there instead of at
41
+ the stream's end and what was stored while the session was away is still relayed, and shown: such
42
+ an id has no follow times either, and a frame stored before a topic's follow time is kept when
43
+ the engine's cursor for it says the core path followed the topic from below that frame's sequence.
44
+ Answered `core`, or not at all, the core path below runs:
45
+ the rollout fallback for a plugin or listener of an earlier release and for a listener whose NATS
46
+ user lacks the session-consumer grant, which dispatch://INFRA-771 removes.
47
+
48
+ On that fallback, what those subscriptions missed while they were down, the server reads back
49
+ from Envoy's notification stream through its listener (`POST /v1/replay`) with the shared engine
50
+ `@legion/envoy-client/replay`, which pi-envoy uses too (its rules are "Missed messages" in
51
+ `packages/pi-envoy/AGENTS.md`). A sweep runs at channel start (a resume included), on each nats.js
52
+ `reconnect` status, on every heartbeat and after a new follow, and shows each stored frame the
53
+ session was not shown, once, through the same path a live frame takes (the channel notification,
54
+ or the Legion bridge's inbox), with a last line saying when it was published while the
55
+ connection was down (`replayedText`, worded by the engine, as are the notices, which its
56
+ `envoyNoticeText` renders in TOON as every live card is); a frame there runs nothing. On this
57
+ path, role messages and team or other ad-hoc topics are not recovered. The live path records
58
+ each frame Claude Code was handed in the engine's ledger before its claim settles, and drops a
59
+ frame the ledger holds (a repeat after a restart). The state is one file per session id,
60
+ `replay-<url-encoded session id>.json` in the
61
+ plugin's state directory (`src/replay-state.ts`, mode 0600, written by rename): the cursors, the
62
+ followed subjects, which the server follows again at start even after the listener reaped its
63
+ interests, and at most 72 hours of ledger, at most 20,000 entries; no sweep prunes these files,
64
+ since Claude Code can resume a session at any later time. A `/clear` handoff to a new id starts a
65
+ fresh file, as a new session does, so nothing from before it comes back. A frame that fails three
66
+ times, or a live frame that fails and no sweep will show (one that arrived before its subject's
67
+ cursor started included, stamped when its subscription yielded it, `readArrivals`, not when the
68
+ frames queued before it finished delivering), is replaced by a notice naming it on
69
+ the same path; a notice that cannot be shown, and a listener that does not replay (404,
70
+ `REPLAY_UNAVAILABLE`, 401 or 403), go to stderr. A crash between the hand-off and the ledger
71
+ write can show a frame twice, never lose it. The host policy pi-envoy shares is the engine's too:
72
+ which subjects are followed for replay (`replayFollowedSubjects`: the agent subject and every
73
+ topic but a role's or one reaching a private stream subject, which registration leaves out too:
74
+ `reachesPrivateStreamSubject`, the listener's rule, another session's agent subject, a
75
+ failed-delivery lane, the Legion controller lane, or a wildcard reaching one), which remembered
76
+ topics come back at start
77
+ (`rememberedTopics`), when a replayed `subscription.removed` closes a topic and takes it out of
78
+ the registry (`closeReplayedRemoval`), and which one-time notices are a card rather than a warning
79
+ (`routeReplayNotice`); this server keeps only how it shows a card, where it warns, and its file.
80
+
81
+ `runEnvoyChannelServer` dials NATS with the shared options of
82
+ `@legion/envoy-client/nats-connection` (`natsConnectionOptions`): a ping every 10 s and the
83
+ connection dropped on the tick that finds two pings unanswered, so a path that freezes without
84
+ closing (packets stop, neither side closes) is left within 30 s and redialed at once, 52 s when
85
+ that redial also hangs on nats.js's 20 s dial timeout and the 2 s reconnect wait; it reconnects
86
+ forever, 2 s apart, and each `reconnect` status starts the sweep above. nats.js's default 120 s
87
+ ping interval left a frozen path for up to 6 minutes. Every registration names the plugin:
88
+ `plugin` `@sjawhar/claude-envoy` and `plugin_version` the version segment of the plugin-cache
89
+ directory the bundle runs from (`…/plugins/cache/<marketplace>/<plugin>/<version>/`, the npm
90
+ version Claude Code installed, since the marketplace entry is an `npm` source;
91
+ `installedPluginVersion` in
92
+ `src/claude-channel.ts`), or `source` for a bundle outside the plugin cache (a checkout,
93
+ `--plugin-dir`); a checkout's `package.json` `version` names no release and is never sent.
94
+
95
+ A session advertises the delivery modes its deliveries' path can honour, never `btw`, and none at
96
+ all when its deliveries are channel notifications Claude Code did not load (`src/claude-channel.ts`;
97
+ a Legion pane's go to the bridge's inbox instead, so the gate does not apply to it). One whose
98
+ deliveries are channel notifications advertises `aside` alone (`CHANNEL_CAPABILITIES`): Claude Code
99
+ queues a notification for the model's next turn, so a `steer` cannot be honoured. A Legion pane
100
+ whose deliveries go to `legion claude-bridge`'s inbox advertises `aside` and `steer`
101
+ (`INBOX_CAPABILITIES` in `src/legion-inbox.ts`): the bridge starts a turn with a delivery when
102
+ Claude Code is idle and sends it as the next user message of the running turn otherwise. `btw`
103
+ remains intentionally unsupported until there is a server-owned pending-delivery/reply-ack design.
104
+ Permission relay is also intentionally absent: without a sender-authenticated reply path and
105
+ reply-ack design, declaring it would allow an Envoy input to decide Claude Code permissions.
106
+ Dispatch asks stay on Dispatch.
107
+
108
+ ## Where to look
109
+
110
+ | Task | Location | Notes |
111
+ | --- | --- | --- |
112
+ | Claude plugin manifest | `.claude-plugin/plugin.json` | Declares the MCP configuration only; it must not declare a Monitor. `hooks/hooks.json` is auto-discovered. The committed file declares no `version`, and neither does the plugin's entry in the root `.claude-plugin/marketplace.json` (tested): what that entry carries is a range on its `npm` source, `version: ">=1.0.0"`, which keeps an install off the 0.0.0 placeholder published so that trusted publishing could attach to the package and otherwise takes `latest`, and `release.yaml`'s `claude_envoy_build` writes the release's version into the `.claude-plugin/plugin.json` it packs. That is what Claude Code reads — it names an installed plugin by that manifest's `version` and never by the version the registry served, so a package published without one is cached as `unknown` and no later release reads as an update (measured on 2.1.311, and the build job's tarball check pins it). `claude plugin marketplace update legion-plugins`, then `claude plugin update claude-envoy@legion-plugins`, installs the newest `@sjawhar/claude-envoy` (README "Updates"). A `version` committed here would instead hold every install at that string until someone changed it. |
113
+ | Oh My Pi plugin manifest | `.omp-plugin/plugin.json` | Empty `mcpServers`, which is how omp is told this plugin has no MCP server: the channel server exits without Claude Code's session identity, and omp gets Envoy and Dispatch from `@sjawhar/pi-envoy`. omp reads this file before `.claude-plugin/plugin.json` and a manifest `mcpServers` replaces `.mcp.json` rather than merging; omitting the key falls through to `.mcp.json` and relaunches the server. Claude Code never reads it. Its `version` equals `package.json`'s (tested); omp reads only the file's `mcpServers` and that it exists, never its `version`. |
114
+ | Skills | `skills/agent-secrets`, `skills/asd-ste100`, `skills/envoy`, `skills/dispatch`, `skills/dispatch-first`, `skills/dispatch-writing-specs`, `skills/outside-message`, `skills/plain-words` | Relative symlinks into the root `skills/`; the plugin ships only these eight, the ones a standalone Claude Code session uses (the rest belong to Legion roles on Oh My Pi; README "Skills"). The pack resolves them, not the install: `bun pm pack` packs a link as nothing at all, so `package.json`'s `prepack` (`scripts/pack-skills.sh`) replaces each link with the skill's own files, which `files`' `skills/**` then packs, and its `postpack` puts the links back. `scripts/check-packed-skills.sh` reads them back out of the tarball for `release.yaml`'s `claude_envoy_build`, and `tests/packed-skills.test.ts` packs a tarball of its own. |
115
+ | Built bundle | `dist/`, `scripts/build.ts`, `scripts/verify-build.sh` | Built, never committed: `release.yaml`'s `claude_envoy_build` builds it and packs it into the tarball it publishes to npm (its `bun run verify-build <tar>` builds it twice in a scratch directory, checks it as the release will and packs it, writing no file of the checkout, and nothing a person runs locally writes `dist/` or `legion/`; the jobs that run `verify-build` are named in its header and the jobs that run `release-push.sh extract-built` in that subcommand's paragraph; convention below), and `bun scripts/build.ts --out <dir>` builds a runnable copy outside the repository. The plugin cache has no `node_modules`, so the four executables (the channel server, the session hook, the title hook and the `dispatch` CLI, `BUNDLE_ENTRYPOINTS`) ship bundled, with `dist/THIRD_PARTY_NOTICES` (the inlined packages' licenses, from Bun's metafile through the root `scripts/third-party-notices.ts`); the build runs only on the pinned Bun (`.bun-version`). A mode folded into an existing bundle is paid for by that bundle's every other mode: an ESM entrypoint evaluates its whole module graph before it reads `argv`, which is why the title hook has an entrypoint of its own and why a hook on a blocking event is weighed by what its bundle imports, not by what its mode runs. |
116
+ | The `dispatch` CLI | `bin/dispatch` (the shim, mode 755), `dist/dispatch.js` (built from `../envoy-client/bin/dispatch.ts`), `hooks/session-hook.ts` | Claude Code puts the plugin's `bin/` on the Bash tool's `PATH`. The `open-asks` hook writes `export DISPATCH_HOST=claude` and `export ENVOY_CONFIG_FILE='<path>'` (the envoy.json Claude Code's rule picks, `$CLAUDE_CONFIG_DIR/envoy.json` when that is set; single-quoted, the form Claude Code's PowerShell bridge also reads) to `CLAUDE_ENV_FILE`, each unless the file already holds that line, since every `SessionStart` of a session runs it; with no workable file location (a relative `CLAUDE_CONFIG_DIR`, `ENVOY_CONFIG_FILE` or `XDG_CONFIG_HOME`) it writes no `ENVOY_CONFIG_FILE` line. The command reads the file `ENVOY_CONFIG_FILE` names whenever `DISPATCH_HOST` is set. A Bash that takes nothing from that file, as a subagent's can (anthropics/claude-code#46696), has `DISPATCH_HOST` unset and the `CLAUDE_CODE_CHILD_SESSION=1` Claude Code sets in every Bash, so its `dispatch` command takes host `claude` and reads `CLAUDE_CONFIG_DIR` itself, by the plugin's own rule. The command reads the session id from `CLAUDE_CODE_SESSION_ID`, which Claude Code sets fresh in every Bash command and moves on `/clear`. The command prints the follow notice itself, once per ask. |
117
+ | Channel configuration | `.mcp.json`, `bin/envoy-channel.ts` | Launch is `bun ${CLAUDE_PLUGIN_ROOT}/dist/envoy-channel.js`; stdout remains pure MCP protocol. The `env` block passes through `CLAUDE_PROJECT_DIR` only — an unset `${VAR}` is substituted literally, so optional inputs are inherited, never listed. |
118
+ | Hook command | `hooks/hooks.json`, `hooks/session-hook.ts`, `hooks/session-title-hook.ts` | Two bundles. `dist/session-hook.js` takes its mode as an argument — `open-asks`, `dispatch-first` or `answers`, plus `answers-refresh`, which only the `answers` mode starts and hooks.json never names; an argument naming none of them throws — and each mode is a hook of its own, because Claude Code caps each hook's output at 10,000 characters. `open-asks` (every `SessionStart`) names the `dispatch` command's host and hands on the session's envoy.json in `CLAUDE_ENV_FILE`, records the current session id per Claude process, records the session's chosen title and injects the open-asks summary; always exits 0. `dispatch-first` (every `SessionStart`, and every `SubagentStart`, since a subagent runs the same `dispatch` command) injects the `dispatch-first` skill as `hookSpecificOutput.additionalContext` under the input's `hook_event_name` when Dispatch is configured, reading `${CLAUDE_PLUGIN_ROOT}/skills/dispatch-first/SKILL.md` at run time and exiting non-zero naming it when it is missing. On a resume or fork Claude Code adds a SessionStart hook's context only when the transcript does not already hold the same text (measured on 2.1.283), so a resumed session keeps one copy and one opened without the skill gets it; the hook itself checks nothing. `answers` (every `SessionStart`, and every `SubagentStart`) injects what people wrote to the session in the last 72 hours as `hookSpecificOutput.additionalContext`: `readSessionAnswers` from `@legion/envoy-client/session-answers` for the input's `session_id` (at `SubagentStart` the parent's, which `tests/answers-claude.test.ts` checks on the real binary), the list pi-envoy puts on every model request, or its failure line naming why when a read failed and no list read before has an item to show, and nothing when there is nothing to show, Dispatch answered its own `404 NOT_FOUND` "no route for …" (`isNoAnswersRoute`, which `dispatch answers` cannot read past either; the hook then writes the read's `notice`, naming that Dispatch, to stderr, one line per process), or Dispatch is not configured; a proxy's 404 page or a 200 that is no answers list is a failed read. Its bound is counted from its process's start, Bun's start-up included: it prints by `ANSWERS_BY_MS` (800 ms, `SESSION_ANSWERS_DEADLINE_MS`), or `ANSWERS_LEAST_WAIT_MS` (150 ms) after its code begins when the start-up took longer, carrying the last list read from `answers.json` in the session's Dispatch state directory (`readSessionAnswers` works it out from `env`) when Dispatch has not answered, then waits on `sessionAnswersSettled` for `ANSWERS_SETTLE_MS` (50 ms) more so a read that answered late still writes that file for the next hook, and calls `process.exit(0)` whatever happened, a malformed input included (stderr names why), so nothing still in flight holds it. Dispatch is resolved with `commands: "kept"` first; a fresh process has kept no `!` command's outcome, so when that resolution is off with an error the mode starts the refresher: this bundle again (`process.execPath`, `Bun.main`) in mode `answers-refresh` with the session id and project directory, detached (its own session, so a signal to the hook's process group does not reach it), stdio ignored (so it holds none of the hook's pipes) and `unref()`ed, unless `answers-refresh.lock` in the session's state directory names a live refresher (its pid; a lock older than 30 s, or whose pid is gone, is stale). The refresher takes that lock (`wx`; a second one exits at once, so a burst of `SubagentStart` hooks runs the command once), resolves Dispatch with commands run, so `envoy-config`'s run of the command ends in its own process with its own clean-up (the group killed at the 10 s timeout, the stdout file's directory removed), reads with a 12 s deadline (which writes `answers.json`), writes `{finished_at, dispatch_url, text, notice}` to `answers-refresh.json` and removes the lock. The hook polls that file every 20 ms until its print deadline and carries its `text`, and reports its `notice` on stderr, when it finished after the hook started; otherwise it carries the last list read from the Dispatch URL envoy.json names now (`sessionDispatchUrl`, resolved before the wait without reading the token, passed as `readSessionAnswers`' `dispatchUrl`; a list another Dispatch answered never stands in) or the failure line naming that the command had not finished, and exits, so the next hook carries the list the refresher read. Its `timeout` is 5. Measured with Dispatch hanging: about 870 ms wall from spawn to exit; healthy, about 220 ms. `dist/session-title-hook.js` (every `UserPromptSubmit`, no argument) records the payload's `session_title` — the name a person gave the session, which Claude Code hands a hook and the plugin nowhere else — under `${CLAUDE_PLUGIN_DATA}/titles/`, and removes that file when the field is absent or blank, which is how a rename is undone. It writes nothing to stdout and exits 0 whatever happens, a payload that is not JSON included (`open-asks` and `dispatch-first` still fail loudly on one), because Claude Code adds a `UserPromptSubmit` hook's stdout to the model's context and reads its exit code 2 as a block of the prompt, and a cosmetic title may do neither; a failure goes to stderr. Claude Code blocks the user's prompt on it, so it is a bundle apart from the other two and keeps zod, the Dispatch client and `session-settings` out of its import graph (`src/session-state.ts` holds the file helpers for that reason, `src/session-identity.ts` imports zod and is not reachable from here): 5 kB and ~68 ms against the shared bundle's 811 kB and ~165 ms, the latter all but 3 ms of it bare `bun` start-up. It checks the payload itself — a non-empty string `session_id`, an optional string `session_title` — rather than carry a schema library. Its `timeout` is 5, not the 20 the `open-asks` and `dispatch-first` hooks carry. |
119
+ | Real Claude Code test | `tests/answers-claude.test.ts`, `/.claude-code-pin`, `/.github/actions/install-claude-code` | The `answers` hook on the real binary: Claude Code runs with this package as `--plugin-dir`, an isolated HOME and `CLAUDE_CONFIG_DIR`, and one stand-in as its Messages endpoint (`ANTHROPIC_BASE_URL`, server-sent events as the API streams them) and Dispatch (`DISPATCH_URL`); each case reads the list off the Messages request after the moment: startup (`claude -p --session-id`), `--resume <id>`, `/compact` sent in stream-json as `legion claude-bridge` sends turns, and a subagent (the stand-in answers with an `Agent` tool call, the API name of Claude Code's Task tool). `LEGION_TEST_CLAUDE` names the binary; the test skips without it, except on GitHub Actions, where the claude-envoy job installs the release `.claude-code-pin` pins (`bun add` into the runner's temp directory). A pin bump that changes a request shape is fixed in that file. |
120
+ | Inbound channel server | `src/envoy-channel-server.ts`, `src/envoy-off.ts` | MCP capability, notification queue, NATS registration, session-id handoff, role persistence, rejected-frame replies, and the Envoy tools, on the envoy.json Claude Code's rule picks (`@legion/envoy-client/session-settings`); why a section is off, and that inbound messages are off, goes to stderr once as `envoy-channel: <notice>`. `runEnvoyChannelServer` chooses between that server and the two reduced modes of `src/envoy-off.ts`. With Envoy off it runs `serveEnvoyOff` instead: an MCP server that lists no tool, refuses every call with `Envoy is off for this session: <why>` (the section's notice or error), opens no NATS connection and calls no listener, and exits 0 when stdin ends or on SIGTERM, SIGINT or SIGHUP. With Envoy on and inbound messages off (`inbound.off`: no NATS server, from `envoy.natsUrls` or `ENVOY_NATS_URL`, or its seed refused), it serves the tools on `inboundOffChannelSession`: no NATS connection, nothing registered with the listener, an empty inbox, `envoy_subscribe` refused naming why, and a role `envoy_role_set` claims still persisted. |
121
+ | Channel gate | `src/claude-channel.ts` | Whether Claude Code loaded this server as a channel, which it never tells the server: the command line of the nearest ancestor whose program is `claude` (`claudeCommandLine`; Claude Code may run the server through a shell prefix or a `bun` shim), read from `/proc` (exact, with its stdout's terminal and its directory) or `ps` (split on whitespace), against Claude Code's own gate as of 2.1.301: a `plugin:claude-envoy@…` entry when `CLAUDE_PLUGIN_ROOT` says it runs as a plugin, any `server:` entry from a bare config; `--channels` entries before development ones; `--dangerously-load-development-channels` only in an interactive session (`asksForPrintMode`, the arguments skipped as Claude Code's scanner skips them, and a terminal on stdout where `/proc` shows it); the marketplace it is loaded from (`loadedMarketplace`: `inline` for `--plugin-dir`, else the cache's, compared after Claude Code's cache-name sanitizing); for `--channels`, `allowedChannelPlugins` (object or `plugin@marketplace` entries, each invalid one dropped alone) from the managed source that owns it (`remote-settings.json`, else `managed-settings.json` with its non-dot drop-ins; under `managedSourcesBehavior: "merge"` the highest that sets it). Not loaded, the session registers `capabilities: []`. `ENVOY_CHANNEL_LOADED=1`/`0` overrides it. A command line it cannot read, or no `claude` ancestor, counts as loaded, and so does one Node overwrote with `process.title = "claude"` (on Linux, the title alone followed by NUL padding, whatever the executable's name; elsewhere one word from a program named `node…`). |
122
+ | Session identity | `src/session-identity.ts`, `src/session-state.ts`, `src/claude-session.ts` | `SessionIdentity` is the one mutable id holder; handoff file `sessions/<claude-pid>/session-id`; role file `roles/<url-encoded session id>.json` (`readPersistedRole`, `writePersistedRole`; the suffix is what keeps a session id of `.` or `..` inside `roles/`, since `encodeURIComponent` leaves a dot alone). `CLAUDE_CODE_SESSION_ID`, or explicit `ENVOY_SESSION_ID` for QA. `src/session-state.ts` holds what the state files share and nothing that imports zod: `writeAtomicStateFile`, and `pruneStateDirectory(directory, isStale)`, the one ENOENT-tolerant sweep behind all three of them — handoffs by liveness (a `sessions/<pid>` whose Claude process is gone, anything else there left alone), titles and roles by age (`writtenLongAgo`, `STATE_FILE_MAX_AGE_MS`, thirty days). The role sweep exists because exactly one path removes a role file by hand — a soft `setRole` the listener refuses with a holder that is **not** the predecessor the claim named, which says that predecessor no longer holds the role. A refusal naming the predecessor itself keeps the file, since that record belongs to the live holder, which may be another process sharing this state directory. The handoff sweep cannot reach either, keyed as it is by session id with no process left to ask about. All three run at startup in `startChannelSession`, each under its own `.catch` that logs and swallows: the heartbeat timer is not armed yet, so housekeeping must never cost a session its channel. |
123
+ | Session title | `src/session-title.ts`, `src/session-state.ts` | The title every registration sends, which Dispatch's Agents page shows in place of the id: `ENVOY_SESSION_TITLE` on one line when it holds a character that shows; else the name the person running the session gave it (`claude -n`, `--name`, `/rename`), alone, with no short id after it, since a name a person chose needs nothing to tell it apart; else a name and the current id's first eight characters (a function of the id, so it follows a `/clear` handoff, and keeps two sessions in one tmux window apart). The chosen name is read, through `readChosenTitleSync`, from the `titles/<url-encoded session id>.txt` file the hooks write under `CLAUDE_PLUGIN_DATA` (keyed by id, like the role file, so `claude --resume <id>` finds it and a `/clear`'s new id starts without one; the suffix keeps a session id of `.` or `..` inside `titles/`, since `encodeURIComponent` leaves a dot alone), and every writer and reader goes through `sessionTitleFile` so the file the hook writes is the file the server reads; that read is synchronous and swallows every error, because a cosmetic title must leave the session on its fallback rather than fail the registration carrying it, and the name goes through the same `oneLine`/`visible` pair the other sources do, so a blank or invisible one falls through to the fallback while a multi-line one is put on one line and kept. The fallback's name is the pane's tmux name (`TMUX` and `TMUX_PANE`, one bounded `tmux -u -S <socket> display-message` at startup, used only when the answering server's pid is the one in `TMUX` and the pane's process is one of this process's ancestors (`isAncestor`); `session:window` when the window's `automatic-rename` is off, else the session, never a window index; for grouped sessions the group's name), else the project directory's name (outside tmux, a refused or missing answer, or a session named only by digits in a window nobody named). That name is worked out once as the server starts; the chosen name is asked for on every call instead, so a rename reaches the page on the session's next prompt plus a heartbeat, with no watcher anywhere. `pruneStaleSessionTitles` drops title files untouched for thirty days at startup, through the sweep the handoff and role prunes share and under the same best-effort guard, since nothing else deletes a file keyed by a session id. The listener replaces the stored title on every registration, and `register` also stamps it (`stampTitle`: `writeSessionTitle` in the session's Dispatch state directory) once per id, where the `dispatch` command reads it for the `session_title` on its writes; with inbound messages or Envoy off, nothing registers, so `runEnvoyChannelServer` stamps it once for the starting id (a `/clear` there leaves the new id unstamped); nothing is stamped when the session starts with Dispatch off or under `ENVOY_SESSION_ID`. |
124
+ | NATS topic consumer | `src/channel-forwarder.ts` | Dedupe through the shared `createDeliveryDedupe`: a re-send by its `dedupe_key` (never by `event_id` alone, which the listener mints fresh for every send, so a Dispatch Retry would pass as new), and a second copy of one publish arriving on overlapping subscriptions by its `event_id`; surface the envelope's topic, and drain safely at shutdown. The promise the dedupe keeps is stated on `DELIVERY_DUPLICATE_WINDOW_MS` in `packages/contracts`. |
125
+ | Consumer delivery | `src/envoy-channel-server.ts` (`openRelay`, `deliverRelayed`, `renewRelayEpoch`, `relaysCoreTopic`), `src/replay-state.ts` (the `consumer` part of `replay-<session id>.json`), `../envoy-client/src/consumer-delivery.ts`, `../../scripts/e2e/envoy-session-delivery.sh` | The listener's relay of the session's durable consumer ("Overview"): the relay subscription and epoch, each frame's `received`, `done` and `nak`, and the persisted resume point. The repository's `scripts/e2e/envoy-session-delivery.sh` is the live proof under `legion claude-bridge` (`scripts/e2e/README.md`). |
126
+ | Outbound transport | `src/envoy-client.ts`, `bin/envoy-send.ts` | Envoy listener HTTP client for `envoy-send`, which sends to the listener and with the bearer `envoySettings` resolves over the envoy.json `envoyConfigPath("command")` chooses. |
127
+ | Send CLI parsing | `src/send-arguments.ts` | Validates destination and message arguments. |
128
+ | Manual channel smoke | `scripts/smoke-channel.sh` | Real smoke, unattended (answers every startup dialog, captures the pane); documented and deliberately excluded from CI. |
129
+ | Manual `/clear` smoke | `scripts/smoke-clear-rebind.sh` | Real `/clear` against the live listener with the build in `CLAUDE_PLUGIN_DIR` (default: a `--out` build of this tree in the smoke's scratch directory); checks the registry entry moves to the new id without the old direct subject. Sends no prompt to the model; excluded from CI. |
130
+ | Manual session-title smoke | `scripts/smoke-session-title.sh` | Real nats-server, a listener built from this tree, and the `dist/` of its own `--out` build of this tree for both the hook and the channel server: a chosen name registers, a rename replaces it, taking the name away restores the folder-and-short-id fallback, and the `dispatch` command's stamp carries the same name, each read back through the listener's `GET /v1/sessions`; the base revision's committed bundle is the negative control. Claude Code is not installed in an issue pod's image, though it runs there from `bunx --package @anthropic-ai/claude-code@<version> claude`, as `scripts/e2e/envoy-session-delivery.sh` runs it; this smoke writes the two hook payloads to their documented shape instead. Excluded from CI. |
131
+ | NATS stall test | `tests/envoy-channel-stall.test.ts`, `../envoy-client/test/stall-proxy.ts`, `../envoy-client/test/envoy-stack.ts`, `../envoy-client/test/stall-scenario.ts` | Runs only with `LEGION_E2E_ENVOY_STACK=1` (CI's `envoy-session-stall` job), and needs `go` on `PATH` to build natsd and the listener from `packages/envoy`: with the gate set, a missing `go` or a failed build fails it rather than skipping it. The real channel server process registers against that listener (its row shows `plugin` `@sjawhar/claude-envoy` and `plugin_version` `source`), which answers `consumer`, and the proxy freezes its NATS path. Within 60 s the connection nats.js dials in place of the frozen one subscribes the relay subject again (natsd's `/connz`), the session registers a new epoch (its `envoy_sessions` entry), and the message sent during the freeze and one sent after it are each handed over once, neither as a replay: the freeze and what it checks are `runFreezeScenario`, which pi-envoy's stall row runs too. |
132
+ | Legion phase worker on Claude Code | `src/legion-claim.ts`, `src/legion-tool.ts`, `src/legion-inbox.ts`, `src/legion-phase-hook.ts` | Active only when `legion claude-bridge` launched the pane (`LEGION_BOOT_TOKEN_FILE`, `LEGION_DAEMON_URL`, `CLAUDE_CONFIG_DIR` set). The claim waits for the session transcript, then boots through the claim boot `@sjawhar/pi-legion` shares (`bootLegionClaim`, `@legion/pi-shared/legion-claim-core`): registers with the daemon, claims the issue's Envoy role soft and calls ready, again on every `onRoleRegained`. The `legion` MCP tool is the phase ops `@sjawhar/pi-legion`'s tool serves too (`@legion/pi-shared/legion-phase-ops`: one schema, one executor, and the shared description template with its `read_record` and `request_backward_move` sentences, naming `mcp__legion__envoy_publish`). `src/legion-phase-hook.ts` is the endless phase reminder's Claude Code host (the section below). With `LEGION_CLAUDE_INBOX` set, deliveries go to the bridge's socket instead of channel notifications. `package.json` `legion.daemonApiVersion` is pinned by `tests/daemon-api-version.test.ts`. |
133
+ | Generated Legion plugin | `legion/`, `scripts/build.ts` | Never edit by hand, and never committed (convention below): `scripts/build.ts` stages pi-legion's skills, converts `packages/pi-legion/agents` (tool names mapped to Claude Code's, `glob`/`grep`/`todo` dropped) and writes the phase reminder's five hook entries (`LEGION_HOOKS` → `legion/hooks/hooks.json`, the section below). `scripts/verify-build.sh` builds the tree twice and fails when the two builds differ, so the release publishes what the source builds to; `tests/dist-fresh.test.ts`, `tests/legion-plugin-fresh.test.ts` and `tests/build-out.test.ts` build fresh and read no file of the package. `tests/legion-pane-mcp.test.ts` runs a Legion pane's server end to end against stand-ins. |
134
+ | The phase reminder on the real Claude Code binary | `tests/legion-phase-hook-claude.test.ts` | The one check that Claude Code arms the generated `legion/hooks/hooks.json` entries at all: it runs the real binary under Legion's own agent command (`claudelaunch.Argv`, `--debug` added for the log it reads), with a fresh `scripts/build.ts --out` build of this tree — its generated `legion/` tree, and its `dist/envoy-channel.js` as the `legion` MCP server — against stand-ins for the Anthropic API, the daemon, the listener and NATS. `LEGION_TEST_CLAUDE` names the binary — in a worker pod, the one installed from `packages/daemon/docker/claude-code/package-lock.json`, at `node_modules/@anthropic-ai/claude-code-linux-x64/claude` (the `node_modules/.bin/claude` symlink is a `.exe` shim that does not exec on Linux): `cd packages/claude-envoy && LEGION_TEST_CLAUDE=<that path> bun test tests/legion-phase-hook-claude.test.ts`. A run without it skips the file, and nothing in CI installs the binary. |
135
+
136
+ ## The Legion phase reminder
137
+
138
+ A Legion phase worker whose turn ends with its phase still open — no successful `handoff_complete`
139
+ since the daemon's assignment — is reminded at the end of every turn, for as long as the phase
140
+ stays open. The machine is not this package's: `@legion/pi-shared` holds it once for both
141
+ harnesses (`phase-stall.ts`, the states and the count; `phase-reminder.ts`, the runner that
142
+ delivers each reminder, tells the architect and reads the daemon's record; `stall-limits.ts`, the
143
+ four numbers), `@sjawhar/pi-legion` drives it from Oh My Pi's session hooks
144
+ (`packages/pi-legion/AGENTS.md`), and `src/legion-phase-hook.ts` drives it here, from Claude
145
+ Code's own hooks. Nothing of the machine is re-decided in this package. `legion claude-bridge` is
146
+ transport and holds no copy of it (`packages/daemon/internal/claudebridge`).
147
+
148
+ **The hooks.** `scripts/build.ts`'s `LEGION_HOOKS` generates five entries into
149
+ `legion/hooks/hooks.json`, each an `mcp_tool` hook on the server `legion` — the pane's own MCP
150
+ server, which `--mcp-config` registers under that bare name
151
+ (`packages/daemon/internal/claudelaunch`) — calling the tool `legion_phase_hook` that
152
+ `src/envoy-channel-server.ts` registers from `phaseHookToolDefinition()`. Claude Code substitutes
153
+ each `${…}` as a string, which the endpoint parses. `Stop` (30 s, since its answer waits on the
154
+ runner's record check, which bounds its own daemon read at 5 s) carries `${last_assistant_message}`
155
+ and `${stop_hook_active}`; `StopFailure` (10 s) the last message; `PostToolUse` and
156
+ `PostToolUseFailure` (10 s) `${tool_name}` and `${agent_id}`; `UserPromptSubmit` (10 s)
157
+ `${prompt}`. They belong to the generated `legion/` plugin alone, never to `hooks/hooks.json`:
158
+ this package is also installed as a plain Claude Code plugin, where no `legion` server exists and
159
+ every entry would be a hook error on every turn. A Legion pane loads both plugin directories, so
160
+ its worker gets them.
161
+
162
+ **What each hook does.** The endpoint answers `{}` unless it has something to say, and answers
163
+ every hook with `{}` for a session that holds no claim yet (the bridge's seeding `/rename` turn)
164
+ or whose role is `architect` — the same gate `phaseWorkerSession` applies in pi-legion. `Stop`
165
+ settles the machine: the first reminder of a run comes back as
166
+ `{"decision":"block","reason":<the reminder>}`, which Claude Code turns into one more turn of the
167
+ same session, and it is the only one of that run; every later one is written into the session over
168
+ the bridge's inbox from a timer, because a line written while a turn runs is appended to that turn
169
+ and the run would never settle for the daemon. `StopFailure` (the turn ended on an API error, and
170
+ Claude Code ignores whatever the hook returns) settles with `canContinue: false`, so the machine
171
+ arms that timer for the reminder instead. `PostToolUse` and `PostToolUseFailure` are the worker
172
+ working, which puts the count back to zero; a non-empty `${agent_id}` is a subagent's
173
+ call and is ignored. `UserPromptSubmit` is this server's only sight of an inbound turn — the
174
+ daemon's task reaches Claude Code's stdin from the bridge, never this process — so it declares the
175
+ turn, drops the armed timer, and classifies the prompt: the reminder it last wrote is itself and
176
+ changes nothing, a line this server wrote (an Envoy delivery, the recreated-workspace notice) is
177
+ an inbound event, and anything else is an assignment. The tool is visible to the model, and a call
178
+ it does not recognise is answered with `PHASE_HOOK_REFUSAL` and records nothing, so the model
179
+ cannot zero its own count.
180
+
181
+ **What closes the phase.** Nothing a hook sees: the `legion` tool's `handoff_complete`, and its
182
+ `request_backward_move`, are calls of the same MCP server, and its CallTool handler tells the
183
+ endpoint (`phaseClosed`) once one of the two returned without throwing — the two operations
184
+ `createLegionTool` closes the Oh My Pi host's phase on. Until that lands, a completed worker's
185
+ record still says `open`, and only the runner's record check, which fails open when the daemon
186
+ does not answer, stands between it and a reminder telling it to complete again.
187
+
188
+ **What the update task's answer does.** The same CallTool handler tells the endpoint
189
+ (`updateAnswered`) once the `legion` tool's `ready_for_update` returned without throwing
190
+ (INFRA-484). It closes no phase — answering is not completing — and moves the machine to
191
+ `awaiting-update`, the wait the daemon ends: the `Stop` of the turn the worker answered in blocks
192
+ nothing, so that turn ends and the daemon, which replaces the pod only once every worker of it
193
+ has answered, sees it end. A blocked `Stop` would instead continue that turn, and the work it did
194
+ would go with the pod. Nothing is written into that wait and no timer is armed in it, because the
195
+ daemon is what wakes the worker: a round, once opened, ends only by replacing its pod or because
196
+ that pod is gone, so the process is replaced and the task the round held behind it reaches the
197
+ worker on the new pod, in the same session. The record holds the wait as the plain `waiting` a
198
+ WAITING reply buys (`recordedPhaseStall`): only a relaunched process reads a record, and that
199
+ relaunch is the round over.
200
+
201
+ **The state file.** This host has no transcript entry to record in, so the record is
202
+ `<CLAUDE_CONFIG_DIR>/legion-phase-stall-<session id>.json` (`phaseStallFile`), written whole
203
+ through a temporary file in the same directory renamed into place, before the hook answers. It
204
+ carries the shared entry — `state` (`closed`, `open`, `waiting`), `count`, `reported`, the
205
+ architect notice's `reportKey` and `lastMessage`, the head of what the worker last said, which the
206
+ notice quotes after a relaunch — and one key only this host keeps, `recreatedNoticeSent`. A
207
+ missing file is a fresh session; an unreadable one, one that is not JSON, or one holding no state
208
+ this build knows is one `warn` and a fresh start. A file the build now in the pods wrote holds
209
+ `{"state":"quiet"}` or `{"state":"text-call"}`, which read as an open phase with no reminder yet,
210
+ so a session carried across the cutover is reminded rather than left alone. Claude Code may
211
+ restart this server inside the same `claude` process, which is why the count is on disk and not
212
+ in memory alone, and the only reason `recreatedNoticeSent` is: a launch whose
213
+ `LEGION_WORKSPACE_RECREATED` is not `true` clears it, so the next launch that recreates the
214
+ workspace is told again.
215
+
216
+ **Its inputs.** The four numbers come from the worker's own environment, which the daemon fills
217
+ from its own under both runtimes: `LEGION_STALL_REMINDER_MS` (300000, how long after a settle a
218
+ later reminder waits), `LEGION_STALL_WAITING_MS` (900000, what a reply starting WAITING buys),
219
+ `LEGION_STALL_RESUME_MS` (120000, the reminder for a worker relaunched into an open phase) and
220
+ `LEGION_STALL_ARCHITECT_AFTER` (3 unanswered reminders before the architect is told over Envoy,
221
+ through this server's own listener client). A worker whose launcher recreated its workspace is
222
+ told so over the same inbox, once per launch, as an Oh My Pi worker is
223
+ (`LEGION_WORKSPACE_RECREATED`, `@legion/pi-shared/workspace-recreated`). `tests/legion-phase-hook.test.ts`
224
+ drives the endpoint over a real state file, and `tests/legion-pane-mcp.test.ts` proves the tool is
225
+ on a Legion pane's server. Both drive this package alone: that Claude Code arms these hook entries
226
+ at all, reaches the server under the bare name `legion`, turns the `Stop` block into one more turn
227
+ of the same session, and fires `PostToolUse` for the model's own call and not for the hook's own
228
+ MCP call, is proved only on the real binary, by `tests/legion-phase-hook-claude.test.ts` (the row
229
+ above says how to run it).
230
+
231
+ ## Critical conventions
232
+
233
+ - `renderInbound` from `@legion/envoy-client/delivery` is the sole inbound renderer. Never add an
234
+ adapter-specific renderer or emit raw envelope bytes.
235
+ - Each channel meta key must contain only letters, digits, and underscores and every value must be
236
+ a string. `producer`, `topic`, `event_id`, `dedupe_key`, `urgency`, `from_session`,
237
+ `expects_reply`, and `in_reply_to` are the current contract. Never emit a `source` key: Claude
238
+ Code stamps its own `source="plugin:claude-envoy:envoy"` attribute on the `<channel>` tag.
239
+ - Subscribe NATS before registering the self-subscribed session route — on startup, on a session-id
240
+ handoff (new direct subject first, then deregister the old id, drop the old subject, register,
241
+ move the role by soft claim; a handoff that fails before the id switch drops the new subject
242
+ again), and when rebuilding the interests a resumed id already registered. Subscribe the current
243
+ id's relay subject (`notifications.envoy.deliver.<session_id>`) before any registration under
244
+ that id, since that registration asks for consumer delivery. The handoff's
245
+ deregistration carries this process's client instance id, so the listener removes that entry
246
+ only while it is still this instance's: a process that resumed the old id and registered it
247
+ keeps its registration, and the soft claim that follows is then weighed against a predecessor
248
+ that is live, rather than against one this handoff cleared.
249
+ A handoff is adopted within 250 ms of the SessionStart hook writing the handoff file: the server
250
+ polls the file by path (`fs.watchFile`), because a directory watch reports only the hook's
251
+ temporary file, not the rename onto `session-id`. The heartbeat is the fallback. On shutdown
252
+ (stdin end, SIGTERM, SIGINT, SIGHUP) drain NATS, deregister the session, close the transport, and
253
+ let the stdio process exit.
254
+ - The listener merges each registration's topics into the entry and never removes one. So every
255
+ registry write (register, unsubscribe, deregister, the handoff) goes through the session's one
256
+ `serialized` chain, a registration reads the id and topics when it runs, and a topic leaves the
257
+ forwarder's topic list (`unfollow` removes it before its first await) before the listener is
258
+ told or the next registration is sent. A human Dispatch subscription removal is repeated to the
259
+ listener through the same chain. Nothing inside the chain may wait without a bound (the
260
+ handoff's NATS flush has a timeout), because shutdown's deregistration queues behind it.
261
+ - Publish the empty receipt only for a forwarded lane (envelope topic differs from the direct
262
+ subject: the listener's role lane, which waits for it). A plain direct send is a JetStream publish
263
+ whose reply inbox expects the server's acknowledgement; an empty receipt there makes the listener
264
+ report `invalid jetstream publish response` for an event that was delivered. The rule is
265
+ `expectsLaneReceipt` from `@legion/envoy-client/delivery`, shared with the OMP extension. The
266
+ core subscription answers that receipt in both delivery modes: on consumer delivery the core copy
267
+ of a relayed subject answers its receipt and is shown nothing, and the stored copy arrives on the
268
+ relay.
269
+ - Every capture of the session id goes through the shared `SessionIdentity` (`identity.id` at the
270
+ moment of use), never a copied string: after a `/clear` handoff the model posts to Dispatch,
271
+ registers, unfollows, remembers roles, and deregisters under the current id.
272
+ - Pass a `ChannelInboundMessage` built field by field; a nats.js `Msg` exposes `subject`, `data`, and
273
+ `reply` through prototype getters that an object spread silently drops. The forwarder decodes
274
+ `data` once into `raw`; downstream code reads `raw`, never re-decodes.
275
+ - Register the capabilities the delivery path honours, never `btw`: `CHANNEL_CAPABILITIES`
276
+ (`["aside"]`) for channel notifications, which queue for the next turn, so a steer at the next
277
+ tool boundary cannot be honoured, and none when Claude Code did not load the channel;
278
+ `INBOX_CAPABILITIES` (`["aside", "steer"]`) when `LEGION_CLAUDE_INBOX` routes deliveries to the
279
+ bridge, which needs no channel. Dispatch refuses a targeted delivery in a mode the session does
280
+ not advertise, and a message with no command goes in the mode the addressed sessions take
281
+ (`plainDelivery`), as a broadcast does (`broadcastDelivery`).
282
+ The direct NATS receipt occurs immediately after notification enqueue; it cannot imply model
283
+ processing because Claude Code does not acknowledge channel notifications. A relayed frame's
284
+ `done` waits for the write instead: the resolved notification write in channel mode, which is
285
+ the last signal Claude Code gives there, and the bridge's inbox answer in a Legion pane, which
286
+ comes once Claude Code re-emits the delivery (`--replay-user-messages`).
287
+ - Preserve the held role at `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json` (keyed by session id so
288
+ `claude --resume <id>` finds it); soft-reclaim it with its `previous_session_id` after
289
+ registration, then reassert it on each registration heartbeat. The listener grants such a claim
290
+ only when that predecessor has no live registry entry, when its live entry carries this
291
+ process's own client instance id, or when either side carries no instance id. A predecessor
292
+ still live under another client instance keeps the role, and the claim is refused naming it.
293
+ That refusal keeps the predecessor's role file, because the record belongs to the live holder.
294
+ A refusal naming any other session removes the file. The handoff directory
295
+ `sessions/<claude-pid>/` is pruned at startup when its pid is gone — never deleted on shutdown,
296
+ because Claude Code restarts the server inside the same `claude` process — and the two
297
+ id-keyed directories, `roles/` and `titles/`, are pruned there too at thirty days, since a
298
+ session that ends while it still holds its role deletes neither file itself.
299
+ - Shared logic remains shared: use `sessionDispatchSettings`, `formatOpenAsksSummary`,
300
+ `postDeliveryReply`, `mergeInterestSources`, and `sendConfirmationText`. No Dispatch call goes
301
+ through the MCP server; the `dispatch` command runs the shared executor, and a `dispatch_*` tool
302
+ name is an unknown tool here. Rejected targeted frames answer Dispatch with
303
+ `Invalid Dispatch targeted delivery frame`; malformed ones without a reply address are logged and
304
+ dropped — never shown to the model.
305
+ - `envoy_inbox` is bounded to 50 metadata-only entries. Keep full payloads in neither the tool
306
+ output nor plugin data.
307
+ - Deployment is `--channels plugin:claude-envoy@legion-plugins` under managed settings
308
+ (`channelsEnabled`, `allowedChannelPlugins`); `--dangerously-load-development-channels` is for
309
+ entries outside the allowlist and cannot be combined with any `--channels` entry naming the same
310
+ plugin: Claude Code matches the `--channels` entry first, which then needs the allowlist and its
311
+ own marketplace. `claude -p` has no interactive plan approval, multiple-choice,
312
+ or consent surfaces; workers need pre-approved configuration.
313
+ - Nothing commits `dist/` or `legion/`, and `.gitignore` names both. `release.yaml`'s
314
+ `claude_envoy_build` builds them after each merge that bumps this plugin's version, packs them
315
+ into the tarball with the rest of the package, and the `publish` job uploads that tarball to
316
+ npm as `@sjawhar/claude-envoy`; the marketplace entry names that package and bounds it with the
317
+ range `>=1.0.0` on its `npm` source, which leaves out the 0.0.0 placeholder, so an install or a
318
+ `claude plugin update` takes the newest release.
319
+ A merge whose commits touch none of `$CLAUDE_ENVOY_VERSION_ARGS`' paths releases nothing and
320
+ leaves the newest release standing.
321
+ `tests/build-inputs.test.ts`, in the required test job, fails when the bundles' module graph (with
322
+ each reached package's manifest, tsconfig and license files, and the lockfile, root manifest and
323
+ patches `bun install` applies) or the skills and agents `buildLegionPlugin` reads reach a file
324
+ `scripts/build-inputs.txt` does not match. The rest of the list is listed by hand, as its header
325
+ names it (`.bun-version`, `bunfig.toml`, the `.gitattributes` files, the scripts
326
+ `build.ts` imports or runs, and the files `verify-build.sh` reads): a pull
327
+ request that makes `build.ts` or `verify-build.sh` read a new file adds it to the
328
+ list itself, and a path added or dropped there is owed the same change in
329
+ `$CLAUDE_ENVOY_VERSION_ARGS`.
330
+ The hand-written plugin files (`hooks/hooks.json`, `.mcp.json`, `bin/`,
331
+ `.claude-plugin/`) are packed from the same tree as the build beside them, so a change that
332
+ makes one of them name a new or renamed bundle ships with that bundle in one release.
333
+ To run the plugin from a tree, build it with `bun scripts/build.ts --out <dir>` into a directory
334
+ outside the repository. A reviewer asks for any change under the two directories to be dropped.
335
+ The production check of a pull request that changes the plugin's built output names the release
336
+ the merge published and the version npm then carries: `npm view @sjawhar/claude-envoy version`,
337
+ and `npm pack @sjawhar/claude-envoy` unpacked holds `dist/envoy-channel.js` and
338
+ `legion/.claude-plugin/plugin.json`. A release that did not publish is a finding for the
339
+ controller.
340
+
341
+ ## Topic reminder
342
+
343
+ - Direct Claude session: `notifications.agent.<session_id>`