@sjawhar/claude-envoy 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/plugin.json +6 -0
  4. package/AGENTS.md +343 -0
  5. package/README.md +639 -0
  6. package/bin/dispatch +6 -0
  7. package/bin/envoy-channel.ts +9 -0
  8. package/bin/envoy-send.ts +33 -0
  9. package/dist/THIRD_PARTY_NOTICES +816 -0
  10. package/dist/dispatch.js +27707 -0
  11. package/dist/envoy-channel.js +57695 -0
  12. package/dist/session-hook.js +26328 -0
  13. package/dist/session-title-hook.js +116 -0
  14. package/hooks/hooks.json +60 -0
  15. package/hooks/session-hook.ts +512 -0
  16. package/hooks/session-title-hook.ts +46 -0
  17. package/legion/.claude-plugin/plugin.json +4 -0
  18. package/legion/agents/deep-worker.md +68 -0
  19. package/legion/agents/oracle.md +35 -0
  20. package/legion/agents/plan-gap-analyst.md +65 -0
  21. package/legion/agents/plan-reviewer.md +104 -0
  22. package/legion/agents/thermonuclear-code-quality.md +30 -0
  23. package/legion/agents/thermonuclear-deep-review.md +30 -0
  24. package/legion/hooks/hooks.json +89 -0
  25. package/legion/skills/ce-simplify-code/LICENSE +21 -0
  26. package/legion/skills/ce-simplify-code/SKILL.md +66 -0
  27. package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  28. package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  29. package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  30. package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
  31. package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
  32. package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
  33. package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
  34. package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
  35. package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
  36. package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
  37. package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
  38. package/legion/skills/legion-architect/SKILL.md +86 -0
  39. package/legion/skills/legion-architect/references/acceptance.md +208 -0
  40. package/legion/skills/legion-architect/references/decomposition.md +129 -0
  41. package/legion/skills/legion-architect/references/design-gate.md +206 -0
  42. package/legion/skills/legion-architect/references/merge-notices.md +91 -0
  43. package/legion/skills/legion-architect/references/wake-routing.md +41 -0
  44. package/legion/skills/legion-controller/SKILL.md +151 -0
  45. package/legion/skills/legion-controller/references/admission.md +22 -0
  46. package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
  47. package/legion/skills/legion-controller/references/daily-report.md +75 -0
  48. package/legion/skills/legion-controller/references/escalation.md +82 -0
  49. package/legion/skills/legion-controller/references/finding-asks.md +181 -0
  50. package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
  51. package/legion/skills/legion-controller/references/session.md +100 -0
  52. package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
  53. package/legion/skills/legion-controller/references/triage.md +96 -0
  54. package/legion/skills/legion-controller/references/wakes.md +132 -0
  55. package/legion/skills/legion-oracle/SKILL.md +48 -0
  56. package/legion/skills/legion-retro/SKILL.md +112 -0
  57. package/legion/skills/legion-retro/references/learnings.md +60 -0
  58. package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
  59. package/legion/skills/legion-worker/SKILL.md +69 -0
  60. package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
  61. package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
  62. package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
  63. package/legion/skills/legion-worker/references/findings.md +199 -0
  64. package/legion/skills/legion-worker/references/merge-gate.md +353 -0
  65. package/legion/skills/legion-worker/references/pr-body.md +218 -0
  66. package/legion/skills/legion-worker/references/review-threads.md +77 -0
  67. package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
  68. package/legion/skills/legion-worker/references/tools.md +708 -0
  69. package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
  70. package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
  71. package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
  72. package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
  73. package/package.json +49 -1
  74. package/scripts/build-inputs.txt +84 -0
  75. package/scripts/build.ts +415 -0
  76. package/scripts/check-packed-skills.sh +50 -0
  77. package/scripts/pack-skills.sh +146 -0
  78. package/scripts/smoke-channel.sh +178 -0
  79. package/scripts/smoke-clear-rebind.sh +164 -0
  80. package/scripts/smoke-session-title.sh +245 -0
  81. package/scripts/verify-build.sh +88 -0
  82. package/skills/agent-secrets/SKILL.md +180 -0
  83. package/skills/asd-ste100/LICENSE +21 -0
  84. package/skills/asd-ste100/SKILL.md +140 -0
  85. package/skills/asd-ste100/examples/before-after.md +92 -0
  86. package/skills/asd-ste100/references/writing-rules.md +59 -0
  87. package/skills/dispatch/SKILL.md +89 -0
  88. package/skills/dispatch-first/SKILL.md +46 -0
  89. package/skills/dispatch-writing-specs/SKILL.md +80 -0
  90. package/skills/envoy/SKILL.md +82 -0
  91. package/skills/envoy/references/check-settlement.md +62 -0
  92. package/skills/envoy/references/deliveries.md +67 -0
  93. package/skills/envoy/references/legion.md +14 -0
  94. package/skills/envoy/references/roles.md +12 -0
  95. package/skills/envoy/references/sources.md +41 -0
  96. package/skills/outside-message/SKILL.md +111 -0
  97. package/skills/plain-words/SKILL.md +88 -0
  98. package/src/channel-forwarder.ts +262 -0
  99. package/src/claude-channel.ts +705 -0
  100. package/src/claude-session.ts +38 -0
  101. package/src/envoy-channel-server.ts +2115 -0
  102. package/src/envoy-client.ts +26 -0
  103. package/src/envoy-off.ts +62 -0
  104. package/src/legion-claim.ts +211 -0
  105. package/src/legion-inbox.ts +110 -0
  106. package/src/legion-phase-hook.ts +554 -0
  107. package/src/legion-tool.ts +69 -0
  108. package/src/replay-state.ts +97 -0
  109. package/src/send-arguments.ts +13 -0
  110. package/src/session-identity.ts +165 -0
  111. package/src/session-state.ts +142 -0
  112. package/src/session-title.ts +251 -0
  113. package/src/subscription-topics.ts +16 -0
@@ -0,0 +1,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
+ }