@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,554 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import type { LegionState } from "@legion/contracts/legion-api"
|
|
5
|
+
import { messageFor } from "@legion/envoy-client/errors"
|
|
6
|
+
import type { ActivityInput } from "@legion/pi-shared/daemon-client"
|
|
7
|
+
import type { ClaimCapability } from "@legion/pi-shared/legion-phase-ops"
|
|
8
|
+
import {
|
|
9
|
+
createPhaseReminder,
|
|
10
|
+
type EnvoyPublish,
|
|
11
|
+
type PhaseReminderLog,
|
|
12
|
+
} from "@legion/pi-shared/phase-reminder"
|
|
13
|
+
import { type PhaseStallEntry, phaseStallEntryFrom } from "@legion/pi-shared/phase-stall"
|
|
14
|
+
import {
|
|
15
|
+
CLAUDE_BACKGROUND_TASK_KIND,
|
|
16
|
+
sessionActivity,
|
|
17
|
+
sessionActivityRequest,
|
|
18
|
+
} from "@legion/pi-shared/session-activity"
|
|
19
|
+
import { stallLimits } from "@legion/pi-shared/stall-limits"
|
|
20
|
+
import { workspaceRecreated, workspaceRecreatedNotice } from "@legion/pi-shared/workspace-recreated"
|
|
21
|
+
import { z } from "zod"
|
|
22
|
+
import { configuredValue } from "./claude-session"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The endless phase reminder's Claude Code host (INFRA-220). A Claude Code phase worker runs no Oh
|
|
26
|
+
* My Pi process, so the machine `packages/pi-shared/src/phase-reminder.ts` holds — the one
|
|
27
|
+
* `packages/pi-legion/extensions/legion.ts` drives from Oh My Pi's session hooks — is driven here
|
|
28
|
+
* instead, from Claude Code's own hooks: each fires an `mcp_tool` hook against `legion_phase_hook`
|
|
29
|
+
* on the pane's `legion` MCP server, the server this file's endpoint is registered on
|
|
30
|
+
* (`./envoy-channel-server`). Nothing of the machine is re-decided here.
|
|
31
|
+
*
|
|
32
|
+
* The record lives in a file rather than a transcript entry, `<CLAUDE_CONFIG_DIR>/
|
|
33
|
+
* legion-phase-stall-<session id>.json`. Nothing else writes that file: the Go bridge, which
|
|
34
|
+
* wrote it until this change made it pure transport, is now only what carries a line into the
|
|
35
|
+
* session. The path and the atomic rename are kept as the bridge left them, so a session carried
|
|
36
|
+
* across the cutover from the build now in the pods still reads its record.
|
|
37
|
+
*
|
|
38
|
+
* The first reminder of a run is the `Stop` hook's `block`, which Claude Code turns into one more
|
|
39
|
+
* turn of the same session. Every one after it is written into the session over the bridge's
|
|
40
|
+
* inbox, from a timer this server arms at a turn's end: a line written while a turn is running is
|
|
41
|
+
* appended to that turn as its next step (`deliver`/`step` in
|
|
42
|
+
* `packages/daemon/internal/claudebridge/bridge.go`), which would destroy the turn boundary the
|
|
43
|
+
* whole split exists for.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The tool's name: Claude Code shows it as `mcp__legion__legion_phase_hook`, and a hook entry
|
|
47
|
+
* names it as the bare `legion_phase_hook` on the server `legion`. */
|
|
48
|
+
export const PHASE_HOOK_TOOL_NAME = "legion_phase_hook"
|
|
49
|
+
|
|
50
|
+
/** What the tool says it is to the model that sees it in the tool list, and what a call this
|
|
51
|
+
* endpoint does not recognise is answered with. A model that calls the tool anyway must not be
|
|
52
|
+
* able to move the state, so that answer records nothing. */
|
|
53
|
+
export const PHASE_HOOK_REFUSAL =
|
|
54
|
+
"legion_phase_hook is Claude Code's hook endpoint for Legion's phase reminder: Claude Code's " +
|
|
55
|
+
"own hooks call it with the event they fire for, and it does nothing for a call the model " +
|
|
56
|
+
"makes. Do not call it."
|
|
57
|
+
|
|
58
|
+
/** The events the five generated hook entries fire (`legion/hooks/hooks.json`). */
|
|
59
|
+
const HOOK_EVENTS = [
|
|
60
|
+
"Stop",
|
|
61
|
+
"StopFailure",
|
|
62
|
+
"PostToolUse",
|
|
63
|
+
"PostToolUseFailure",
|
|
64
|
+
"UserPromptSubmit",
|
|
65
|
+
] as const
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How many inbox lines this server wrote are kept waiting for the prompt that carries them back.
|
|
69
|
+
* Each is dropped as its prompt arrives, so the set stays at the one or two lines in flight; the
|
|
70
|
+
* cap is what bounds it when a line never comes back at all, because the bridge handed two of
|
|
71
|
+
* them to one turn. A turn whose line was dropped by the cap counts as an assignment rather than
|
|
72
|
+
* an inbound event: both put the count back to 0, but an assignment reopens a phase an inbound
|
|
73
|
+
* event would leave closed, so the mistake is the unsafe direction and the record check is all
|
|
74
|
+
* that bounds it. The cap is high enough that only a bridge that lost a line reaches it.
|
|
75
|
+
*/
|
|
76
|
+
const WRITTEN_LINES_KEPT = 32
|
|
77
|
+
|
|
78
|
+
/** One hook call's arguments: the event, and that event's own fields. Claude Code substitutes
|
|
79
|
+
* `${…}` as strings, so every field is one — `stopHookActive` ("true"/"false") and
|
|
80
|
+
* `backgroundTasks` (the JSON array, as text) included. */
|
|
81
|
+
const hookArguments = z.object({
|
|
82
|
+
event: z.enum(HOOK_EVENTS),
|
|
83
|
+
lastAssistantMessage: z.string().optional(),
|
|
84
|
+
stopHookActive: z.string().optional(),
|
|
85
|
+
backgroundTasks: z.string().optional(),
|
|
86
|
+
toolName: z.string().optional(),
|
|
87
|
+
agentId: z.string().optional(),
|
|
88
|
+
prompt: z.string().optional(),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/** The `${background_tasks}` payload: one entry per task of the session, of which only the status
|
|
92
|
+
* decides anything here. Claude Code 2.1.310 writes each as
|
|
93
|
+
* `{ id, type, status, description, command }`. */
|
|
94
|
+
const backgroundTaskList = z.array(z.object({ status: z.string() }).loose())
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the turn now ending leaves background work of this session's own still running, from
|
|
98
|
+
* `${background_tasks}` in the `Stop` hook's input. Claude Code substitutes it as that JSON array
|
|
99
|
+
* rendered to text, and as `[]` when there are none; measured on 2.1.310 against a `Bash` call
|
|
100
|
+
* with `run_in_background: true`, whose task reads `"status":"running"` at the `Stop` of the turn
|
|
101
|
+
* that started it.
|
|
102
|
+
*
|
|
103
|
+
* A status other than `running` — a task that finished inside the turn — is no reason to keep the
|
|
104
|
+
* reminder back, and an argument an older binary does not substitute at all is read as no
|
|
105
|
+
* background work, which is the behaviour before this field existed.
|
|
106
|
+
*/
|
|
107
|
+
function backgroundWorkRunning(tasks: string | undefined): boolean {
|
|
108
|
+
if (tasks === undefined || tasks.trim() === "") return false
|
|
109
|
+
let parsed: unknown
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(tasks)
|
|
112
|
+
} catch {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
const list = backgroundTaskList.safeParse(parsed)
|
|
116
|
+
return list.success && list.data.some((task) => task.status === "running")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The tool as `tools/list` declares it. */
|
|
120
|
+
export function phaseHookToolDefinition() {
|
|
121
|
+
return {
|
|
122
|
+
name: PHASE_HOOK_TOOL_NAME,
|
|
123
|
+
description: PHASE_HOOK_REFUSAL,
|
|
124
|
+
inputSchema: z.toJSONSchema(hookArguments),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One hook call's answer, as the tool's one JSON text block: `{}` when there is nothing to say,
|
|
129
|
+
* `{"decision":"block","reason":…}` on the `Stop` that carries a reminder — which Claude Code
|
|
130
|
+
* turns into one more turn of the same session, holding the reason — or the refusal. */
|
|
131
|
+
export interface PhaseHookAnswer {
|
|
132
|
+
readonly decision?: "block"
|
|
133
|
+
readonly reason?: string
|
|
134
|
+
readonly error?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PhaseHookEndpoint {
|
|
138
|
+
/** Answers one hook call, with the record written before the answer. */
|
|
139
|
+
readonly handle: (input: unknown) => Promise<PhaseHookAnswer>
|
|
140
|
+
/** One line this server has just written into the bridge's inbox for an Envoy delivery. The
|
|
141
|
+
* prompt that carries it back is that delivery's turn, not a fresh assignment, and this is the
|
|
142
|
+
* only way the endpoint can tell the two apart: the server writes every delivery itself. */
|
|
143
|
+
readonly deliveryWritten: (content: string) => void
|
|
144
|
+
/** The `legion` tool's `handoff_complete`, or its `request_backward_move`, returned without
|
|
145
|
+
* throwing: this session's phase is no longer open. The CallTool handler of the same server
|
|
146
|
+
* calls it (`./envoy-channel-server`), as `onPhaseCompleted` does on the Oh My Pi host. */
|
|
147
|
+
readonly phaseClosed: () => void
|
|
148
|
+
/** The `legion` tool's `ready_for_update` returned without throwing: the worker has pushed
|
|
149
|
+
* what it wants kept and waits for the process its claim runs in to be replaced. Its phase
|
|
150
|
+
* stays open, and the `Stop` of the turn it answered in must block nothing: the work a
|
|
151
|
+
* continuation did would go with the pod, and the daemon would see no turn end. The same
|
|
152
|
+
* CallTool handler calls it, as `onUpdateAnswered` does on the Oh My Pi host. */
|
|
153
|
+
readonly updateAnswered: () => void
|
|
154
|
+
/** Drops the armed timer, so a 15-minute WAITING wait never holds a shutting-down server
|
|
155
|
+
* open (the server's own `stop`). */
|
|
156
|
+
readonly stop: () => void
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface PhaseHookDeps {
|
|
160
|
+
/** The record's file (`phaseStallFile`). */
|
|
161
|
+
readonly stateFile: string
|
|
162
|
+
/** The pane's registered claim, or undefined while its boot is still registering. */
|
|
163
|
+
readonly claim: () => ClaimCapability | undefined
|
|
164
|
+
/** Settles once the pane's claim boot has run (`startLegionClaim`'s `booted`). The resume
|
|
165
|
+
* reminder and the recreated-workspace notice both need the claim, and neither is sent before
|
|
166
|
+
* one exists. Never rejects. */
|
|
167
|
+
readonly booted: Promise<void>
|
|
168
|
+
/** The daemon's whole state, for the reminder's record check, aborted with `signal` once the
|
|
169
|
+
* check's bound passes (`PhaseReminderDeps.readState`). */
|
|
170
|
+
readonly readState: (signal: AbortSignal) => Promise<LegionState>
|
|
171
|
+
/** Sends this session's activity report to the daemon (`LegionDaemonClient.activity`), so the
|
|
172
|
+
* idle rule can decide whether the pane's pod may be released (INFRA-945). */
|
|
173
|
+
readonly reportActivity: (input: ActivityInput) => Promise<void>
|
|
174
|
+
/** Writes one line into the session over the bridge's inbox, which turns it into a user turn,
|
|
175
|
+
* and resolves once the bridge has queued it. Undefined for a session outside a Legion pane,
|
|
176
|
+
* where nothing can reach the worker between its turns. */
|
|
177
|
+
readonly inbox: ((text: string) => Promise<void>) | undefined
|
|
178
|
+
/** The server's own Envoy client's `publish`, or undefined when Envoy is off for the session:
|
|
179
|
+
* the architect is then not told, and the count still climbs. */
|
|
180
|
+
readonly publish: EnvoyPublish | undefined
|
|
181
|
+
/** The worker's environment: `LEGION_PROJECT`, the four `LEGION_STALL_*` numbers and
|
|
182
|
+
* `LEGION_WORKSPACE_RECREATED`. */
|
|
183
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
184
|
+
readonly log: PhaseReminderLog
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `<CLAUDE_CONFIG_DIR>/legion-phase-stall-<session id>.json`, the path the Go bridge built while
|
|
188
|
+
* it kept this record itself: kept as it was, so a session carried across the cutover from the
|
|
189
|
+
* build now in the pods finds the record where it left it. */
|
|
190
|
+
export function phaseStallFile(configDir: string, sessionID: string): string {
|
|
191
|
+
return join(configDir, `legion-phase-stall-${sessionID}.json`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** `PhaseReminderLog` over the pane's own log (`logLegion`, `./legion-claim`): one line each,
|
|
195
|
+
* with its context as JSON. */
|
|
196
|
+
export function paneReminderLog(write: (line: string) => void): PhaseReminderLog {
|
|
197
|
+
const line = (level: string, message: string, context?: Record<string, unknown>): void => {
|
|
198
|
+
write(
|
|
199
|
+
context === undefined
|
|
200
|
+
? `${level}: ${message}`
|
|
201
|
+
: `${level}: ${message} ${JSON.stringify(context)}`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
debug: (message, context) => line("debug", message, context),
|
|
206
|
+
warn: (message, context) => line("warn", message, context),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* What this host's state file holds: the record the machine shares with the Oh My Pi host, and the
|
|
212
|
+
* one key only this host keeps. `PhaseStallEntry` is the machine's record and both hosts read it,
|
|
213
|
+
* so the extra key is composed around it here rather than added to it, and a record the Go bridge
|
|
214
|
+
* left behind, which has no such key, still reads.
|
|
215
|
+
*/
|
|
216
|
+
export interface PhaseStallRecord {
|
|
217
|
+
/** The phase and count the file holds, or undefined when it holds none this build knows. */
|
|
218
|
+
readonly entry: PhaseStallEntry | undefined
|
|
219
|
+
/** Whether this launch's recreated-workspace notice has already been written into the session
|
|
220
|
+
* (`recreatedNoticeSent` in the file); false for a record that names no such key. It outlives
|
|
221
|
+
* only a server Claude Code restarts inside one `claude` process: a launch that recreated
|
|
222
|
+
* nothing clears it (`createPhaseHookEndpoint`). */
|
|
223
|
+
readonly recreatedNoticeSent: boolean
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A file this session holds no record in: a fresh session, an unreadable one, or one written by
|
|
227
|
+
* a build whose state this one does not know. */
|
|
228
|
+
const NO_RECORD: PhaseStallRecord = { entry: undefined, recreatedNoticeSent: false }
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The record `file` holds: a missing file is no record at all, so a fresh session restores
|
|
232
|
+
* nothing. The entry itself is assembled where the Oh My Pi host's is
|
|
233
|
+
* (`phaseStallEntryFrom`), so a record written by the build now in the pods —
|
|
234
|
+
* `{"state":"quiet"}` or `{"state":"text-call"}` — reads as an open phase with no reminders yet
|
|
235
|
+
* here too, and a missing `count`, `reported` or `reportKey` reads as 0, false and absent. This
|
|
236
|
+
* host adds the warning for a record it understands none of, and its own `recreatedNoticeSent`.
|
|
237
|
+
*/
|
|
238
|
+
export function readPhaseStallRecord(file: string, log: PhaseReminderLog): PhaseStallRecord {
|
|
239
|
+
let raw: string
|
|
240
|
+
try {
|
|
241
|
+
raw = readFileSync(file, "utf8")
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const missing =
|
|
244
|
+
typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
|
|
245
|
+
if (!missing) {
|
|
246
|
+
log.warn("legion: the phase-stall state could not be read; this session starts afresh", {
|
|
247
|
+
file,
|
|
248
|
+
error: messageFor(error),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
return NO_RECORD
|
|
252
|
+
}
|
|
253
|
+
let data: unknown
|
|
254
|
+
try {
|
|
255
|
+
data = JSON.parse(raw)
|
|
256
|
+
} catch (error) {
|
|
257
|
+
log.warn("legion: the phase-stall state is not JSON; this session starts afresh", {
|
|
258
|
+
file,
|
|
259
|
+
error: messageFor(error),
|
|
260
|
+
})
|
|
261
|
+
return NO_RECORD
|
|
262
|
+
}
|
|
263
|
+
if (typeof data !== "object" || data === null) {
|
|
264
|
+
log.warn("legion: the phase-stall state is not an object; this session starts afresh", { file })
|
|
265
|
+
return NO_RECORD
|
|
266
|
+
}
|
|
267
|
+
const entry = phaseStallEntryFrom(data)
|
|
268
|
+
if (entry === undefined) {
|
|
269
|
+
log.warn("legion: the phase-stall state holds no state this build knows; starting afresh", {
|
|
270
|
+
file,
|
|
271
|
+
})
|
|
272
|
+
return NO_RECORD
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
entry,
|
|
276
|
+
recreatedNoticeSent: "recreatedNoticeSent" in data && data.recreatedNoticeSent === true,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Replaces `file` with `entry` and this host's own key, through a temporary file in the same
|
|
281
|
+
* directory renamed into place, so a process killed mid-write never leaves half a record to read:
|
|
282
|
+
* the shape the Go bridge wrote the same file in while it kept this record, kept so a session
|
|
283
|
+
* carried across the cutover reads whatever the build now in the pods left. The key is written
|
|
284
|
+
* only once it is true, so a record this host writes is otherwise the shared entry and nothing
|
|
285
|
+
* more. A write that fails is logged rather than thrown: the worker still gets its reminder. */
|
|
286
|
+
export function writePhaseStallRecord(
|
|
287
|
+
file: string,
|
|
288
|
+
entry: PhaseStallEntry,
|
|
289
|
+
recreatedNoticeSent: boolean,
|
|
290
|
+
log: PhaseReminderLog,
|
|
291
|
+
): void {
|
|
292
|
+
const temporary = join(dirname(file), `.legion-phase-stall-${randomUUID()}`)
|
|
293
|
+
try {
|
|
294
|
+
writeFileSync(
|
|
295
|
+
temporary,
|
|
296
|
+
JSON.stringify({ ...entry, ...(recreatedNoticeSent ? { recreatedNoticeSent } : {}) }),
|
|
297
|
+
{ mode: 0o600 },
|
|
298
|
+
)
|
|
299
|
+
renameSync(temporary, file)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
rmSync(temporary, { force: true })
|
|
302
|
+
log.warn("legion: the phase-stall state is not recorded for a relaunch", {
|
|
303
|
+
file,
|
|
304
|
+
error: messageFor(error),
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The endpoint the pane's `legion_phase_hook` tool calls, over this session's own record, which is
|
|
311
|
+
* restored here before any hook can be answered.
|
|
312
|
+
*/
|
|
313
|
+
export function createPhaseHookEndpoint(deps: PhaseHookDeps): PhaseHookEndpoint {
|
|
314
|
+
const limits = stallLimits(deps.env, deps.log)
|
|
315
|
+
const restored = readPhaseStallRecord(deps.stateFile, deps.log)
|
|
316
|
+
// The machine's entry and this host's own key are one record, so the last entry written is kept
|
|
317
|
+
// for the write the notice makes.
|
|
318
|
+
let entry: PhaseStallEntry = restored.entry ?? { state: "closed", count: 0, reported: false }
|
|
319
|
+
let recreatedNoticeSent = restored.recreatedNoticeSent
|
|
320
|
+
const saveRecord = (next: PhaseStallEntry): void => {
|
|
321
|
+
entry = next
|
|
322
|
+
writePhaseStallRecord(deps.stateFile, next, recreatedNoticeSent, deps.log)
|
|
323
|
+
}
|
|
324
|
+
// The key exists only so a server Claude Code restarts inside one `claude` process, whose
|
|
325
|
+
// environment is still its launch's, does not repeat that launch's notice. The launcher decides
|
|
326
|
+
// `LEGION_WORKSPACE_RECREATED` afresh for every launch, so a launch that recreated nothing
|
|
327
|
+
// clears it, and the next launch that recreates the workspace again is told again.
|
|
328
|
+
if (recreatedNoticeSent && !workspaceRecreated(deps.env)) {
|
|
329
|
+
recreatedNoticeSent = false
|
|
330
|
+
saveRecord(entry)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The one timer this server has armed, or undefined when none is. One at a time: arming the
|
|
334
|
+
* next drops it, and so does the server's `stop`. */
|
|
335
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
336
|
+
const dropTimer = (): void => {
|
|
337
|
+
if (timer === undefined) return
|
|
338
|
+
clearTimeout(timer)
|
|
339
|
+
timer = undefined
|
|
340
|
+
}
|
|
341
|
+
const armTimer = (callback: () => void | Promise<void>, delayMs: number): void => {
|
|
342
|
+
dropTimer()
|
|
343
|
+
timer = setTimeout(() => {
|
|
344
|
+
timer = undefined
|
|
345
|
+
void callback()
|
|
346
|
+
}, delayMs)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The reminder last written to the inbox: the prompt that carries it back is this endpoint's
|
|
350
|
+
* own reminder, and a reminder must never reset the count it just moved. */
|
|
351
|
+
let lastReminder: string | undefined
|
|
352
|
+
/** The lines this server wrote into the inbox for something that is not a reminder — an Envoy
|
|
353
|
+
* delivery, the recreated-workspace notice — whose prompt has not arrived yet. Each is dropped
|
|
354
|
+
* as its prompt arrives (`WRITTEN_LINES_KEPT` bounds the rest). */
|
|
355
|
+
const written = new Set<string>()
|
|
356
|
+
const noteWritten = (content: string): void => {
|
|
357
|
+
written.add(content)
|
|
358
|
+
for (const oldest of written) {
|
|
359
|
+
if (written.size <= WRITTEN_LINES_KEPT) break
|
|
360
|
+
written.delete(oldest)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Writes `text` into the session over the bridge's inbox, and settles as the inbox does: it
|
|
365
|
+
* rejects when the bridge did not queue the line. A session with no inbox says so and writes
|
|
366
|
+
* nothing, and that is no failure: no later attempt could reach the worker either. */
|
|
367
|
+
const writeToSession = async (text: string, what: string): Promise<void> => {
|
|
368
|
+
const { inbox } = deps
|
|
369
|
+
if (inbox === undefined) {
|
|
370
|
+
deps.log.warn(`legion: ${what} has no way to reach this worker: this session has no inbox`, {
|
|
371
|
+
text,
|
|
372
|
+
})
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
await inbox(text)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The inbox's failure goes back to the runner, which logs it, uncounts the reminder and arms
|
|
379
|
+
// the next one: no turn starts on a line the bridge did not take, so no `Stop` would.
|
|
380
|
+
const sendReminder = (text: string): Promise<void> => {
|
|
381
|
+
lastReminder = text
|
|
382
|
+
return writeToSession(text, "a phase reminder")
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const reminder = createPhaseReminder({
|
|
386
|
+
// The bridge turns one inbox line into a user turn, which is what every reminder after the
|
|
387
|
+
// `Stop` continuation is.
|
|
388
|
+
sendReminder,
|
|
389
|
+
armTimer,
|
|
390
|
+
// The record lands before the hook answers: every change of it is this synchronous write, and
|
|
391
|
+
// the machine makes it inside `settle` before that promise resolves. Claude Code may restart
|
|
392
|
+
// this server inside the same `claude` process, and a reminder the worker was given but whose
|
|
393
|
+
// count was never recorded is what the count exists to catch.
|
|
394
|
+
saveEntry: saveRecord,
|
|
395
|
+
claim: deps.claim,
|
|
396
|
+
readState: deps.readState,
|
|
397
|
+
// The server's own listener client, so the notice reaches the one this session already talks
|
|
398
|
+
// to; Envoy off for the session leaves it undefined, the runner says so once at debug, and
|
|
399
|
+
// the reminder keeps coming without a notice.
|
|
400
|
+
publisher: () => deps.publish,
|
|
401
|
+
project: configuredValue(deps.env["LEGION_PROJECT"]),
|
|
402
|
+
limits,
|
|
403
|
+
log: deps.log,
|
|
404
|
+
})
|
|
405
|
+
reminder.restore(restored.entry)
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* INFRA-945: what this session is doing, reported to the daemon as each turn settles, so the
|
|
409
|
+
* idle rule can decide whether the pane's pod may be released. Claude Code's only sight of
|
|
410
|
+
* background work is the `Stop` hook's `${background_tasks}`, so this host reports at the
|
|
411
|
+
* settle and nowhere else; the one kind it can name is a Claude Code background task.
|
|
412
|
+
*
|
|
413
|
+
* A report that does not reach the daemon is logged and dropped: the turn's reminder is what
|
|
414
|
+
* the hook owes its worker, and a claim with no report after its last turn is held rather than
|
|
415
|
+
* released.
|
|
416
|
+
*/
|
|
417
|
+
const sendActivity = async (claim: ClaimCapability, backgroundWork: boolean): Promise<void> => {
|
|
418
|
+
const request = sessionActivityRequest({
|
|
419
|
+
project: configuredValue(deps.env["LEGION_PROJECT"]),
|
|
420
|
+
claim,
|
|
421
|
+
report: sessionActivity(
|
|
422
|
+
backgroundWork ? [CLAUDE_BACKGROUND_TASK_KIND] : [],
|
|
423
|
+
reminder.phaseWake(),
|
|
424
|
+
),
|
|
425
|
+
})
|
|
426
|
+
if (request === undefined) return
|
|
427
|
+
try {
|
|
428
|
+
await deps.reportActivity(request)
|
|
429
|
+
} catch (error) {
|
|
430
|
+
deps.log.warn("legion: this session's activity report did not reach the daemon", {
|
|
431
|
+
issue: claim.issue,
|
|
432
|
+
error: messageFor(error),
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* D29: a worker whose launcher recreated its workspace is told so, as an Oh My Pi one is, over
|
|
439
|
+
* the same inbox the reminders use. Once per launch: the record it is written in is the one the
|
|
440
|
+
* phase and count live in, so a server Claude Code restarts inside the same `claude` process
|
|
441
|
+
* does not repeat it, and a launch that recreated nothing clears it again. The record lands
|
|
442
|
+
* before the line does — a notice a broken socket loses is one notice lost, while one written
|
|
443
|
+
* twice is the thing it must not be — and the line is noted as written, so the turn it starts
|
|
444
|
+
* counts as an inbound event and leaves a closed phase closed.
|
|
445
|
+
*/
|
|
446
|
+
const sendRecreatedNotice = (issue: string): void => {
|
|
447
|
+
if (recreatedNoticeSent) return
|
|
448
|
+
const notice = workspaceRecreatedNotice(deps.env, issue)
|
|
449
|
+
if (notice === undefined) return
|
|
450
|
+
recreatedNoticeSent = true
|
|
451
|
+
saveRecord(entry)
|
|
452
|
+
noteWritten(notice.content)
|
|
453
|
+
void writeToSession(notice.content, "the recreated-workspace notice").catch(
|
|
454
|
+
(error: unknown) => {
|
|
455
|
+
deps.log.warn("legion: the recreated-workspace notice did not reach this worker", {
|
|
456
|
+
error: messageFor(error),
|
|
457
|
+
})
|
|
458
|
+
},
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
void deps.booted.then(() => {
|
|
463
|
+
const claim = deps.claim()
|
|
464
|
+
if (claim === undefined) return
|
|
465
|
+
sendRecreatedNotice(claim.issue)
|
|
466
|
+
// D10: a worker the daemon relaunched into an open phase is reminded from a timer, since
|
|
467
|
+
// nothing else would wake it. The guard drops that timer when a turn started meanwhile, which
|
|
468
|
+
// is exactly the case to drop — the daemon re-sending an interrupted task arrives as a
|
|
469
|
+
// `UserPromptSubmit`.
|
|
470
|
+
if (claim.role !== "architect") reminder.armResumeReminder()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
deliveryWritten: noteWritten,
|
|
475
|
+
phaseClosed: () => reminder.apply({ kind: "phase-closed" }),
|
|
476
|
+
updateAnswered: () => reminder.apply({ kind: "update-answered" }),
|
|
477
|
+
stop: dropTimer,
|
|
478
|
+
handle: async (input) => {
|
|
479
|
+
const parsed = hookArguments.safeParse(input)
|
|
480
|
+
// An event this endpoint does not know, or none at all, is the model calling the tool
|
|
481
|
+
// itself: it is refused, and no state moves, so it cannot zero its own count.
|
|
482
|
+
if (!parsed.success) return { error: PHASE_HOOK_REFUSAL }
|
|
483
|
+
const event = parsed.data
|
|
484
|
+
// The same gate as `phaseWorkerSession` in `packages/pi-legion/extensions/legion.ts`: the
|
|
485
|
+
// machine runs for a phase worker whose claim has booted, and for nobody else. The bridge's
|
|
486
|
+
// seeding `/rename` turn is what writes the transcript the claim boot waits for
|
|
487
|
+
// (`waitForTranscript`, `./legion-claim`), so it runs before any claim exists, answers `{}`
|
|
488
|
+
// here, and is never reminded.
|
|
489
|
+
const claim = deps.claim()
|
|
490
|
+
if (claim === undefined || claim.role === "architect") return {}
|
|
491
|
+
switch (event.event) {
|
|
492
|
+
case "Stop": {
|
|
493
|
+
// `stop_hook_active` arrives as "true" once this hook has already continued the turn. It
|
|
494
|
+
// decides nothing: the count is what bounds a run of reminders. `background_tasks` does
|
|
495
|
+
// decide: a turn that ends with a background task of the worker's own still running is
|
|
496
|
+
// a worker waiting to be woken by it, not a stalled one.
|
|
497
|
+
const backgroundWork = backgroundWorkRunning(event.backgroundTasks)
|
|
498
|
+
deps.log.debug("legion: a Claude Code phase worker's turn is settling", {
|
|
499
|
+
issue: claim.issue,
|
|
500
|
+
role: claim.role,
|
|
501
|
+
stopHookActive: event.stopHookActive ?? "",
|
|
502
|
+
backgroundWork,
|
|
503
|
+
})
|
|
504
|
+
const reminded = await reminder.settle(event.lastAssistantMessage ?? "", {
|
|
505
|
+
backgroundWork,
|
|
506
|
+
})
|
|
507
|
+
// After the settle, so the report carries the wake that settle armed. The daemon
|
|
508
|
+
// releases no claim whose last report is older than its last turn, so it goes at every
|
|
509
|
+
// settle, whatever it says.
|
|
510
|
+
await sendActivity(claim, backgroundWork)
|
|
511
|
+
return reminded === undefined ? {} : { decision: "block", reason: reminded }
|
|
512
|
+
}
|
|
513
|
+
case "StopFailure": {
|
|
514
|
+
// The turn ended on an API error. Claude Code ignores whatever this hook returns, so
|
|
515
|
+
// this event cannot carry a continuation: the machine is told so and arms the reminder
|
|
516
|
+
// its settle earned itself, under every guard an armed reminder has. This is the one
|
|
517
|
+
// event of the five the pod measurements did not cover; it records only, so a
|
|
518
|
+
// `StopFailure` that never fires costs the reminder after an API-error turn and nothing
|
|
519
|
+
// else.
|
|
520
|
+
await reminder.settle(event.lastAssistantMessage ?? "", { canContinue: false })
|
|
521
|
+
return {}
|
|
522
|
+
}
|
|
523
|
+
case "PostToolUse":
|
|
524
|
+
case "PostToolUseFailure": {
|
|
525
|
+
// The worker is working, whatever it is working on, and an attempted call that failed
|
|
526
|
+
// is work too. `${agent_id}` substitutes to "" in the main conversation and to a
|
|
527
|
+
// non-empty id for a subagent (measured on the real binary), so a helper agent's call
|
|
528
|
+
// is not this worker answering its reminder.
|
|
529
|
+
if ((event.agentId ?? "") !== "") return {}
|
|
530
|
+
reminder.apply({ kind: "tool-call" })
|
|
531
|
+
return {}
|
|
532
|
+
}
|
|
533
|
+
case "UserPromptSubmit": {
|
|
534
|
+
// D7: the only sight this server has of an inbound turn. The daemon's task reaches
|
|
535
|
+
// Claude Code's stdin from the bridge and never this process, so what a turn means is
|
|
536
|
+
// read from the prompt itself. The turn is declared first, which makes every armed
|
|
537
|
+
// timer stale, and the pending one is dropped outright: the runner's generation guard
|
|
538
|
+
// answers the same for the timers it armed, and the one this endpoint arms itself (the
|
|
539
|
+
// `StopFailure` reminder) obeys the same rule that way rather than through a second
|
|
540
|
+
// guard of its own.
|
|
541
|
+
reminder.turnStarted()
|
|
542
|
+
dropTimer()
|
|
543
|
+
const prompt = event.prompt ?? ""
|
|
544
|
+
if (prompt === lastReminder) return {}
|
|
545
|
+
// A line this server wrote is dropped as its prompt arrives, so a later turn carrying
|
|
546
|
+
// the same text is an assignment, which is what a repeat of it would be.
|
|
547
|
+
const delivered = written.delete(prompt)
|
|
548
|
+
reminder.apply({ kind: delivered ? "inbound-event" : "assignment" })
|
|
549
|
+
return {}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { LegionRole } from "@legion/contracts"
|
|
2
|
+
import type { LegionPhase } from "@legion/contracts/legion-api"
|
|
3
|
+
import type { LegionDaemonClient } from "@legion/pi-shared/daemon-client"
|
|
4
|
+
import type { HandoffReport } from "@legion/pi-shared/handoff-record"
|
|
5
|
+
import {
|
|
6
|
+
type ClaimCapability,
|
|
7
|
+
executePhaseOp,
|
|
8
|
+
legionToolDescription,
|
|
9
|
+
PHASE_OP_SENTENCES,
|
|
10
|
+
PHASE_OPS,
|
|
11
|
+
phaseOpParameters,
|
|
12
|
+
} from "@legion/pi-shared/legion-phase-ops"
|
|
13
|
+
import { z } from "zod"
|
|
14
|
+
|
|
15
|
+
/** The tool's name: Claude Code shows it as `mcp__legion__legion`, under the pane's one server. */
|
|
16
|
+
export const LEGION_TOOL_NAME = "legion"
|
|
17
|
+
|
|
18
|
+
/** The tool as `tools/list` declares it: one object schema, `op` choosing one of the phase ops
|
|
19
|
+
* (`@legion/pi-shared/legion-phase-ops`), the operations a phase worker gets. */
|
|
20
|
+
export function legionToolDefinition() {
|
|
21
|
+
return {
|
|
22
|
+
name: LEGION_TOOL_NAME,
|
|
23
|
+
description: legionToolDescription({
|
|
24
|
+
envoyPublish: "mcp__legion__envoy_publish",
|
|
25
|
+
before: PHASE_OP_SENTENCES.readRecord + PHASE_OP_SENTENCES.readReleaseRecords,
|
|
26
|
+
after: PHASE_OP_SENTENCES.requestBackwardMove,
|
|
27
|
+
}),
|
|
28
|
+
inputSchema: z.toJSONSchema(z.object({ op: z.enum(PHASE_OPS), ...phaseOpParameters(z) })),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LegionToolRuntime {
|
|
33
|
+
readonly daemon: LegionDaemonClient
|
|
34
|
+
/** The pane's registered claim, or undefined while its boot is still registering. */
|
|
35
|
+
readonly claim: () => ClaimCapability | undefined
|
|
36
|
+
/** What a completion of `phase` by `role` reports beside its summary (`findHandoffReport` with
|
|
37
|
+
* the pane's environment and Dispatch): throws naming the remedy when the phase has no handoff
|
|
38
|
+
* record, Dispatch does not answer, or the implementer's work is not pushed. */
|
|
39
|
+
readonly handoffReport: (phase: LegionPhase, role: LegionRole) => Promise<HandoffReport>
|
|
40
|
+
/** The head of `legion/<issue>@origin` a `ready_for_update` signal reports
|
|
41
|
+
* (`findPushedBranchHead` with the pane's environment): `""` when the branch is not there. */
|
|
42
|
+
readonly branchHead: () => Promise<string>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Runs one call of the `legion` tool and answers its JSON result; a refusal throws its reason.
|
|
46
|
+
* `signal`, the MCP request's cancellation, ends a `handoff_complete` waiting out a daemon
|
|
47
|
+
* restart. */
|
|
48
|
+
export async function executeLegionTool(
|
|
49
|
+
runtime: LegionToolRuntime,
|
|
50
|
+
input: unknown,
|
|
51
|
+
signal?: AbortSignal,
|
|
52
|
+
): Promise<unknown> {
|
|
53
|
+
const claim = runtime.claim()
|
|
54
|
+
if (claim === undefined) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"this session has no Legion claim yet: the pane registers with the daemon once its first turn starts",
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return executePhaseOp(
|
|
60
|
+
{
|
|
61
|
+
daemon: runtime.daemon,
|
|
62
|
+
claim,
|
|
63
|
+
handoffReport: runtime.handoffReport,
|
|
64
|
+
branchHead: runtime.branchHead,
|
|
65
|
+
...(signal === undefined ? {} : { restart: { signal } }),
|
|
66
|
+
},
|
|
67
|
+
input,
|
|
68
|
+
)
|
|
69
|
+
}
|