Runs several crews of agents in parallel from the main session: each crew's lead delegates to your project's agents in its own worktree, and the crews'…

Runs a large goal as several crews of agents working in parallel. The main session is the foreman: it plans the run, decides for it, and is the only one that talks to you. Each crew has a lead that delegates to member agents in its own worktree, and a judge that checks the crew's work against proof. A crew's branch merges into your target branch only after its judge says VERDICT: PASS.
docs/architecture.md covers the internals: who talks to whom, the files on disk, each hook, and how a run recovers from failures.
| Role | Who | What it does |
|---|---|---|
| Owner | You | Picks the goal's altitude, approves the plan, the merge mode and the target branch. Rules on changes to frozen scope, on production deploys, and on every push or PR. |
| Foreman | The main session, after /foundry:run | Plans the run and decides for it as product owner, logging every decision. Starts the leads, answers them, and merges what the crews deliver. Never does a crew's work. |
| Cynic | foundry:cynic, Opus, effort high, outside every crew | Read-only. For each question, proposal or blocker bound for you, recommends answer, defer or pass. Before the merge, checks the crews for drift from the plan and for overlap. It recommends; the foreman decides. |
| Lead | foundry:lead, Opus, one per crew | Splits the crew's goal into member tasks, delegates them, merges the members' branches into the crew branch, runs the checks and reports. |
| Members | Your project's agents, plugin agents, or general-purpose | Do the building. Each starts with an explicit model and effort and a name that starts with its crew's. |
| Judge | foundry:judge, Opus | Never the builder. Checks the proof in the crew's reports, answers a four-question rubric, and reports VERDICT: PASS or VERDICT: FAIL. Runs one effort level above the strongest builder it judges, from high to xhigh. In red-team mode it tries to break the crew's work instead. |
| Writer | foundry:writer, Opus | A member for the prose a crew ships. Loads the best writing skill the session has (technical-writer when it's installed) and writes only what the code or a command it ran shows. |
| DevOps | foundry:devops, Sonnet | A member that validates a deploy with proof: rollout status, health endpoint, smoke tests, logs. May deploy a preview or staging build. Never deploys to production or changes it. |
foundry works with or without agents of your own. When the project defines none that fit, members run as general-purpose with a prompt the lead writes, which says what the job is rather than who the agent is. The kit plugin's builder, test-writer, reviewer and scout make good members, and the worktrees plugin starts each worktree from your current commit instead of origin/HEAD.
/foundry:run loads the foreman's protocol. The foreman:
.claude/agents/*.md, yours in ~/.claude/agents/, plugin agents) and the project's skills;QUESTION:, BLOCKED: or PROPOSAL:. It decides what it can and logs it. Anything bound for you goes through a cynic first, and a PROPOSAL: defaults to no because the scope is frozen;VERDICT: PASS for the crew branch's head commit;The foreman follows the cynic's recommendation by default. To overrule it, the foreman logs a one-line reason, and every overrule appears in the end-of-run report. The foreman clears a BLOCKED: itself when it can inside frozen scope, for example from the plan, the decision log or something it knows: a missing input, a collision with another crew. Every other BLOCKED: reaches you, and none is ever deferred. A production promotion, a change to frozen scope, and every push or PR reach you whatever the cynic recommends, unless the project grants the foreman merge ownership for pushes and PRs.
A lead first proves it can run its crew's checks in its own worktree. If it can't, it sends BLOCKED: before splitting any work. It then runs its own, fuller depth pass and turns the answers into member tasks. Anything that would add scope goes to the foreman as a PROPOSAL:.
Every member report carries proof: a command and its output, before-and-after numbers, or a walk through the real flow. These don't count as proof: tests the builder wrote after building, "I verified it" with no output, a typecheck alone, a mocked end-to-end test, and output from another commit. Builders may not edit the done-check tests. Every commit carries a Foundry-Crew: <slug> trailer.
After the last merge and a green run of the checks, the lead starts a fresh judge. For a crew with high-impact paths, the plan can add a red team first: foundry:judge in red-team mode, which works in its own worktree, checks out the crew head SHA there itself, and proves each break with a command and its output. A reproduced break is fixed, and a fresh red team runs before the judge does.
When two fix attempts at one level end with the same check failing, the lead climbs the escalation ladder: Sonnet, then Opus at effort medium, high, xhigh. Each step is a fresh agent, never a resume, and its first deliverable is a root-cause analysis with evidence. If xhigh fails twice, the lead sends BLOCKED:.
The lead's final report has fixed sections: Summary, Branch, Changes, Proof, Checks run, Open questions. A report that starts with CHECKPOINT: means the lead stopped with work left, and the foreman starts a successor lead, <slug>-2, from it.
| Mode | What happens | Use it when |
|---|---|---|
sequential | Crews land on the target one at a time. The foreman asks each lead in turn to rebase its branch onto the target, resolve the conflicts and run the checks. If the rebase moved the head, a fresh judge rules on it. On a PASS for that head, the foreman fast-forwards the target to it. | You want one reviewable merge per crew. |
single | One integrator agent (Opus, in a worktree) merges every crew branch into one integration branch, foundry-<short goal>, resolves the conflicts and runs the checks. An integration judge rules on the result. You get one PR. | Each merge to the target triggers something expensive, such as a deploy. |
Conflicts are always resolved by an agent, never in the main thread.
Before anything leaves the machine, the foreman reports each crew's summary and what merged where, the checks and every judge's verdict, the decisions it made and every cynic overrule with its reason, and the items deferred to the end. It lists the high-impact files that changed and asks you to read those, and only those.
A crew is a foundry:lead started by the main thread, plus every agent below it, however deep. An agent goes by the name it was spawned with, else its id, and the crew by its lead's. Agents outside every crew are left alone, which includes the cynic, the integrator and the integration judge.
<git common dir>/foundry/<session id>/reports/<crew>/<agent name>.md. A report sent through the hand-back tool is saved instead. Outside git the folder is ~/.claude/foundry/<session id>/. The git common dir is the main checkout's .git, so every worktree writes to the same place. Hand-backs from nested agents have been lost on some machines; the lead and the foreman read the file when one doesn't arrive. Each Agent call that starts a lead or a crew member gets a [foundry] note naming the path, and the main session gets a [foundry] dir: <path> note at session start.SendMessage may go to its own lead and the other members of its crew. A lead's may go to main, its own members and the other leads. Anything else from inside a crew is refused, with a reason telling a member to go through its lead and a lead to message the other crew's lead. So is an address no agent of the session has had, and a name two live agents in different crews share.<foundry folder>/messages.jsonl as { ts, from, to, summary }. Once the log passes 3 MiB it moves to messages.<n>.jsonl, the first free number, and a new log starts.model, without effort, or with effort: "max" is refused, with a reason saying what to set.## Proof section and doesn't start with CHECKPOINT:, BLOCKED:, QUESTION: or PROPOSAL:, the stop is sent back with a reason. The hook sends a stop back at most twice in a row, then lets it through. That bound relies on Claude Code sending stop_hook_active on the stop after a block, which hasn't been seen live; Claude Code itself lets a stop through after 8 consecutive blocks. A stop while a member, or a member's background task, still runs goes through; the lead's own background tasks don't count.[foundry] stalled: prompt to the main session.[foundry] limit reset: prompt, once per reset./foundry lists the crews: each lead's name and status, its live members by model, when it last reported and was last active, any stalled agents, the verdict in its highest-numbered judge file, and its report folder. A lead whose last message to main, or last captured report, starts with QUESTION:, BLOCKED: or PROPOSAL: shows as waiting on you:, blocked: or proposal: with the first line, until the foreman messages it. A lead that ended its turn while its members still work shows as waiting on members.Every hook fails open. If foundry's own code throws, the call goes on as if the plugin were not installed, and the error goes to the debug log.
/foundry, not /foundry:status. A plugin registers its command under a bare name, and /status is built in.<slug>-2 instead; the skill does.waiting on members depends on it.tool.call hook on SubagentHandback, is covered by tests but not yet verified in a live session.$.clock timers run from session.start, a hot reload cancels them (so the wake is re-armed from plugin state), and $.prompt.submit wakes an idle session. Nobody has yet seen the wake fire after a real usage-limit stop.SubagentStop hooks run only in plugins it lists. There, reports are captured only from the hand-back tool. The lead stop gate is a SubagentStop hook too, so it shouldn't run there either; that is inferred from the code, not seen.agents/lead.md tells a lead to load subagent-limits:split-work, a skill from the subagent-limits plugin, when it is available. A run without that plugin hasn't been tested.stop_hook_active on the stop after a block, which hasn't been seen live; Claude Code lets a stop through itself after 8 consecutive blocks./plugin marketplace add AbhiramDwivedi/claude-ops
/plugin install foundry@claude-ops
claude plugin validate plugins/foundry
claude plugin test plugins/foundry
Drafted with AI assistance and reviewed by the author.
hooks/register.ts 892 lines1import type { AgentInfo, ClassicResult, EngineInterface, Register, Timer, ToolCallResult } from 'claude-code'
2
3// the agent type every crew lead runs as; only a lead the main thread started heads a crew
4export const LEAD_TYPE = 'foundry:lead'
5// marks text this mod wrote
6export const MARK = '[foundry]'
7// the main session, as SendMessage addresses it
8export const MAIN = 'main'
9// how a lead's question to the person begins
10export const QUESTION = 'QUESTION:'
11// past this size the message log is rolled to messages.<n>.jsonl, well under what one $.fs.read takes
12export const ROLL_BYTES = 3 * 1024 * 1024
13// idle this long while running = stalled
14export const STALL_MS = 15 * 60_000
15// the stall check timer period
16export const HEARTBEAT_MS = 60_000
17// wake this long after a usage window's resetsAt
18export const RESET_GRACE_MS = 2 * 60_000
19// most blocks per lead per stop streak
20export const LEAD_STOP_BLOCKS = 2
21export const STALLED = '[foundry] stalled:'
22export const LIMIT_RESET = '[foundry] limit reset:'
23// how a lead says it cannot go on without help, proposes a change, or stops part-way
24export const BLOCKED = 'BLOCKED:'
25export const PROPOSAL = 'PROPOSAL:'
26export const CHECKPOINT = 'CHECKPOINT:'
27// the note the main session gets at its start, naming this session's foundry folder
28export const DIR_NOTE = '[foundry] dir:'
29// statuses of an agent that is still working or can still be woken
30const LIVE = new Set(['pending', 'running', 'waiting', 'idle'])
31
32// What the mod needs of an agent. `name` is what SendMessage addresses it by (the engine's, which may carry a
33// suffix); `label` is the name it was spawned with, else its id, and names its crew and its report file.
34// `live`: in the latest agent list and still working; `seq`: when the mod first saw it, newest highest.
35export type Agent = Pick<AgentInfo, 'id' | 'type' | 'status' | 'description'> & {
36 name?: string
37 parentId?: string
38 label?: string
39 live?: boolean
40 seq?: number
41}
42
43// the crew an agent works in: the lead at its top, the crew's name, and whether the agent is that lead
44export type Crew = { crew: string; leadId: string; leadName: string; isLead: boolean }
45
46export function isLead(agent: Agent): boolean {
47 return agent.type === LEAD_TYPE && agent.parentId === undefined
48}
49
50// the one name an agent's crew, report file and notes go by: its spawn name, else its id
51export function labelOf(agent: Agent): string {
52 if (agent.label !== undefined && agent.label !== '') return agent.label
53 return agent.name !== undefined && agent.name !== '' ? agent.name : agent.id
54}
55
56// the crew's name is its lead's label
57export function crewName(lead: Agent): string {
58 return labelOf(lead)
59}
60
61function isLive(agent: Agent): boolean {
62 return agent.live ?? LIVE.has(agent.status)
63}
64
65// The crew `id` works in: it is a lead the main thread started, or one of its ancestors is. Undefined for every
66// other agent, including the main thread and agents of a lead some other agent started.
67export function crewOf(id: string | undefined, agents: readonly Agent[]): Crew | undefined {
68 if (id === undefined) return undefined
69 const byId = new Map(agents.map(a => [a.id, a]))
70 const seen = new Set<string>()
71 let at = byId.get(id)
72 while (at !== undefined && !seen.has(at.id)) {
73 seen.add(at.id)
74 if (at.parentId === undefined) {
75 if (!isLead(at)) return undefined
76 return { crew: crewName(at), leadId: at.id, leadName: crewName(at), isLead: at.id === id }
77 }
78 at = byId.get(at.parentId)
79 }
80 return undefined
81}
82
83// every agent whose parent chain reaches `id`, live or not
84export function descendantsOf(id: string, agents: readonly Agent[]): Agent[] {
85 const byId = new Map(agents.map(a => [a.id, a]))
86 return agents.filter(a => {
87 const seen = new Set<string>()
88 let at = a.parentId
89 while (at !== undefined && !seen.has(at)) {
90 if (at === id) return true
91 seen.add(at)
92 at = byId.get(at)?.parentId
93 }
94 return false
95 })
96}
97
98// A lead's stop is interim while it still has work out: a live descendant, or a running or pending background task
99// whose id is one of its descendants'. The session-wide task list also holds other crews' tasks; those are ignored.
100export function isInterimStop(descendants: readonly Agent[], tasks: readonly { id: string; status: string }[] | undefined): boolean {
101 if (descendants.some(isLive)) return true
102 const ids = new Set(descendants.map(d => d.id))
103 return (tasks ?? []).some(t => (t.status === 'running' || t.status === 'pending') && ids.has(t.id))
104}
105
106// a name safe as one path segment
107export function safeName(name: string): string {
108 const cleaned = name.replace(/[^A-Za-z0-9._-]+|\.{2,}/g, '-').replace(/-{2,}/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 100)
109 return cleaned === '' ? 'agent' : cleaned
110}
111
112function slashed(path: string): string {
113 return path.replace(/\\/g, '/').replace(/\/+$/, '')
114}
115
116// Where this session's foundry files live: in the repo's git common dir, so every worktree of it shares one place,
117// or under the home folder outside git. Undefined when neither is known.
118export function foundryDir(gitCommonDir: string | undefined, home: string | undefined, sessionId: string): string | undefined {
119 const base = gitCommonDir !== undefined && gitCommonDir !== '' ? slashed(gitCommonDir) : home !== undefined && home !== '' ? `${slashed(home)}/.claude` : undefined
120 return base === undefined ? undefined : `${base}/foundry/${safeName(sessionId)}`
121}
122
123export function reportDir(dir: string, crew: string): string {
124 return `${dir}/reports/${safeName(crew)}`
125}
126
127export function reportPath(dir: string, crew: string, agent: string): string {
128 return `${reportDir(dir, crew)}/${safeName(agent)}.md`
129}
130
131export function messagesPath(dir: string): string {
132 return `${dir}/messages.jsonl`
133}
134
135export function rolledPath(dir: string, n: number): string {
136 return `${dir}/messages.${n}.jsonl`
137}
138
139// The agents `to` addresses: the one with that id; else those with that name (a listing's " [ref]" dropped) that
140// are live; else, when none is, the newest one seen with it. Empty when nothing matches.
141export function recipientsOf(to: string, agents: readonly Agent[]): Agent[] {
142 const bare = to.replace(/\s*\[[^\]]*\]\s*$/, '')
143 const byId = agents.find(a => a.id === bare)
144 if (byId !== undefined) return [byId]
145 const named = agents.filter(a => a.name === bare || labelOf(a) === bare)
146 const live = named.filter(isLive)
147 if (live.length > 0) return live
148 const newest = named.reduce<Agent | undefined>((best, a) => (best === undefined || (a.seq ?? 0) > (best.seq ?? 0) ? a : best), undefined)
149 return newest === undefined ? [] : [newest]
150}
151
152export type MessageVerdict =
153 | { kind: 'pass' }
154 | { kind: 'allow'; crossCrew: false }
155 | { kind: 'allow'; crossCrew: true; fromCrew: string; toCrew: string }
156 | { kind: 'deny'; reason: string }
157
158const MEMBER_RULE = 'crew members message only their own lead and the other members of their crew'
159
160// Who a crew agent may message. A member: its own lead and its crew's other members. A lead: "main", its own
161// members, and the other leads. Anything else from inside a crew is refused, including an address no agent of this
162// session has had; agents outside every crew are not this mod's business.
163export function messageVerdict(senderId: string | undefined, to: unknown, agents: readonly Agent[]): MessageVerdict {
164 const sender = crewOf(senderId, agents)
165 if (sender === undefined) return { kind: 'pass' }
166 const address = typeof to === 'string' ? to.trim() : ''
167 if (address === MAIN || address.startsWith(`${MAIN} [`)) {
168 if (sender.isLead) return { kind: 'allow', crossCrew: false }
169 return { kind: 'deny', reason: `${MARK} Refused: ${MEMBER_RULE}. Put your question in a message to your lead, ${sender.leadName}, which asks "main" for you.` }
170 }
171 const targets = address === '' ? [] : recipientsOf(address, agents)
172 const crews = targets.map(t => crewOf(t.id, agents))
173 if (new Set(crews.map(c => c?.leadId)).size > 1) {
174 return { kind: 'deny', reason: `${MARK} Refused: more than one live agent is named "${address}", in different crews. Address it by its agent id.` }
175 }
176 const target = crews[0]
177 if (target !== undefined && target.leadId === sender.leadId) return { kind: 'allow', crossCrew: false }
178 if (sender.isLead && target?.isLead) return { kind: 'allow', crossCrew: true, fromCrew: sender.crew, toCrew: target.crew }
179 if (sender.isLead) {
180 const via = target === undefined ? 'Message "main", your own members, or another crew\'s lead.' : `Send it to that crew's lead, ${target.leadName}, instead.`
181 return { kind: 'deny', reason: `${MARK} Refused: a lead reaches another crew only through its lead. ${via}` }
182 }
183 return { kind: 'deny', reason: `${MARK} Refused: ${MEMBER_RULE}. To reach anyone else, tell your lead, ${sender.leadName}.` }
184}
185
186export const MODEL_REFUSAL = `${MARK} Refused: inside a crew, every Agent call sets \`model\`: "opus" for judgment-heavy work (design, debugging, review, tricky code), "sonnet" for mechanical or well-specified work. Call Agent again with model set.`
187
188// an Agent call from inside a crew must name its model
189export function agentVerdict(callerId: string | undefined, model: unknown, agents: readonly Agent[]): { deny: string } | undefined {
190 if (crewOf(callerId, agents) === undefined) return undefined
191 return typeof model === 'string' && model !== '' ? undefined : { deny: MODEL_REFUSAL }
192}
193
194const EFFORTS = ['low', 'medium', 'high', 'xhigh']
195
196export const EFFORT_REFUSAL = `${MARK} Refused: inside a crew, every Agent call sets \`effort\` to one of "low", "medium", "high" or "xhigh": "low" for lookups and mechanical edits, "medium" for well-specified work, "high" or "xhigh" for hard design, debugging or review. Call Agent again with effort set.`
197export const MAX_EFFORT_REFUSAL = `${MARK} Refused: effort "max" is not allowed inside a crew; "xhigh" is the top. Call Agent again with effort set to "low", "medium", "high" or "xhigh".`
198
199// an Agent call from inside a crew must name its effort, and never "max"
200export function effortVerdict(callerId: string | undefined, effort: unknown, agents: readonly Agent[]): { deny: string } | undefined {
201 if (crewOf(callerId, agents) === undefined) return undefined
202 if (effort === 'max') return { deny: MAX_EFFORT_REFUSAL }
203 return typeof effort === 'string' && EFFORTS.includes(effort) ? undefined : { deny: EFFORT_REFUSAL }
204}
205
206export function reportNote(who: string, path: string): string {
207 return `${MARK} If ${who}'s report does not reach you, read ${path} once it stops.`
208}
209
210// the first line of a lead's question to the person, or undefined when the text is not one
211export function questionOf(text: string): string | undefined {
212 const trimmed = text.trimStart()
213 if (!trimmed.startsWith(QUESTION)) return undefined
214 return trimmed.slice(QUESTION.length).split('\n')[0]?.trim() ?? ''
215}
216
217// The tags a lead's message to main, or its report, can start with, and what /foundry shows for each.
218export type Tag = { kind: 'waiting on you' | 'blocked' | 'proposal'; text: string }
219
220const TAGS: readonly [string, Tag['kind']][] = [
221 [QUESTION, 'waiting on you'],
222 [BLOCKED, 'blocked'],
223 [PROPOSAL, 'proposal'],
224]
225
226// the tag a text starts with and the first line after it, or undefined when it starts with none
227export function tagOf(text: string): Tag | undefined {
228 const trimmed = text.trimStart()
229 for (const [prefix, kind] of TAGS) {
230 if (trimmed.startsWith(prefix)) return { kind, text: trimmed.slice(prefix.length).split('\n')[0]?.trim() ?? '' }
231 }
232 return undefined
233}
234
235// A lead's final text that may end its run: a checkpoint, a blocker, a question or a proposal, or a report with a
236// Proof section.
237export function leadStopPasses(text: string): boolean {
238 const trimmed = text.trimStart()
239 return [CHECKPOINT, BLOCKED, QUESTION, PROPOSAL].some(p => trimmed.startsWith(p)) || /^##\s+Proof\b/m.test(text)
240}
241
242export const LEAD_STOP_REASON = `${MARK} Not done yet: finish with your report, in the sections Summary, Branch, Changes, Proof, Checks run and Open questions (each a "## " heading), or, if you cannot finish, start your final message with CHECKPOINT:, BLOCKED:, QUESTION: or PROPOSAL:.`
243
244export function stallText(label: string, crew: string, minutes: number): string {
245 return `${STALLED} ${label} in crew ${crew}, no activity for ${minutes}m. Check /foundry and message the ${crew} lead.`
246}
247
248export function limitResetText(resetsAt: string): string {
249 return `${LIMIT_RESET} the five-hour usage window reset at ${resetsAt}. Check /foundry and message each lead whose crew isn't done to resume.`
250}
251
252// the first line of a judge's report past VERDICT:, or undefined when it has none
253export function verdictOf(text: string): string | undefined {
254 const first = text.trimStart().split('\n')[0] ?? ''
255 return first.startsWith('VERDICT:') ? first.slice('VERDICT:'.length).trim() : undefined
256}
257
258// a crew's judge report files: <crew>-judge.md and <crew>-judge-<n>.md
259export function isJudgeFile(crew: string, file: string): boolean {
260 const prefix = `${safeName(crew)}-judge`
261 if (!file.startsWith(prefix) || !file.endsWith('.md')) return false
262 const middle = file.slice(prefix.length, -'.md'.length)
263 return middle === '' || /^-\d+$/.test(middle)
264}
265
266// 95_000 -> 2m ago, 7_200_000 -> 2h ago
267export function ago(ms: number): string {
268 const m = Math.max(0, Math.round(ms / 60_000))
269 return m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`
270}
271
272export type CrewStatus = {
273 crew: string
274 status: string
275 live: Record<string, number>
276 lastReportMs?: number
277 // a lead's open question to the person; `tag` wins when both are set
278 question?: string
279 tag?: Tag
280 // the lead ended its turn (or its run) while its members still work
281 waitingOnMembers?: boolean
282 // when the lead last did anything, as far as this mod saw
283 lastActiveMs?: number
284 // the crew's agents flagged as stalled, and for how many whole minutes
285 stalled?: { label: string; minutes: number }[]
286 judge?: string
287 dir?: string
288}
289
290export function statusText(crews: readonly CrewStatus[], now: number, messages?: string, root?: string): string {
291 const dirLine = root === undefined ? [] : [`Foundry dir: ${root}`]
292 if (crews.length === 0) return ['No foundry crews in this session. Start them with /foundry:run.', ...dirLine].join('\n')
293 const lines = [`Crews (${crews.length}):`]
294 for (const c of crews) {
295 const total = Object.values(c.live).reduce((a, b) => a + b, 0)
296 const byModel = Object.entries(c.live).sort(([a], [b]) => a.localeCompare(b)).map(([m, n]) => `${m} ${n}`).join(', ')
297 const members = total === 0 ? 'no live members' : `${total} live member${total === 1 ? '' : 's'} (${byModel})`
298 const tag = c.tag ?? (c.question !== undefined ? { kind: 'waiting on you', text: c.question } : undefined)
299 const report =
300 tag !== undefined
301 ? `${tag.kind}: ${tag.text}`
302 : c.waitingOnMembers === true
303 ? 'waiting on members'
304 : c.lastReportMs === undefined
305 ? 'no report yet'
306 : `last report ${ago(now - c.lastReportMs)}`
307 const active = c.lastActiveMs === undefined ? '' : `, last active ${ago(now - c.lastActiveMs)}`
308 lines.push(`- ${c.crew}: ${c.status}, ${members}, ${report}${active}`)
309 for (const s of c.stalled ?? []) lines.push(` ${s.label}: stalled ${s.minutes}m`)
310 if (c.judge !== undefined) lines.push(` judge: ${c.judge}`)
311 if (c.dir !== undefined) lines.push(` reports: ${c.dir}`)
312 }
313 if (messages !== undefined) lines.push(`Messages between leads: ${messages}`)
314 lines.push(...dirLine)
315 return lines.join('\n')
316}
317
318// the model names `$.agent.spawn` answers ("claude-sonnet-4-6") counted by family
319export function family(model: string | undefined): string {
320 if (model === undefined || model === '') return 'unknown'
321 const m = /(opus|sonnet|haiku|fable)/i.exec(model)
322 return m?.[1] !== undefined ? m[1].toLowerCase() : model
323}
324
325type $ = EngineInterface
326
327async function gitCommonDir($: $, cwd: string | undefined): Promise<string | undefined> {
328 try {
329 const r = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd, timeoutMs: 15_000 })
330 const out = r.stdout.replace(/\r/g, '').trim()
331 return r.exitCode === 0 && out !== '' ? out : undefined
332 } catch {
333 return undefined
334 }
335}
336
337async function homeDir($: $): Promise<string | undefined> {
338 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
339}
340
341// A hook step that must never break the session: an error is logged and the step skipped.
342async function safely<T>($: $, what: string, step: () => Promise<T>): Promise<T | undefined> {
343 try {
344 return await step()
345 } catch (error) {
346 try {
347 $.ui.log(`foundry: ${what} failed, letting the call through: ${String(error)}`, { to: 'debug' })
348 } catch {
349 // nothing left to do
350 }
351 return undefined
352 }
353}
354
355// A hook that throws or overruns its budget anyway: answering undefined leaves the hook absent, so the call goes on
356// as if foundry were not installed.
357const failOpen = ($: $, _e: unknown, next: { error?: unknown }) => {
358 try {
359 $.ui.log(`foundry: a hook failed and was skipped: ${String(next.error)}`, { to: 'debug' })
360 } catch {
361 // nothing left to do
362 }
363 return undefined
364}
365
366function withNote(result: ToolCallResult, note: string): ToolCallResult {
367 if (result.deny !== undefined) return result
368 return { ...result, context: [...(result.context ?? []), note] }
369}
370
371// what the mod keeps for the session; a reload or /clear starts it over (the armed limit reset lives in $.state)
372type State = {
373 // every agent seen this session, so a member whose lead has ended still resolves to its crew
374 known: Map<string, Agent>
375 // the order agents were first seen in
376 seq: number
377 // the name each agent was spawned with, else its id: the one name its report file and crew go by
378 labels: Map<string, string>
379 // the model each agent was started on, from agent.spawn
380 models: Map<string, string>
381 // agents whose report was captured from their hand-back; their next stop leaves the file alone, once
382 handedBack: Set<string>
383 // each pending hand-back's message, by agent: what a lead's stop is judged on
384 handBacks: Map<string, string>
385 // the tag (question, blocker, proposal) each lead shows in /foundry until answered or replaced, by lead id
386 waiting: Map<string, Tag>
387 // when each agent last made a tool call or a model request, by id
388 activity: Map<string, number>
389 // agents already reported as stalled; new activity takes them off
390 stalled: Set<string>
391 // how many stops of each lead's current stop streak were blocked
392 streaks: Map<string, number>
393 // this session's foundry folder, resolved once from the session's working directory
394 root?: string
395}
396
397const LIMIT_RESET_REF = { plugin: 'foundry', key: 'limitReset' } as const
398
399// The session's foundry folder: worked out once, from the session's own working directory, so the path a note
400// names and the path a report is written to always agree. Not cached while it cannot be found.
401async function rootOf($: $, state: State): Promise<string | undefined> {
402 if (state.root !== undefined) return state.root
403 const root = foundryDir(await gitCommonDir($, await $.session.cwd()), await homeDir($), await $.session.id())
404 if (root !== undefined) state.root = root
405 return root
406}
407
408// the agent list, merged into what was seen before. `listed` is every agent the list returned, whatever its
409// status (a completed or killed agent can still be listed); test isLive for an agent that is still at work.
410async function agentsOf($: $, state: State): Promise<{ all: Agent[]; listed: Agent[] }> {
411 const answer = await $.agent.list()
412 const ids = new Set(answer.map(a => a.id))
413 for (const a of state.known.values()) if (!ids.has(a.id)) a.live = false
414 const listed: Agent[] = []
415 for (const a of answer) {
416 const before = state.known.get(a.id)
417 const seq = before?.seq ?? state.seq++
418 const agent: Agent = { id: a.id, type: a.type, status: a.status, description: a.description, name: a.name, parentId: a.parentId, label: state.labels.get(a.id), live: LIVE.has(a.status), seq }
419 state.known.set(a.id, agent)
420 listed.push(agent)
421 }
422 return { all: [...state.known.values()], listed }
423}
424
425// an agent did something: it is not stalled, whatever it was before
426function touch(state: State, id: string, at: number) {
427 const before = state.activity.get(id)
428 if (before === undefined || at > before) state.activity.set(id, at)
429 state.stalled.delete(id)
430}
431
432// Records activity without waiting on it: a tool call or a model step never pays for the clock read.
433function noteActivity($: $, state: State, id: string) {
434 try {
435 void $.clock.now().then(
436 at => touch(state, id, at),
437 () => undefined,
438 )
439 } catch {
440 // a clock that throws records nothing
441 }
442}
443
444// what the stop gate needs of a stopping agent: whether it is a lead the main thread started, and its descendants
445// as last listed. One not yet listed is taken at its event's type.
446async function stopperOf($: $, state: State, id: string, type: string | undefined): Promise<{ lead: boolean; descendants: Agent[] }> {
447 const all = (await agentsOf($, state)).all
448 const known = all.find(a => a.id === id)
449 const lead = known === undefined ? type === LEAD_TYPE : isLead(known)
450 return { lead, descendants: lead ? descendantsOf(id, all) : [] }
451}
452
453// `tags` false: an interim stop's text is kept on disk but neither sets nor clears what /foundry shows
454async function writeReport($: $, state: State, crew: Crew, id: string, text: string, tags = true) {
455 const dir = await rootOf($, state)
456 if (dir === undefined) return
457 // an agent started before the mod loaded has no spawn name on record: its listed name, else its id
458 const known = state.known.get(id)
459 await $.fs.write(reportPath(dir, crew.crew, state.labels.get(id) ?? (known === undefined ? id : labelOf(known))), text)
460 // a lead's captured text tells whether it stopped on a question, a blocker or a proposal
461 if (crew.isLead && tags) {
462 const tag = tagOf(text)
463 if (tag === undefined) state.waiting.delete(id)
464 else state.waiting.set(id, tag)
465 }
466}
467
468// one line onto the message log; past ROLL_BYTES the log moves to the next free messages.<n>.jsonl first
469async function appendMessage($: $, state: State, entry: { from: string; to: string; summary: string }) {
470 const dir = await rootOf($, state)
471 if (dir === undefined) return
472 const path = messagesPath(dir)
473 let before = ''
474 if (await $.fs.exists(path)) {
475 const text = String(await $.fs.read(path))
476 if ((await $.fs.stat(path)).size >= ROLL_BYTES) {
477 let n = 1
478 while (await $.fs.exists(rolledPath(dir, n))) n++
479 await $.fs.write(rolledPath(dir, n), text)
480 } else {
481 before = text
482 }
483 }
484 const line = JSON.stringify({ ts: new Date(await $.clock.now()).toISOString(), ...entry })
485 const sep = before === '' || before.endsWith('\n') ? '' : '\n'
486 await $.fs.write(path, `${before}${sep}${line}\n`)
487}
488
489async function lastModified($: $, dir: string): Promise<number | undefined> {
490 if (!(await $.fs.exists(dir))) return undefined
491 let last: number | undefined
492 for (const f of await $.fs.list(dir)) {
493 if (f.kind !== 'file') continue
494 const { mtimeMs } = await $.fs.stat(`${dir}/${f.name}`)
495 if (last === undefined || mtimeMs > last) last = mtimeMs
496 }
497 return last
498}
499
500// the verdict on the first line of the crew's latest judge report (highest k in -judge-<k>.md; bare -judge.md is k=1), if it has one
501async function judgeOf($: $, dir: string, crew: string): Promise<string | undefined> {
502 if (!(await $.fs.exists(dir))) return undefined
503 let latest: { name: string; k: number } | undefined
504 for (const f of await $.fs.list(dir)) {
505 if (f.kind !== 'file' || !isJudgeFile(crew, f.name)) continue
506 const k = Number(/-(\d+)\.md$/.exec(f.name)?.[1] ?? 1)
507 if (latest === undefined || k > latest.k) latest = { name: f.name, k }
508 }
509 return latest === undefined ? undefined : verdictOf(String(await $.fs.read(`${dir}/${latest.name}`)))
510}
511
512async function statusOf($: $, state: State): Promise<string> {
513 const { all, listed } = await agentsOf($, state)
514 // a listed lead shows its own status, completed or killed included; one the list dropped shows as ended
515 const listedIds = new Set(listed.map(a => a.id))
516 const dir = await rootOf($, state)
517 const now = await $.clock.now()
518 const crews: CrewStatus[] = []
519 for (const lead of all.filter(isLead)) {
520 const crew = crewName(lead)
521 const counts: Record<string, number> = {}
522 const stalled: { label: string; minutes: number }[] = []
523 for (const a of listed) {
524 if (crewOf(a.id, all)?.leadId !== lead.id) continue
525 const last = state.activity.get(a.id)
526 if (state.stalled.has(a.id) && a.status === 'running' && last !== undefined) stalled.push({ label: labelOf(a), minutes: Math.floor((now - last) / 60_000) })
527 if (a.id === lead.id || !LIVE.has(a.status)) continue
528 const m = family(state.models.get(a.id))
529 counts[m] = (counts[m] ?? 0) + 1
530 }
531 const reports = dir === undefined ? undefined : reportDir(dir, crew)
532 const lastReportMs = reports === undefined ? undefined : await lastModified($, reports)
533 const judge = reports === undefined ? undefined : await safely($, 'judge verdict', () => judgeOf($, reports, crew))
534 crews.push({
535 crew,
536 status: listedIds.has(lead.id) ? lead.status : 'ended',
537 live: counts,
538 lastReportMs,
539 tag: state.waiting.get(lead.id),
540 waitingOnMembers: !(listedIds.has(lead.id) && lead.status === 'running') && descendantsOf(lead.id, all).some(isLive),
541 lastActiveMs: state.activity.get(lead.id),
542 stalled,
543 judge,
544 dir: reports,
545 })
546 }
547 const log = dir === undefined ? undefined : messagesPath(dir)
548 return statusText(crews, now, log !== undefined && (await $.fs.exists(log)) ? log : undefined, dir)
549}
550
551// One pass of the heartbeat: every running crew agent idle STALL_MS or more is reported once, with a toast and a
552// prompt to the main session. An agent with nothing on record counts as active from now.
553async function stallCheck($: $, state: State) {
554 const now = await $.clock.now()
555 const { all, listed } = await agentsOf($, state)
556 for (const a of listed) {
557 // running only: a listed agent that is idle, waiting or done is never stalled
558 if (a.status !== 'running') continue
559 const crew = crewOf(a.id, all)
560 if (crew === undefined) continue
561 const last = state.activity.get(a.id)
562 if (last === undefined) {
563 state.activity.set(a.id, now)
564 continue
565 }
566 if (state.stalled.has(a.id) || now - last < STALL_MS) continue
567 // flagged before any await, so an overlapping check never reports it twice
568 state.stalled.add(a.id)
569 const label = labelOf(a)
570 const minutes = Math.floor((now - last) / 60_000)
571 await safely($, 'stall wake', async () => {
572 try {
573 $.ui.toast(`${STALLED} ${label} (crew ${crew.crew}), no activity for ${minutes}m`)
574 } catch {
575 // the prompt below still goes out
576 }
577 await $.prompt.submit({ text: stallText(label, crew.crew, minutes) })
578 })
579 }
580}
581
582// The module's timers. A hot reload cancels them with the old module and starts this record over; a repeated
583// session.start in the same module replaces each, so there is never more than one of either.
584type Wakes = {
585 heartbeat?: Timer
586 // a stall check is under way; the next period skips rather than overlap it
587 checking: boolean
588 // the limit-reset timer and the resetsAt it is for
589 reset?: { resetsAt: string; timer: Timer }
590 // the resetsAt values this module has woken the foreman for
591 fired: Set<string>
592 // limit-reset work runs one step at a time, so two triggers for one reset never both arm
593 work: Promise<unknown>
594}
595
596function startHeartbeat($: $, state: State, wakes: Wakes) {
597 wakes.heartbeat?.cancel()
598 wakes.heartbeat = $.clock.every(HEARTBEAT_MS, () => {
599 if (wakes.checking) return
600 wakes.checking = true
601 void safely($, 'stall check', () => stallCheck($, state)).finally(() => {
602 wakes.checking = false
603 })
604 })
605}
606
607// does $.state say positively that this resetsAt's wake has gone out? An unreadable or stale state says no.
608async function firedFor($: $, resetsAt: string): Promise<{ fired: boolean; version?: number; ours: boolean }> {
609 try {
610 const held = await $.state.get(LIMIT_RESET_REF)
611 const ours = held.value?.resetsAt === resetsAt
612 return { fired: ours && held.value?.fired === true, version: held.version, ours: ours || held.value === undefined }
613 } catch {
614 return { fired: false, ours: false }
615 }
616}
617
618// The timer's resetsAt is the source of truth: the wake goes out once for it unless $.state positively says it
619// already went. The module guard stops a second run in this module; the $.state claim stops one after a reload.
620async function fireReset($: $, wakes: Wakes, resetsAt: string) {
621 if (wakes.reset?.resetsAt === resetsAt) wakes.reset = undefined
622 if (wakes.fired.has(resetsAt)) return
623 wakes.fired.add(resetsAt)
624 const before = await firedFor($, resetsAt)
625 if (before.fired) return
626 // claimed before the prompt goes out; a state holding another window's reset is left alone
627 if (before.ours) {
628 const claimed = await $.state
629 .set(LIMIT_RESET_REF, { resetsAt, fired: true }, before.version === undefined ? undefined : { ifVersion: before.version })
630 .catch(() => undefined)
631 // lost the claim: fire anyway unless whoever won says this reset has fired
632 if (claimed !== undefined && !claimed.isSet && (await firedFor($, resetsAt)).fired) return
633 }
634 await $.prompt.submit({ text: limitResetText(resetsAt) })
635}
636
637async function scheduleReset($: $, wakes: Wakes, resetsAt: string) {
638 const due = Date.parse(resetsAt)
639 if (Number.isNaN(due)) return
640 const now = await $.clock.now()
641 wakes.reset?.timer.cancel()
642 const timer = $.clock.after(Math.max(0, due + RESET_GRACE_MS - now), () => {
643 void safely($, 'limit reset wake', () => fireReset($, wakes, resetsAt))
644 })
645 wakes.reset = { resetsAt, timer }
646}
647
648// a five-hour window at its limit while a crew is live: wake the foreman once it resets, once per resetsAt
649async function armReset($: $, state: State, wakes: Wakes, resetsAt: string) {
650 if (Number.isNaN(Date.parse(resetsAt))) return
651 // a lead still at work, not one that is listed but completed, failed or killed
652 if (!(await agentsOf($, state)).listed.some(a => isLead(a) && isLive(a))) return
653 const held = await $.state.get(LIMIT_RESET_REF)
654 if (held.value?.resetsAt === resetsAt) {
655 // fired already, or a timer is running; one a reload took is armed again by session.start
656 if (held.value.fired || wakes.reset?.resetsAt === resetsAt) return
657 } else {
658 await $.state.set(LIMIT_RESET_REF, { resetsAt, fired: false })
659 }
660 await scheduleReset($, wakes, resetsAt)
661}
662
663// session.start: a reload took the limit-reset timer with the old module, so arm it again from $.state
664async function rearmReset($: $, wakes: Wakes) {
665 const held = await $.state.get(LIMIT_RESET_REF)
666 if (held.value !== undefined && !held.value.fired) await scheduleReset($, wakes, held.value.resetsAt)
667}
668
669async function resetFromUsage($: $, state: State, wakes: Wakes) {
670 const resetsAt = (await $.session.usage()).rateLimits.find(l => l.kind === 'five_hour' && l.percentUsed >= 100 && typeof l.resetsAt === 'string')?.resetsAt
671 if (resetsAt !== undefined) await armReset($, state, wakes, resetsAt)
672}
673
674function queueLimit($: $, wakes: Wakes, what: string, step: () => Promise<unknown>): Promise<unknown> {
675 wakes.work = wakes.work.then(() => safely($, what, step))
676 return wakes.work
677}
678
679export const register: Register = on => {
680 const state: State = {
681 known: new Map(),
682 seq: 0,
683 labels: new Map(),
684 models: new Map(),
685 handedBack: new Set(),
686 handBacks: new Map(),
687 waiting: new Map(),
688 activity: new Map(),
689 stalled: new Set(),
690 streaks: new Map(),
691 }
692 const wakes: Wakes = { checking: false, fired: new Set(), work: Promise.resolve() }
693 // appends to the messages log, one at a time
694 let appending: Promise<unknown> = Promise.resolve()
695
696 on('session.end', { reason: 'clear' }, async ($, e, next) => {
697 state.known.clear()
698 state.labels.clear()
699 state.models.clear()
700 state.handedBack.clear()
701 state.handBacks.clear()
702 state.waiting.clear()
703 state.activity.clear()
704 state.stalled.clear()
705 state.streaks.clear()
706 state.root = undefined
707 return next(e)
708 })
709
710 on('agent.spawn', async ($, e, next) => {
711 const started = await next(e)
712 const id = started.agentId
713 if (id !== undefined) {
714 const label = e.name !== undefined && e.name !== '' ? e.name : id
715 state.labels.set(id, label)
716 state.known.set(id, { id, type: e.subagentType, status: 'running', description: e.description, name: e.name, parentId: e.parentAgentId, label, live: true, seq: state.seq++ })
717 state.models.set(id, started.model)
718 }
719 return started
720 }).catch(failOpen)
721
722 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
723 const input = e as unknown as { to?: unknown; summary?: unknown; message?: unknown }
724 if (e.agentId === undefined) {
725 // the foreman answering a lead: that lead is no longer waiting on the person
726 const result = await next(e)
727 if (result.deny === undefined && typeof input.to === 'string' && state.waiting.size > 0) {
728 const to = input.to
729 await safely($, 'answer check', async () => {
730 for (const t of recipientsOf(to, (await agentsOf($, state)).all)) state.waiting.delete(t.id)
731 })
732 }
733 return result
734 }
735 const sender = e.agentId
736 const decided = await safely($, 'SendMessage check', async () => messageVerdict(sender, input.to, (await agentsOf($, state)).all))
737 if (decided?.kind === 'deny') return { deny: decided.reason }
738 const result = await next(e)
739 if (decided?.kind !== 'allow' || result.deny !== undefined) return result
740 if (input.to === MAIN && typeof input.message === 'string') {
741 // a tagged message to main sets what /foundry shows for the lead; an untagged one replaces it with nothing
742 const tag = tagOf(input.message)
743 if (tag !== undefined) state.waiting.set(sender, tag)
744 else state.waiting.delete(sender)
745 }
746 if (decided.crossCrew) {
747 const entry = { from: decided.fromCrew, to: decided.toCrew, summary: typeof input.summary === 'string' ? input.summary : '' }
748 appending = appending.then(() => safely($, 'message log', () => appendMessage($, state, entry)))
749 await appending
750 }
751 return result
752 }).catch(failOpen)
753
754 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
755 const caller = await safely($, 'Agent check', async () => {
756 if (e.agentId === undefined) return undefined
757 const all = (await agentsOf($, state)).all
758 return { crew: crewOf(e.agentId, all), refused: agentVerdict(e.agentId, e.model, all) ?? effortVerdict(e.agentId, e.effort, all) }
759 })
760 if (caller?.refused !== undefined) return caller.refused
761 const result = await next(e)
762 const startsLead = e.agentId === undefined && e.subagent_type === LEAD_TYPE
763 if (result.deny !== undefined || (caller?.crew === undefined && !startsLead)) return result
764 const note = await safely($, 'report note', async () => {
765 const dir = await rootOf($, state)
766 const id = (result.result as { agentId?: unknown } | undefined)?.agentId
767 if (dir === undefined || typeof id !== 'string') return undefined
768 // the name agent.spawn recorded, which the report file will carry
769 const name = state.labels.get(id) ?? (e.name !== undefined && e.name !== '' ? e.name : id)
770 return reportNote(name, reportPath(dir, caller?.crew?.crew ?? name, name))
771 })
772 return note === undefined ? result : withNote(result, note)
773 }).catch(failOpen)
774
775 // Every tool call of an agent is activity. A report sent through the hand-back tool is the agent's real report;
776 // its last message may be only a sign-off.
777 on('tool.call', async ($, e, next) => {
778 if (e.agentId === undefined) return next(e)
779 const id = e.agentId
780 noteActivity($, state, id)
781 if ((e.tool as string) !== 'SubagentHandback') return next(e)
782 const message = (e as unknown as { message?: unknown }).message
783 if (typeof message === 'string' && message.trim() !== '') {
784 // marked before any await, so a stop that races this capture never overwrites it
785 state.handedBack.add(id)
786 state.handBacks.set(id, message)
787 await safely($, 'hand-back capture', async () => {
788 const all = (await agentsOf($, state)).all
789 const crew = crewOf(id, all)
790 if (crew === undefined) return
791 // a lead's report the stop gate will send back is not its report: the stop that goes through writes it
792 if (crew.isLead && !leadStopPasses(message)) return
793 await writeReport($, state, crew, id, message)
794 })
795 }
796 return next(e)
797 }).catch(failOpen)
798
799 // every model request of an agent is activity; the stream passes untouched
800 on('turn.step', async function* ($, e, next) {
801 if (e.agentId !== undefined) noteActivity($, state, e.agentId)
802 return yield* next(e)
803 }).catch(async function* ($, e, next) {
804 // turn.step streams, so its catch is a generator: log as failOpen does, then let the stream go on
805 failOpen($, e, next)
806 return yield* next(e)
807 })
808
809 on('classic.SubagentStop', async ($, e, next) => {
810 const result = await next(e)
811 const id = e.agent_id
812 // A lead may stop only on a finished report or a tagged hand-off, at most LEAD_STOP_BLOCKS times a streak. A stop
813 // while its members still work is interim: it goes through and leaves the streak and the tag alone.
814 const gate = await safely($, 'lead stop gate', async () => {
815 const stopper = await stopperOf($, state, id, e.agent_type)
816 if (!stopper.lead) return { lead: false, interim: false }
817 if (isInterimStop(stopper.descendants, e.background_tasks)) return { lead: true, interim: true }
818 const count = e.stop_hook_active ? (state.streaks.get(id) ?? 0) : 0
819 const text = state.handBacks.get(id) ?? e.last_assistant_message ?? ''
820 if (count >= LEAD_STOP_BLOCKS || leadStopPasses(text)) {
821 state.streaks.delete(id)
822 return { lead: true, interim: false }
823 }
824 state.streaks.set(id, count + 1)
825 return { lead: true, interim: false, block: LEAD_STOP_REASON }
826 })
827 const handBack = state.handBacks.get(id)
828 state.handBacks.delete(id)
829 if (gate?.block !== undefined) {
830 // the hand-back belonged to the stop sent back; the next one is judged and captured afresh
831 state.handedBack.delete(id)
832 return { ...result, block: gate.block }
833 }
834 const tags = gate?.interim !== true
835 // the hand-back already holds this stop's report; the agent's next stop is captured again
836 if (state.handedBack.delete(id)) {
837 if (gate?.lead === true && handBack !== undefined && !leadStopPasses(handBack)) {
838 // a lead's hand-back the gate could have sent back was held; this stop went through, so it is the report
839 await safely($, 'report capture', async () => {
840 const crew = crewOf(id, (await agentsOf($, state)).all)
841 if (crew !== undefined) await writeReport($, state, crew, id, handBack, tags)
842 })
843 }
844 return result
845 }
846 await safely($, 'report capture', async () => {
847 const text = e.last_assistant_message
848 if (text === undefined || text.trim() === '') return
849 const crew = crewOf(id, (await agentsOf($, state)).all)
850 if (crew !== undefined) await writeReport($, state, crew, id, text, tags)
851 })
852 return result
853 }).catch(failOpen)
854
855 on('session.measure', async ($, e, next) => {
856 const result = await next(e)
857 const hit = e.rateLimits.find(l => l.kind === 'five_hour' && l.percentUsed >= 100 && typeof l.resetsAt === 'string')
858 const resetsAt = hit?.resetsAt
859 if (resetsAt !== undefined) await queueLimit($, wakes, 'limit reset', () => armReset($, state, wakes, resetsAt))
860 return result
861 }).catch(failOpen)
862
863 on('classic.StopFailure', async ($, e, next) => {
864 const result = await next(e)
865 if (e.error === 'rate_limit') await queueLimit($, wakes, 'limit reset', () => resetFromUsage($, state, wakes))
866 return result
867 }).catch(failOpen)
868
869 // the main session learns where this session's foundry files live
870 on('classic.SessionStart', async ($, e, next) => {
871 const result = await next(e)
872 if (e.agent_id !== undefined) return result
873 const dir = await safely($, 'dir note', () => rootOf($, state))
874 return dir === undefined ? result : { ...result, additionalContext: [...(result.additionalContext ?? []), `${DIR_NOTE} ${dir}`] }
875 }).catch(failOpen)
876
877 on('session.start', async ($, e, next) => {
878 const started = await next(e)
879 await safely($, 'command register', () =>
880 $.command.register({ name: 'foundry', description: 'Show the foundry crews in this session: status, live members by model, last report', argumentHint: '[status]' }),
881 )
882 await safely($, 'stall timer', async () => startHeartbeat($, state, wakes))
883 await queueLimit($, wakes, 'limit reset re-arm', () => rearmReset($, wakes))
884 return started
885 })
886
887 on('command.run', { command: 'foundry' }, async ($, e) => {
888 const text = await safely($, 'status', () => statusOf($, state))
889 return { text: text ?? 'foundry could not read the crews; see the debug log.' }
890 })
891}
892types/index.d.ts 10 lines1// the five-hour limit reset a wake is armed for, and whether that wake has gone out
2export type LimitReset = { resetsAt: string; fired: boolean }
3
4// what foundry keeps in the session's $.state, so it survives a hot reload of the hooks module
5declare module 'claude-code' {
6 interface PluginState {
7 foundry: { limitReset: LimitReset }
8 }
9}
10