SLOPSHOPPER

foundry

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'…

guardcommandtoastprocesstimeragents
★ 2v0.2.0MITupdated 2026-10-26AbhiramDwivedi/claude-ops/plugins/foundry
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · foundry
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /foundry ⎿ foundry: No foundry crews in this session. Start them with /foundry:run. ⎿ foundry: Foundry dir: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/foundry/preview-session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

foundry

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.

Roles

RoleWhoWhat it does
OwnerYouPicks 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.
ForemanThe main session, after /foundry:runPlans 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.
Cynicfoundry:cynic, Opus, effort high, outside every crewRead-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.
Leadfoundry:lead, Opus, one per crewSplits the crew's goal into member tasks, delegates them, merges the members' branches into the crew branch, runs the checks and reports.
MembersYour project's agents, plugin agents, or general-purposeDo the building. Each starts with an explicit model and effort and a name that starts with its crew's.
Judgefoundry:judge, OpusNever 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.
Writerfoundry:writer, OpusA 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.
DevOpsfoundry:devops, SonnetA 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.

How a run goes

/foundry:run loads the foreman's protocol. The foreman:

  1. asks you once whether the goal is the end or one step toward a bigger outcome, and describes the bigger version. You pick one;
  2. runs a short depth pass: what a top practitioner would do that wasn't asked for, what the rawest material is, which tools that practitioner would use, and what besides a model verifies the result. The answers decide which crews exist;
  3. lists the agent types the session can start (your project's .claude/agents/*.md, yours in ~/.claude/agents/, plugin agents) and the project's skills;
  4. splits the goal into crews, usually two to four. Each crew owns its own files, has a done-check it can't game (code it doesn't own, or tests a test-writer writes before the builder starts), tags its high-impact paths (auth, money, migrations, CI, lockfiles), and gets its own port range and database or temp directory;
  5. shows the plan as a table and asks for the merge mode and the target branch. Scope grows here, once. When you approve, the foreman writes the plan to disk and the scope is frozen;
  6. writes a goal file for each crew and starts every lead in one message, in the background, each in its own worktree;
  7. handles the leads' messages, each tagged 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;
  8. merges each crew once its highest-numbered judge file says VERDICT: PASS for the crew branch's head commit;
  9. reports to you, and asks before every push and every PR.

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.

Inside a crew

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.

Merge modes

ModeWhat happensUse it when
sequentialCrews 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.
singleOne 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.

The end-of-run report

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.

What the hooks do

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.

  • Reports are saved. When a crew agent stops, its last message is written to <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.
  • Crew messages follow the chain. A member's 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.
  • Lead-to-lead messages are logged to <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.
  • Crew agents set a model and an effort. An Agent call from inside a crew without model, without effort, or with effort: "max" is refused, with a reason saying what to set.
  • A lead can't stop without a report. When a lead with no live members and no members' background tasks running stops on a message that has no ## 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.
  • Stalls wake the foreman. Every minute the plugin checks for running crew agents with no tool call or model request for 15 minutes. It reports each one once, with a toast and a [foundry] stalled: prompt to the main session.
  • A usage-limit reset wakes the foreman. When the five-hour window hits its limit while a lead is still at work, the plugin sets a timer for two minutes after the reset and then sends a [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.

Limitations

  • The slash command is /foundry, not /foundry:status. A plugin registers its command under a bare name, and /status is built in.
  • A member is matched to its crew through the agents' parent ids. An agent that ended before the plugin loaded (a reload mid-session) resolves to no crew, and a message to it from a crew agent is refused.
  • A lead restarted with the same name shares the old lead's report folder. Start it as <slug>-2 instead; the skill does.
  • No prompt in 0.2.0 (the skill or any of the five agents) has yet run as a live agent in a real run. The hooks are covered by tests against a mock engine.
  • What status a lead shows after it ends its turn while its members still run hasn't been observed in a live session. waiting on members depends on it.
  • Capturing a report from the hand-back tool, through a tool.call hook on SubagentHandback, is covered by tests but not yet verified in a live session.
  • The limit-reset wake rests on three things a probe confirmed: $.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.
  • Where an organization manages Claude Code, 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.
  • The gate's two-block bound relies on Claude Code sending 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.

Install

/plugin marketplace add AbhiramDwivedi/claude-ops
/plugin install foundry@claude-ops

Testing

claude plugin validate plugins/foundry
claude plugin test plugins/foundry

Drafted with AI assistance and reviewed by the author.

Source 2 files
hooks/register.ts 892 lines
1import 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}
892
types/index.d.ts 10 lines
1// 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