@sjawhar/claude-envoy 0.0.0 → 1.1.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.
- package/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/plugin.json +6 -0
- package/AGENTS.md +343 -0
- package/README.md +639 -0
- package/bin/dispatch +6 -0
- package/bin/envoy-channel.ts +9 -0
- package/bin/envoy-send.ts +33 -0
- package/dist/THIRD_PARTY_NOTICES +816 -0
- package/dist/dispatch.js +27707 -0
- package/dist/envoy-channel.js +57695 -0
- package/dist/session-hook.js +26328 -0
- package/dist/session-title-hook.js +116 -0
- package/hooks/hooks.json +60 -0
- package/hooks/session-hook.ts +512 -0
- package/hooks/session-title-hook.ts +46 -0
- package/legion/.claude-plugin/plugin.json +4 -0
- package/legion/agents/deep-worker.md +68 -0
- package/legion/agents/oracle.md +35 -0
- package/legion/agents/plan-gap-analyst.md +65 -0
- package/legion/agents/plan-reviewer.md +104 -0
- package/legion/agents/thermonuclear-code-quality.md +30 -0
- package/legion/agents/thermonuclear-deep-review.md +30 -0
- package/legion/hooks/hooks.json +89 -0
- package/legion/skills/ce-simplify-code/LICENSE +21 -0
- package/legion/skills/ce-simplify-code/SKILL.md +66 -0
- package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
- package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
- package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
- package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
- package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
- package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
- package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
- package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
- package/legion/skills/legion-architect/SKILL.md +86 -0
- package/legion/skills/legion-architect/references/acceptance.md +208 -0
- package/legion/skills/legion-architect/references/decomposition.md +129 -0
- package/legion/skills/legion-architect/references/design-gate.md +206 -0
- package/legion/skills/legion-architect/references/merge-notices.md +91 -0
- package/legion/skills/legion-architect/references/wake-routing.md +41 -0
- package/legion/skills/legion-controller/SKILL.md +151 -0
- package/legion/skills/legion-controller/references/admission.md +22 -0
- package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
- package/legion/skills/legion-controller/references/daily-report.md +75 -0
- package/legion/skills/legion-controller/references/escalation.md +82 -0
- package/legion/skills/legion-controller/references/finding-asks.md +181 -0
- package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
- package/legion/skills/legion-controller/references/session.md +100 -0
- package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
- package/legion/skills/legion-controller/references/triage.md +96 -0
- package/legion/skills/legion-controller/references/wakes.md +132 -0
- package/legion/skills/legion-oracle/SKILL.md +48 -0
- package/legion/skills/legion-retro/SKILL.md +112 -0
- package/legion/skills/legion-retro/references/learnings.md +60 -0
- package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
- package/legion/skills/legion-worker/SKILL.md +69 -0
- package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
- package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
- package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
- package/legion/skills/legion-worker/references/findings.md +199 -0
- package/legion/skills/legion-worker/references/merge-gate.md +353 -0
- package/legion/skills/legion-worker/references/pr-body.md +218 -0
- package/legion/skills/legion-worker/references/review-threads.md +77 -0
- package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
- package/legion/skills/legion-worker/references/tools.md +708 -0
- package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
- package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
- package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
- package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
- package/package.json +49 -1
- package/scripts/build-inputs.txt +84 -0
- package/scripts/build.ts +415 -0
- package/scripts/check-packed-skills.sh +50 -0
- package/scripts/pack-skills.sh +146 -0
- package/scripts/smoke-channel.sh +178 -0
- package/scripts/smoke-clear-rebind.sh +164 -0
- package/scripts/smoke-session-title.sh +245 -0
- package/scripts/verify-build.sh +88 -0
- package/skills/agent-secrets/SKILL.md +180 -0
- package/skills/asd-ste100/LICENSE +21 -0
- package/skills/asd-ste100/SKILL.md +140 -0
- package/skills/asd-ste100/examples/before-after.md +92 -0
- package/skills/asd-ste100/references/writing-rules.md +59 -0
- package/skills/dispatch/SKILL.md +89 -0
- package/skills/dispatch-first/SKILL.md +46 -0
- package/skills/dispatch-writing-specs/SKILL.md +80 -0
- package/skills/envoy/SKILL.md +82 -0
- package/skills/envoy/references/check-settlement.md +62 -0
- package/skills/envoy/references/deliveries.md +67 -0
- package/skills/envoy/references/legion.md +14 -0
- package/skills/envoy/references/roles.md +12 -0
- package/skills/envoy/references/sources.md +41 -0
- package/skills/outside-message/SKILL.md +111 -0
- package/skills/plain-words/SKILL.md +88 -0
- package/src/channel-forwarder.ts +262 -0
- package/src/claude-channel.ts +705 -0
- package/src/claude-session.ts +38 -0
- package/src/envoy-channel-server.ts +2115 -0
- package/src/envoy-client.ts +26 -0
- package/src/envoy-off.ts +62 -0
- package/src/legion-claim.ts +211 -0
- package/src/legion-inbox.ts +110 -0
- package/src/legion-phase-hook.ts +554 -0
- package/src/legion-tool.ts +69 -0
- package/src/replay-state.ts +97 -0
- package/src/send-arguments.ts +13 -0
- package/src/session-identity.ts +165 -0
- package/src/session-state.ts +142 -0
- package/src/session-title.ts +251 -0
- package/src/subscription-topics.ts +16 -0
package/.mcp.json
ADDED
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>`
|