@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.
- 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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RequestCredential } from "@legion/envoy-client/bearer"
|
|
2
|
+
import ky from "ky"
|
|
3
|
+
|
|
4
|
+
export type SendEnvoyMessageOptions = {
|
|
5
|
+
readonly baseUrl: string
|
|
6
|
+
/** What the send presents (`EnabledEnvoySettings.credential`): the listener's bearer, this
|
|
7
|
+
* machine's broker credential, which signs a DPoP proof for this request, or none, which
|
|
8
|
+
* sends no `Authorization` header, for a loopback listener that asks for none. */
|
|
9
|
+
readonly credential: RequestCredential | undefined
|
|
10
|
+
readonly targetSession: string
|
|
11
|
+
readonly message: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function sendEnvoyMessage(options: SendEnvoyMessageOptions): Promise<void> {
|
|
15
|
+
const url = new URL("/v1/messages/send", options.baseUrl)
|
|
16
|
+
const headers = (await options.credential?.headers("POST", url.toString())) ?? {}
|
|
17
|
+
await ky.post(url, {
|
|
18
|
+
json: {
|
|
19
|
+
target_session: options.targetSession,
|
|
20
|
+
message: options.message,
|
|
21
|
+
},
|
|
22
|
+
headers,
|
|
23
|
+
retry: 0,
|
|
24
|
+
timeout: 5_000,
|
|
25
|
+
})
|
|
26
|
+
}
|
package/src/envoy-off.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
4
|
+
import type { ChannelSession } from "./envoy-channel-server"
|
|
5
|
+
import { roleStateFile, type SessionIdentity, writePersistedRole } from "./session-identity"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The session of a channel server whose Envoy takes no inbound messages, for `why`: no NATS
|
|
9
|
+
* consumer, nothing registered with the listener (which would route a delivery to a deaf process),
|
|
10
|
+
* an empty inbox, and following a topic refused naming `why`. A role an explicit `envoy_role_set`
|
|
11
|
+
* claimed through the listener is still persisted, so a later server with NATS takes it back.
|
|
12
|
+
*/
|
|
13
|
+
export function inboundOffChannelSession(options: {
|
|
14
|
+
readonly identity: SessionIdentity
|
|
15
|
+
readonly stateDirectory: string
|
|
16
|
+
readonly why: string
|
|
17
|
+
}): ChannelSession {
|
|
18
|
+
const { identity, stateDirectory, why } = options
|
|
19
|
+
return {
|
|
20
|
+
delivery: { enqueue: async () => false, inbox: () => [] },
|
|
21
|
+
topics: () => [],
|
|
22
|
+
async follow() {
|
|
23
|
+
throw new Error(`inbound messages are off for this session: ${why}`)
|
|
24
|
+
},
|
|
25
|
+
unfollow: async () => [],
|
|
26
|
+
// No heartbeat runs, so no role is ever regained.
|
|
27
|
+
onRoleRegained: () => undefined,
|
|
28
|
+
async rememberRole(role) {
|
|
29
|
+
await writePersistedRole(roleStateFile(stateDirectory, identity.id), {
|
|
30
|
+
session_id: identity.id,
|
|
31
|
+
role,
|
|
32
|
+
})
|
|
33
|
+
},
|
|
34
|
+
shutdown: async () => undefined,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The MCP server, as `serverInfo`, of a session whose Envoy is off: no tool, every call refused
|
|
40
|
+
* with `refusal`, and nothing subscribed, until stdin ends or a signal stops it.
|
|
41
|
+
*/
|
|
42
|
+
export async function serveEnvoyOff(
|
|
43
|
+
serverInfo: { readonly name: string; readonly version: string },
|
|
44
|
+
refusal: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const server = new Server(serverInfo, { capabilities: { tools: {} } })
|
|
47
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }))
|
|
48
|
+
server.setRequestHandler(CallToolRequestSchema, async () => {
|
|
49
|
+
throw new Error(refusal)
|
|
50
|
+
})
|
|
51
|
+
const stopped = Promise.withResolvers<void>()
|
|
52
|
+
process.stdin.once("end", stopped.resolve)
|
|
53
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
|
|
54
|
+
process.once(signal, () => stopped.resolve())
|
|
55
|
+
}
|
|
56
|
+
await server.connect(new StdioServerTransport())
|
|
57
|
+
try {
|
|
58
|
+
await stopped.promise
|
|
59
|
+
} finally {
|
|
60
|
+
await server.close().catch(() => undefined)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises"
|
|
4
|
+
import { messageFor } from "@legion/envoy-client/errors"
|
|
5
|
+
import { readSecretFile } from "@legion/envoy-client/secret-file"
|
|
6
|
+
import type { EnvoyClient } from "@legion/envoy-client/transport"
|
|
7
|
+
import type { LegionDaemonClient } from "@legion/pi-shared/daemon-client"
|
|
8
|
+
import { bootLegionClaim } from "@legion/pi-shared/legion-claim-core"
|
|
9
|
+
import type { ClaimCapability } from "@legion/pi-shared/legion-phase-ops"
|
|
10
|
+
import { legion } from "../package.json" with { type: "json" }
|
|
11
|
+
import { configuredValue } from "./claude-session"
|
|
12
|
+
import type { ChannelSession } from "./envoy-channel-server"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A Claude Code phase worker's claim on the Legion daemon, made by the MCP server Claude Code runs
|
|
16
|
+
* for the pane (`dist/envoy-channel.js`, under the server name `legion`) through the claim boot
|
|
17
|
+
* every harness shares (`@legion/pi-shared/legion-claim-core`). The daemon launches the pane with
|
|
18
|
+
* its boot token (`LEGION_BOOT_TOKEN_FILE`) and its address (`LEGION_DAEMON_URL`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** What the daemon put on a Legion pane: its address, the boot token the registration spends, the
|
|
22
|
+
* claim's own Claude Code directory (`CLAUDE_CONFIG_DIR`), which holds the transcript, and whether
|
|
23
|
+
* its pod's launcher keeps that transcript in the session table (`LEGION_CLAUDE_SESSION_ID`, which
|
|
24
|
+
* only the Kubernetes runtime sets), so the registration waits for the launcher's first save. */
|
|
25
|
+
export interface LegionPane {
|
|
26
|
+
readonly daemonUrl: string
|
|
27
|
+
readonly bootToken: string
|
|
28
|
+
readonly configDir: string
|
|
29
|
+
readonly launcherSaves: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Legion pane this server runs in, or undefined when no boot token is on its environment (a
|
|
34
|
+
* Claude Code session outside Legion). A set `LEGION_BOOT_TOKEN_FILE` is authoritative, as in
|
|
35
|
+
* every pane: a missing or blank file throws naming it. A pane without `LEGION_DAEMON_URL` or
|
|
36
|
+
* `CLAUDE_CONFIG_DIR` throws naming the variable.
|
|
37
|
+
*/
|
|
38
|
+
export function legionPane(env: NodeJS.ProcessEnv): LegionPane | undefined {
|
|
39
|
+
const pointer = env["LEGION_BOOT_TOKEN_FILE"]
|
|
40
|
+
if (pointer === undefined && configuredValue(env["LEGION_BOOT_TOKEN"]) === undefined) {
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
const required = (key: string): string => {
|
|
44
|
+
const value = configuredValue(env[key])
|
|
45
|
+
if (value === undefined) {
|
|
46
|
+
throw new Error(`${key} is required for Legion: the pane carries a boot token`)
|
|
47
|
+
}
|
|
48
|
+
return value
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
daemonUrl: required("LEGION_DAEMON_URL"),
|
|
52
|
+
configDir: required("CLAUDE_CONFIG_DIR"),
|
|
53
|
+
bootToken:
|
|
54
|
+
pointer === undefined
|
|
55
|
+
? required("LEGION_BOOT_TOKEN")
|
|
56
|
+
: readSecretFile("LEGION_BOOT_TOKEN_FILE", pointer),
|
|
57
|
+
launcherSaves: configuredValue(env["LEGION_CLAUDE_SESSION_ID"]) !== undefined,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Writes one line to the pane's log, the server's stderr. */
|
|
62
|
+
export function logLegion(line: string): void {
|
|
63
|
+
process.stderr.write(`[legion] ${line}\n`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ends the pane's Claude Code, this server's parent, so the daemon launches the claim again: what
|
|
68
|
+
* an Oh My Pi pane's own exit does. `legion claude-bridge` exits with Claude Code's status, and the
|
|
69
|
+
* shim reports the death.
|
|
70
|
+
*/
|
|
71
|
+
export function endPane(): void {
|
|
72
|
+
process.kill(process.ppid, "SIGTERM")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Logs why the pane cannot run, then ends it (`endPane`). */
|
|
76
|
+
export function stopPane(why: string): void {
|
|
77
|
+
logLegion(`${why}; stopping the pane so the daemon launches it again`)
|
|
78
|
+
endPane()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The transcript Claude Code writes for `sessionID`, `<configDir>/projects/<cwd>/<id>.jsonl`, once
|
|
83
|
+
* it exists: the value a relaunch's `--resume` continues. A fresh session writes nothing until its
|
|
84
|
+
* first input, which is the bridge's `/rename` seed (`packages/daemon/internal/claudebridge`); a
|
|
85
|
+
* resumed one already has it. Polls every `pollMs` until `signal` aborts.
|
|
86
|
+
*/
|
|
87
|
+
export async function waitForTranscript(
|
|
88
|
+
configDir: string,
|
|
89
|
+
sessionID: string,
|
|
90
|
+
signal: AbortSignal,
|
|
91
|
+
pollMs = 100,
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
const projects = join(configDir, "projects")
|
|
94
|
+
const name = `${sessionID}.jsonl`
|
|
95
|
+
for (;;) {
|
|
96
|
+
signal.throwIfAborted()
|
|
97
|
+
for (const directory of await readdir(projects).catch(() => [] as string[])) {
|
|
98
|
+
const file = join(projects, directory, name)
|
|
99
|
+
if (await isFile(file)) return file
|
|
100
|
+
}
|
|
101
|
+
await delay(pollMs, undefined, { signal })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The file a pod's launcher writes beside a transcript once its first save of it has committed
|
|
106
|
+
* to the session table (`SavedMarkerSuffix`, `packages/daemon/internal/launcher/claude.go`). */
|
|
107
|
+
export const SAVED_MARKER_SUFFIX = ".legion-saved"
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Waits until the pod's launcher has saved `transcript` to the session table, which it says by
|
|
111
|
+
* writing `<transcript>.legion-saved` beside it: the pod's disk dies with the pod, so a transcript
|
|
112
|
+
* the daemon recorded before the table held it would be refused at the next pod's resume. Polls
|
|
113
|
+
* every `pollMs` until `signal` aborts.
|
|
114
|
+
*/
|
|
115
|
+
async function waitForSavedTranscript(
|
|
116
|
+
transcript: string,
|
|
117
|
+
signal: AbortSignal,
|
|
118
|
+
pollMs = 100,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const marker = `${transcript}${SAVED_MARKER_SUFFIX}`
|
|
121
|
+
for (;;) {
|
|
122
|
+
signal.throwIfAborted()
|
|
123
|
+
if (await isFile(marker)) return
|
|
124
|
+
await delay(pollMs, undefined, { signal })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isFile(file: string): Promise<boolean> {
|
|
129
|
+
return stat(file).then(
|
|
130
|
+
(info) => info.isFile(),
|
|
131
|
+
() => false,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface LegionClaim {
|
|
136
|
+
/** The registered claim, or undefined until `claims/register` has answered. */
|
|
137
|
+
readonly capability: () => ClaimCapability | undefined
|
|
138
|
+
/** Settles once the boot has run: ready sent, or the failure reported. Never rejects. */
|
|
139
|
+
readonly booted: Promise<void>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Boots the pane's claim in the background of the server (`bootLegionClaim`): waits for the
|
|
144
|
+
* transcript, and in a pod whose launcher keeps it (`launcherSaves`) for the launcher's first save
|
|
145
|
+
* of it too, registers with it as the session file (`agentId` is the session id,
|
|
146
|
+
* `pluginContract` this package's `legion.daemonApiVersion`), claims the claim token's Envoy role
|
|
147
|
+
* soft and records it with the channel session, whose heartbeat re-asserts it, and sends
|
|
148
|
+
* `claims/ready` with no capability report, again each time the heartbeat regains the role. A
|
|
149
|
+
* failure the core stops on ends the pane through `stop`; a failure before a registration is
|
|
150
|
+
* logged and leaves the pane to the daemon's registration deadline.
|
|
151
|
+
*/
|
|
152
|
+
export function startLegionClaim(deps: {
|
|
153
|
+
readonly pane: LegionPane
|
|
154
|
+
readonly sessionID: string
|
|
155
|
+
readonly daemon: LegionDaemonClient
|
|
156
|
+
readonly client: Pick<EnvoyClient, "setRole">
|
|
157
|
+
readonly session: Pick<ChannelSession, "rememberRole" | "onRoleRegained">
|
|
158
|
+
readonly signal: AbortSignal
|
|
159
|
+
/** Writes one line to the pane's log, the server's stderr. */
|
|
160
|
+
readonly log: (line: string) => void
|
|
161
|
+
/** Ends the pane's Claude Code, this server's parent, so the daemon launches the claim again. */
|
|
162
|
+
readonly stop: () => void
|
|
163
|
+
readonly pollMs?: number
|
|
164
|
+
}): LegionClaim {
|
|
165
|
+
let capability: ClaimCapability | undefined
|
|
166
|
+
const { sessionID } = deps
|
|
167
|
+
|
|
168
|
+
const boot = async (): Promise<void> => {
|
|
169
|
+
const transcript = await waitForTranscript(
|
|
170
|
+
deps.pane.configDir,
|
|
171
|
+
sessionID,
|
|
172
|
+
deps.signal,
|
|
173
|
+
deps.pollMs,
|
|
174
|
+
)
|
|
175
|
+
if (deps.pane.launcherSaves) {
|
|
176
|
+
await waitForSavedTranscript(transcript, deps.signal, deps.pollMs)
|
|
177
|
+
}
|
|
178
|
+
await bootLegionClaim({
|
|
179
|
+
daemon: deps.daemon,
|
|
180
|
+
registration: {
|
|
181
|
+
bootToken: deps.pane.bootToken,
|
|
182
|
+
sessionId: sessionID,
|
|
183
|
+
ompSessionFile: transcript,
|
|
184
|
+
agentId: sessionID,
|
|
185
|
+
pluginContract: legion.daemonApiVersion,
|
|
186
|
+
},
|
|
187
|
+
setCapability: (registered) => {
|
|
188
|
+
capability = registered
|
|
189
|
+
},
|
|
190
|
+
claimRole: async (role) => {
|
|
191
|
+
const claimed = await deps.client.setRole({ sessionID, role, soft: true })
|
|
192
|
+
if (!claimed.claimed) {
|
|
193
|
+
throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`)
|
|
194
|
+
}
|
|
195
|
+
await deps.session.rememberRole(role)
|
|
196
|
+
},
|
|
197
|
+
onRoleRegained: (listener) => deps.session.onRoleRegained(listener),
|
|
198
|
+
log: deps.log,
|
|
199
|
+
stopping: "stopping the pane so the daemon launches it again",
|
|
200
|
+
stop: deps.stop,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const booted = boot().catch((error: unknown) => {
|
|
205
|
+
if (deps.signal.aborted) return
|
|
206
|
+
deps.log(
|
|
207
|
+
`claim boot failed before a registration; the daemon's registration deadline relaunches the pane: ${messageFor(error)}`,
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
return { capability: () => capability, booted }
|
|
211
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { createConnection, type Socket } from "node:net"
|
|
3
|
+
import type { DeliveryCapability } from "@legion/contracts"
|
|
4
|
+
import type { ChannelNotification, ChannelNotifier } from "./envoy-channel-server"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The variable `legion claude-bridge` exports to Claude Code, and so to this server, naming its
|
|
8
|
+
* inbox socket (`packages/daemon/internal/claudebridge/inbox.go`): Claude Code's print mode never
|
|
9
|
+
* delivers a `notifications/claude/channel`, so in a Legion pane each Envoy delivery goes there and
|
|
10
|
+
* the bridge sends it to Claude Code as a user turn.
|
|
11
|
+
*/
|
|
12
|
+
export const INBOX_VARIABLE = "LEGION_CLAUDE_INBOX"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The delivery modes a session whose deliveries go to the bridge's inbox advertises. The bridge
|
|
16
|
+
* (`deliver` in `packages/daemon/internal/claudebridge/bridge.go`) starts a turn with a delivery
|
|
17
|
+
* when Claude Code is idle and otherwise sends it as the next user message of the turn running,
|
|
18
|
+
* once Claude Code ends its current reply: a Send. An Aside gets the same handling, which keeps
|
|
19
|
+
* its promise: the message joins the main conversation without interrupting the work in hand.
|
|
20
|
+
* The inbox line carries no mode, since the bridge treats both alike. `btw` stays off: the bridge
|
|
21
|
+
* runs no side turn.
|
|
22
|
+
*/
|
|
23
|
+
export const INBOX_CAPABILITIES = [
|
|
24
|
+
"aside",
|
|
25
|
+
"steer",
|
|
26
|
+
] as const satisfies readonly DeliveryCapability[]
|
|
27
|
+
|
|
28
|
+
/** `InboxAnswer`: the bridge's line back for one delivery, once Claude Code took it or cannot. */
|
|
29
|
+
interface InboxAnswer {
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly ok?: boolean
|
|
32
|
+
readonly error?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A notifier that writes each delivery to the bridge's inbox at `socketPath`, one JSON line
|
|
37
|
+
* `{id, content}` (the envelope's `event_id`, else a fresh id; the rendered content), and resolves
|
|
38
|
+
* once the bridge answers `{id, ok: true}`. The bridge answers `ok` only when Claude Code re-emits
|
|
39
|
+
* that user message (`--replay-user-messages`), that is once it took the delivery into a step, and
|
|
40
|
+
* an error when Claude Code never takes it: a relayed frame is answered `done` on the one and `nak`
|
|
41
|
+
* on the other. An answer with an error, a closed socket or an answer for another id rejects. The
|
|
42
|
+
* bridge answers one connection's lines in order, so one connection, opened on first use and again
|
|
43
|
+
* after it closes, carries every delivery.
|
|
44
|
+
*/
|
|
45
|
+
export function createInboxNotifier(socketPath: string): ChannelNotifier {
|
|
46
|
+
let socket: Socket | undefined
|
|
47
|
+
let buffered = ""
|
|
48
|
+
const waiting: {
|
|
49
|
+
readonly id: string
|
|
50
|
+
readonly settle: PromiseWithResolvers<void>
|
|
51
|
+
}[] = []
|
|
52
|
+
|
|
53
|
+
const fail = (error: Error): void => {
|
|
54
|
+
for (const pending of waiting.splice(0)) pending.settle.reject(error)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const answer = (line: string): void => {
|
|
58
|
+
const pending = waiting.shift()
|
|
59
|
+
if (pending === undefined) return
|
|
60
|
+
let parsed: InboxAnswer
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(line) as InboxAnswer
|
|
63
|
+
} catch {
|
|
64
|
+
pending.settle.reject(new Error(`the Legion inbox answered ${JSON.stringify(line)}`))
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (parsed.ok === true && parsed.id === pending.id) pending.settle.resolve()
|
|
68
|
+
else {
|
|
69
|
+
pending.settle.reject(
|
|
70
|
+
new Error(
|
|
71
|
+
`the Legion inbox refused delivery ${pending.id}: ${parsed.error ?? `answered ${line}`}`,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const connected = (): Socket => {
|
|
78
|
+
if (socket !== undefined && !socket.destroyed) return socket
|
|
79
|
+
buffered = ""
|
|
80
|
+
const opened = createConnection(socketPath)
|
|
81
|
+
opened.setEncoding("utf8")
|
|
82
|
+
opened.on("data", (chunk: string) => {
|
|
83
|
+
buffered += chunk
|
|
84
|
+
for (let end = buffered.indexOf("\n"); end >= 0; end = buffered.indexOf("\n")) {
|
|
85
|
+
const line = buffered.slice(0, end)
|
|
86
|
+
buffered = buffered.slice(end + 1)
|
|
87
|
+
answer(line)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
opened.on("error", (error) =>
|
|
91
|
+
fail(new Error(`the Legion inbox ${socketPath}: ${error.message}`)),
|
|
92
|
+
)
|
|
93
|
+
opened.on("close", () => {
|
|
94
|
+
fail(new Error(`the Legion inbox ${socketPath} closed before it answered`))
|
|
95
|
+
if (socket === opened) socket = undefined
|
|
96
|
+
})
|
|
97
|
+
socket = opened
|
|
98
|
+
return opened
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
notification(notification: ChannelNotification): Promise<void> {
|
|
103
|
+
const id = notification.params.meta["event_id"] ?? randomUUID()
|
|
104
|
+
const settle = Promise.withResolvers<void>()
|
|
105
|
+
waiting.push({ id, settle })
|
|
106
|
+
connected().write(`${JSON.stringify({ id, content: notification.params.content })}\n`)
|
|
107
|
+
return settle.promise
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|