Spawn, delegate to and monitor Pi and Claude Code agents in Herdr, with results pushed back into the session

Spawn, delegate to and monitor Pi and Claude Code agents running in Herdr from Claude Code: Pi agents on this machine or on Herdr's saved SSH machines, Claude Code agents on this machine. It is the Claude Code controller for pi-shepherdr: results of background work arrive in the session on their own, so the agent never polls.
Ported from pi-shepherdr by howaboua in howaboua-pi-stuff; its bridge and profiles started as copies of that package.
/plugin install shepherdr --marketplace ramtinJ95/claude-mods
Requires Claude Code running inside Herdr, Node.js 22.18 or newer on PATH, and the Herdr Pi integration (herdr integration install pi). Install pi-shepherdr in the Pi workers too:
pi install npm:@howaboua/pi-shepherdr
Without it, messages still reach a worker, but through Herdr's terminal prompt, which can mix with text typed into that worker's editor, and the worker cannot list its commands. Results report which path was used as delivery: "peer" or "terminal". A spawned Pi worker gets its first task once pi-shepherdr's receiver is up, waiting up to 30 seconds after Pi starts; past that the task goes through the terminal prompt and the result carries a notice saying so. pi-shepherdr 0.2.20 needs Pi 1.2 or newer.
One tool, agents. Called with {} it returns the available profiles, request shapes and rules.
| Action | Result |
|---|---|
list | Agents on this machine and every enabled remote machine, or on one machine, optionally filtered by query or status; watched ones are marked and unreachable machines listed |
spawn | Start a profiled Pi or Claude Code worker in a new tab (or new_workspace, or an existing pane) and give it a task |
assign | Give an existing Pi or Claude Code agent a task whose result is needed |
send | Deliver a message without waiting or watching |
answer | Answer a worker's question (Pi Ask or Claude Code's AskUserQuestion) by its ask_id |
read | The worker's latest reply, or its visible or recent terminal output |
watch / unwatch | Start or stop reporting an agent's work |
{commands: true, target} | The slash commands that worker accepts, without running any |
spawn and assign wait for the worker's reply by default. With blocking: false they return at once, and the result arrives later as an agent_report message: added before the running turn's next model request, or starting a new turn when the session is idle. Watched agents also report questions, blockage and closed panes; reports of failed or blocked workers say how to inspect and recover them. Delegation keeps a watch until unwatch or the pane closes. Messages starting with / run as the worker's slash commands; /quit closes it.
A worker's Pi Ask question is reported with its ask_id and Ask's own question text (prompts, for an older Ask). A blocking question stops the worker; a background one (asked while it continues) is reported at once and the worker keeps going. answer takes one entry per prompt, in order: selections with exact choice labels, other for a custom answer, and an optional comment. Current Pi Ask takes the answers directly through the worker's Shepherdr receiver and reports accepted or why it refused them. With an older Ask, answer types them into its dialog, checking it at every step and stopping rather than guessing, then reports what Pi recorded: accepted, rejected, or unknown when it differs from the answers sent; if Pi records nothing, the call fails instead. A handoff, an action only the user can take, is reported for the user to complete in the worker's pane. A blocking question's answer then waits for the worker to settle; a background one's returns at once. Retrying an answer Pi already recorded sends no input.
Claude Code agents in Herdr panes on this machine are workers too: spawned from a Claude profile or any Claude Code session already running there. Tasks and messages arrive through Claude Code's own cross-session messages (delivery: "session"), so they show in the worker as a message from another session, never as typed input. Slash commands are typed into the worker when it is idle; /quit exits Claude Code and closes its pane.
The worker needs this mod installed too. When it asks with AskUserQuestion, the question is reported like a Pi Ask, and answer settles the dialog in its place; whoever answers first, the person in the worker's pane or the controller, wins. selections and other join into the question's answer and comment becomes its note. The status reported is what the worker recorded.
Spawned Claude Code workers accept their controller's messages whatever either side's permission mode, so tasks are never held for approval in the worker's pane. A profile's own --settings (inline JSON or an absolute path) is merged in; setting crossSessionInbound there overrides this, as does managed policy. A Claude Code worker also stops at its folder trust prompt in a folder it has not been trusted in; spawn reports that instead of starting it.
Remote agents are those on the SSH machines saved in Herdr on the host running Claude Code (herdr machine add, herdr machine list). Pass the machine's ID from list as machine with every request about its agents; labels and hostnames are not accepted. Omitting machine means this machine.
Each machine needs node on its noninteractive SSH PATH, Herdr with the Pi integration and a running Herdr session. Shepherdr copies its bridge once per version to ~/.local/share/claude-shepherdr/bridge/ there and runs each operation over ssh -o BatchMode=yes; OpenSSH owns authentication and nothing keeps running except the monitor while agents there are watched. Profiles and their preparation run on this machine; a remote worker gets its profile prompt as text. A spawn on another machine without workspace opens a new workspace there.
/shepherdr orchestrate tells the agent to orchestrate: fan out suitable work to agents, synthesize their results and report the outcome. /shepherdr normal switches back. Each switch reaches the model as one note without starting a turn, and the mode is restored after compaction and on resume.
/shepherdr lists watched agents, the saved machines and the orchestration mode, and restarts monitoring that stopped on any machine. The status line shows how many agents are watched and working.
Profiles set a worker's model, thinking level, system prompt and optional message preparation. When pi-shepherdr is installed, its profiles in ~/.pi/agent/shepherdr/profiles/ are used, so both controllers launch the same agent types. Otherwise the bundled copies of its defaults are used: general for implementation, explorer for read-only discovery, reviewer for read-only review.
Claude Code profiles are separate, since pi-shepherdr does not read their fields. The bundled claude-general, claude-explorer and claude-reviewer mirror the Pi set on Claude Opus 5.5 with high effort in auto mode. To change them, put your own profiles in $XDG_CONFIG_HOME/claude-shepherdr/profiles/ (default ~/.config), which then replaces the bundled set. Each profile is a folder holding profile.json:
{
"description": "General implementation",
"model": "claude-opus-5-5",
"effort": "high",
"permission_mode": "auto",
"prompt": "prompt.md",
"claude_args": []
}
prompt is appended to Claude Code's system prompt; blocking, accepts and prepare work as in Pi profiles, and claude_args are passed to claude as given.
Watches and the orchestration mode belong to one Claude Code session and survive resuming it. A fork starts with none. After compaction, including a notes-compaction window, the agent is reminded of the agents it watches, idle ones included, and their tasks before its next model request. State is stored as plaintext JSON under $XDG_DATA_HOME/claude-shepherdr/sessions/<session id>.json, defaulting to ~/.local/share.
hooks/register.ts 358 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 type AgentSummary,
5 type AnswerResult,
6 type AnswerStatus,
7 type Bridge,
8 type Delivery,
9 type Tracked,
10 call,
11 message,
12 sendRoute,
13} from './bridge'
14import { type AgentsRequest, type ProfileSummary, TOOL_SPEC, helpText, orchestrationGuidance, parseRequest } from './contract'
15import { Fleet, type SessionIo } from './fleet'
16import { CallRow, PushedReports, ResultRow, parseReports } from './render'
17import { awaitAnswer, takeAnswer } from './worker'
18
19const COMMAND = 'shepherdr'
20
21type Machine = { id: string; label: string; enabled: boolean }
22type Profiles = { sources: Record<string, string>; profiles: ProfileSummary[] }
23
24let fleet: { file: string; current: Fleet; ready: Promise<void> } | undefined
25/** Texts this mod is sending to Claude Code workers right now, for its own permission check. */
26const sending = new Map<string, number>()
27
28async function requireFleet($: EngineInterface): Promise<Fleet> {
29 const file = await sessionFile($, 'sessions')
30 if (fleet?.file !== file) {
31 fleet?.current.stop()
32 const current = new Fleet(sessionIo($, file))
33 fleet = { file, current, ready: loadFleet($, current, file) }
34 }
35 const bound = fleet
36 await bound.ready
37 return bound.current
38}
39
40async function loadFleet($: EngineInterface, current: Fleet, file: string): Promise<void> {
41 try {
42 await current.load()
43 } catch (error) {
44 $.ui.toast(
45 `Shepherdr could not read its saved watches (${message(error)}); fix or remove ${file}. New watches are not saved until then.`,
46 )
47 }
48}
49
50/**
51 * This session's file in a Shepherdr data folder: `sessions` holds its saved
52 * watches, `asks` its open question, which askFile in bridge/workers/claude.mjs reads.
53 */
54async function sessionFile($: EngineInterface, folder: 'sessions' | 'asks'): Promise<string> {
55 const [xdg, home, sessionId] = await Promise.all([$.env.get('XDG_DATA_HOME'), $.env.get('HOME'), $.session.id()])
56 return `${xdg || `${home}/.local/share`}/claude-shepherdr/${folder}/${sessionId}.json`
57}
58
59/** A hook's own process calls: a blocking wait pauses only that hook's budget. */
60function bridgeOf($: EngineInterface): Bridge {
61 return {
62 root: $.plugin.root,
63 run: (argv, init) => $.process.run(argv, init),
64 spawn: request => $.process.spawn(request),
65 send: async (sessionId, text) => {
66 sending.set(text, (sending.get(text) ?? 0) + 1)
67 try {
68 return await $.session.send({ to: { sessionId }, text })
69 } finally {
70 const left = (sending.get(text) ?? 1) - 1
71 if (left) sending.set(text, left)
72 else sending.delete(text)
73 }
74 },
75 }
76}
77
78function sessionIo($: EngineInterface, file: string): SessionIo {
79 return {
80 bridge: bridgeOf($),
81 submit: text => $.prompt.submit({ text }),
82 status: text => $.ui.status(text),
83 toast: text => $.ui.toast(text),
84 readState: async () => ((await $.fs.exists(file)) ? JSON.parse(await $.fs.read(file)) : undefined),
85 writeState: state => $.fs.write(file, JSON.stringify(state)),
86 }
87}
88
89async function appendAll($: EngineInterface, texts: readonly string[]): Promise<void> {
90 for (const text of texts) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
91}
92
93function dispatchWork(
94 bridge: Bridge,
95 current: Fleet,
96 delivery: Delivery,
97 task: string,
98 blocking: boolean,
99 signal: AbortSignal,
100): Promise<string> | string {
101 const summary = {
102 target: delivery.agent.target,
103 ...(delivery.record?.machine ? { machine: delivery.record.machine } : {}),
104 ...(delivery.agent.name ? { name: delivery.agent.name } : {}),
105 delivery: delivery.delivery,
106 ...(delivery.notice ? { notice: delivery.notice } : {}),
107 }
108 if (delivery.command || !delivery.record) return JSON.stringify({ ...summary, commandSubmitted: true })
109 const record = current.track({ agent: delivery.agent, record: delivery.record }, task)
110 if (!blocking) return JSON.stringify({ ...summary, status: 'working' })
111 const settled = current.waitFor(bridge, record, signal)
112 const { notice } = delivery
113 return notice ? settled.then(report => `${report}\n${notice}`) : settled
114}
115
116async function handle(bridge: Bridge, current: Fleet, cwd: string, request: AgentsRequest, signal: AbortSignal): Promise<string> {
117 const { machine } = request
118 switch (request.action) {
119 case 'help':
120 if (request.commands) return JSON.stringify(await call(bridge, 'commands', { machine, target: request.target }))
121 return helpText(await call<Profiles>(bridge, 'profiles'))
122 case 'list': {
123 const { agents, unavailable } = await call<{
124 agents: (AgentSummary & { machine?: string })[]
125 unavailable?: unknown[]
126 }>(bridge, 'list', { machine, query: request.query, status: request.status })
127 return JSON.stringify({
128 agents: agents.map(({ terminal, ...agent }) => ({
129 ...agent,
130 ...(current.isWatched(terminal, agent.machine) ? { watched: true } : {}),
131 })),
132 ...(unavailable ? { unavailable } : {}),
133 })
134 }
135 case 'spawn': {
136 const delivery = await call<Delivery>(bridge, 'spawn', { ...request, fallbackCwd: cwd }, 240_000)
137 try {
138 await sendRoute(bridge, delivery.session)
139 } catch (error) {
140 const worker = delivery.agent.name ?? delivery.agent.target
141 throw new Error(
142 `${worker} started but did not receive its task: ${message(error)}. Assign the task to ${worker}, or close it with send "/quit"`,
143 )
144 }
145 const blocking = delivery.profileBlocking ?? request.blocking ?? true
146 return dispatchWork(bridge, current, delivery, request.message!, blocking, signal)
147 }
148 case 'assign': {
149 const params = { machine, target: request.target, message: request.message, kind: 'task' }
150 const delivery = await call<Delivery>(bridge, 'deliver', params, 90_000)
151 await sendRoute(bridge, delivery.session)
152 return dispatchWork(bridge, current, delivery, request.message!, request.blocking ?? true, signal)
153 }
154 case 'answer': {
155 const askId = request.ask_id!
156 const end = current.beginAnswer(request.target!, machine, askId)
157 try {
158 const params = { machine, target: request.target, ask_id: askId, answers: request.answers }
159 const answered = await call<AnswerResult>(bridge, 'answer', params, 150_000)
160 let { status } = answered
161 if (answered.session) {
162 await sendRoute(bridge, answered.session)
163 ;({ status } = await call<AnswerStatus>(bridge, 'answered', params, 30_000))
164 }
165 const result = { target: answered.agent.target, ask_id: askId, status }
166 if (!answered.record) return JSON.stringify({ ...result, input_sent: false })
167 const record = current.track({ agent: answered.agent, record: answered.record }, undefined, true)
168 // A background Ask never stopped its worker: there is nothing to wait for.
169 if (answered.background || request.blocking === false) return JSON.stringify(result)
170 return `${JSON.stringify(result)}\n${await current.waitFor(bridge, record, signal)}`
171 } finally {
172 end()
173 }
174 }
175 case 'send': {
176 const params = { machine, target: request.target, message: request.message, kind: 'message' }
177 const delivery = await call<Delivery>(bridge, 'deliver', params, 90_000)
178 await sendRoute(bridge, delivery.session)
179 return JSON.stringify({
180 target: delivery.agent.target,
181 delivery: delivery.delivery,
182 ...(delivery.command ? { commandSubmitted: true } : {}),
183 })
184 }
185 case 'read':
186 return JSON.stringify(
187 await call(bridge, 'read', { machine, target: request.target, source: request.source, lines: request.lines }),
188 )
189 case 'watch': {
190 const record = current.track(await call<Tracked>(bridge, 'watch', { machine, target: request.target }))
191 return JSON.stringify({ target: record.paneId, ...(record.name ? { name: record.name } : {}), watching: true })
192 }
193 case 'unwatch': {
194 const record = current.find(request.target!, machine)
195 if (!record) throw new Error(`${request.target} is not watched; list shows watched agents`)
196 current.untrack(record)
197 return JSON.stringify({ target: record.paneId, watching: false })
198 }
199 }
200}
201
202/** /shepherdr: status and restart; `orchestrate` or `normal` switches the guidance. */
203async function runCommand(
204 $: EngineInterface,
205 args: string,
206): Promise<{ text: string; context?: string[] }> {
207 const current = await requireFleet($)
208 const bridge = bridgeOf($)
209 const choice = args.trim()
210 if (choice === 'orchestrate' || choice === 'normal') {
211 const enabled = choice === 'orchestrate'
212 const { profiles } = await call<Profiles>(bridge, 'profiles')
213 const guidance = orchestrationGuidance(enabled, profiles.some(profile => profile.name === 'general'))
214 current.orchestrate(enabled, guidance)
215 return { text: `Orchestration ${enabled ? 'on' : 'off'}.`, context: [guidance] }
216 }
217 if (choice) return { text: 'Use /shepherdr, /shepherdr orchestrate or /shepherdr normal.' }
218 return { text: current.inspect(await call<Machine[]>(bridge, 'machines')) }
219}
220
221export const register: Register = on => {
222 on('session.start', async ($, e, next) => {
223 await Promise.all([
224 $.tool.register(TOOL_SPEC),
225 $.command.register({
226 name: COMMAND,
227 description: 'Show Shepherdr agents and machines, restart monitoring, or switch orchestration (orchestrate|normal)',
228 }),
229 ])
230 const started = await next(e)
231 await requireFleet($)
232 return started
233 })
234
235 on('session.end', ($, e, next) => {
236 fleet?.current.stop()
237 fleet = undefined
238 $.ui.status(undefined)
239 return next(e)
240 })
241
242 // Must precede handlers that answer without next, including our own agents.
243 // Its failure must never change another tool's call.
244 on('tool.call', async ($, e, next) => {
245 if (e.agentId) return next(e)
246 const current = await requireFleet($)
247 const ran = await next(e)
248 if (fleet?.current === current) await appendAll($, current.drain())
249 return ran
250 }).catch(($, e, next) => next(e))
251
252 on('tool.call', { tool: 'mcp__shepherdr__agents' }, async ($, e, next) => {
253 try {
254 const request = parseRequest(e as unknown as Record<string, unknown>)
255 const cwd = request.action === 'spawn' ? await $.session.cwd() : ''
256 return { result: await handle(bridgeOf($), await requireFleet($), cwd, request, next.signal) }
257 } catch (error) {
258 if (next.signal.aborted) return { deny: 'Cancelled; the worker keeps running and reports when it settles.' }
259 return { deny: message(error) }
260 }
261 }).catch(() => ({ deny: 'Shepherdr failed to handle the call; retry or call agents with {} for help.' }))
262
263 on('turn.start', async ($, e, next) => {
264 await appendAll($, (await requireFleet($)).turnStarted())
265 return next(e)
266 })
267
268 // Subagent runs raise turn.complete too (with agentId) but no turn.start.
269 on('turn.complete', async ($, e, next) => {
270 const done = await next(e)
271 if (!e.agentId) fleet?.current.turnEnded()
272 return done
273 })
274
275 on('session.compact', async ($, e, next) => {
276 const compacted = await next(e)
277 const current = fleet?.current
278 if (e.agentId || !current || compacted.skip !== undefined) return compacted
279 // Automatic compaction resumes mid-turn with no turn.start, so the
280 // briefing joins the compacted conversation itself. Between turns
281 // (/compact, a notes window) another plugin may replace the conversation
282 // after this hook, so the next turn's start brings it instead.
283 if (e.trigger === 'auto' || e.trigger === 'precompute') {
284 const briefing = current.briefing()
285 return briefing.length
286 ? { ...compacted, messages: [...compacted.messages, { role: 'user', text: briefing.join('\n\n'), toolUses: [] }] }
287 : compacted
288 }
289 current.compacted()
290 return compacted
291 }).catch(($, e, next) => next(e))
292
293 // A delivery to a Claude Code worker is part of the agents call the session
294 // already approved; auto mode's classifier cannot judge it on its own. Only
295 // this mod's own send of a text it is delivering right now is allowed.
296 on('tool.check', { tool: 'SendMessage' }, ($, e, next) => {
297 // $.session.send runs as this mod's SendMessage call with { to, message }.
298 const text = (e.input as { message?: unknown }).message
299 return next.origin.plugin === $.plugin.name && typeof text === 'string' && sending.has(text)
300 ? { decision: 'allow', reason: 'Shepherdr delivery for an approved agents call' }
301 : next(e)
302 }).catch(($, e, next) => next(e))
303
304 // Worker side: a controller's answer settles this session's open question
305 // in place of its dialog, whichever comes first; the dialog then closes.
306 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
307 if (e.agentId || !e.tool_use_id) return next(e)
308 const file = await sessionFile($, 'asks')
309 await $.fs.write(file, JSON.stringify({ toolCallId: e.tool_use_id, questions: e.questions }))
310 const remote = awaitAnswer(e.tool_use_id)
311 try {
312 const winner = await Promise.race([
313 next(e).then(local => ({ local })),
314 remote.answered.then(payload => ({ payload })),
315 ])
316 if ('local' in winner) return winner.local
317 const { answers, annotations } = winner.payload
318 return {
319 result: {
320 questions: e.questions,
321 answers,
322 ...(annotations && Object.keys(annotations).length ? { annotations } : {}),
323 },
324 }
325 } finally {
326 remote.cancel()
327 await $.fs.write(file, '{}')
328 }
329 }).catch(($, e, next) => (next.called ? { deny: 'The question was interrupted; ask it again.' } : next(e)))
330
331 // A frame for a question already settled is dropped too: it was never for the model.
332 on('session.receive', ($, e, next) => {
333 if (e.origin.kind !== 'peer') return next(e)
334 const taken = takeAnswer(e.text)
335 if (!taken) return next(e)
336 return { consumed: taken === 'answered' ? 'answered an open question' : 'answer for a question no longer open' }
337 }).catch(($, e, next) => next(e))
338
339 on('command.run', { command: COMMAND }, ($, e) => runCommand($, e.args))
340
341 // The rows the person sees; the model reads the same JSON and reports.
342 on('ui.render', { component: 'ToolUse', props: { tool: 'mcp__shepherdr__agents' } }, ($, e) =>
343 CallRow($.ui.resolve(e), e.props.input as Record<string, unknown>, e.props),
344 )
345
346 on('ui.render', { component: 'ToolResult', props: { tool: 'mcp__shepherdr__agents' } }, ($, e, next) => {
347 if (e.props.isErrored || typeof e.props.output !== 'string') return next(e)
348 return ResultRow($.ui.resolve(e), e.props.output) ?? next(e)
349 })
350
351 // Reports pushed as a prompt of their own; ctrl+o still shows the raw text.
352 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'plugin' } } }, ($, e, next) => {
353 const { origin, isExpanded, text } = e.props
354 const reports = origin.kind === 'plugin' && origin.name === 'shepherdr' && !isExpanded ? parseReports(text) : []
355 return reports.length ? PushedReports($.ui.resolve(e), reports) : next(e)
356 })
357}
358hooks/bridge.ts 183 lines1// The mod has no Node APIs; Herdr sockets, transcripts and processes go
2// through the Node helpers under ../bridge. A Bridge carries one hook's own
3// process calls, since `$` stays in the hooks module's top-level functions.
4import type {
5 HookStream,
6 ProcessRunInit,
7 ProcessRunResult,
8 ProcessSpawnChunk,
9 ProcessSpawnRequest,
10 ProcessSpawnResult,
11 SessionSendResult,
12} from 'claude-code'
13
14export type Bridge = {
15 root: string
16 run: (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
17 spawn: (request: ProcessSpawnRequest) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>
18 /** Claude Code's own cross-session message, which the Node bridge cannot send. */
19 send: (sessionId: string, text: string) => Promise<SessionSendResult>
20}
21
22/** A message for a Claude Code worker's session that the bridge prepared and the hooks send. */
23export type SessionRoute = { sessionId: string; text: string }
24
25export async function sendRoute(bridge: Bridge, route: SessionRoute | undefined): Promise<void> {
26 if (!route) return
27 const sent = await bridge.send(route.sessionId, route.text)
28 if (!sent.isDelivered) throw new Error(`The worker's Claude Code session did not take the message: ${sent.reason}`)
29}
30
31export type WorkerRecord = {
32 /** Herdr machine profile ID; absent on the machine running Claude Code. */
33 machine?: string
34 terminalId: string
35 paneId: string
36 name?: string
37 sessionPath?: string
38 seenInputId: string | null
39}
40
41export type AgentSummary = {
42 target: string
43 name?: string
44 kind: string
45 status: string
46 terminal: string
47 cwd?: string
48}
49
50export type Ask = {
51 toolCallId: string
52 /** "steer": asked while the worker continues. */
53 delivery?: 'steer'
54 prompts: { title: string; multiple: boolean; choices: { label: string; description?: string }[]; body?: string }[]
55 /** The worker's own question text, in place of prompts. */
56 question?: string
57 /** Only the user can complete it, in the worker's pane. */
58 handoff?: boolean
59}
60
61export type CheckResult = {
62 state: 'closed' | 'replaced' | 'working' | 'idle' | 'done' | 'question' | 'blocked'
63 agent?: AgentSummary
64 inputId?: string | null
65 sessionPath?: string
66 reply?: { id: string; text: string; stopReason?: string }
67 ask?: Ask
68}
69
70export type Delivery = {
71 agent: AgentSummary
72 /** Present for tasks: the watch that reports the outcome. */
73 record?: WorkerRecord
74 delivery: 'peer' | 'terminal' | 'session'
75 command?: true
76 profileBlocking?: boolean
77 session?: SessionRoute
78 /** Why a spawned worker's first task may have taken a fallback route. */
79 notice?: string
80}
81
82export type Tracked = { agent: AgentSummary; record: WorkerRecord }
83
84export type AnswerStatus = { agent: AgentSummary; status: 'accepted' | 'rejected' | 'unknown' }
85
86export type AnswerResult = {
87 agent: AgentSummary
88 /** Absent while a `session` route is unsent; `answered` reads it afterwards. */
89 status?: AnswerStatus['status']
90 session?: SessionRoute
91 /** A background Ask: the worker never stopped, so there is no settlement to wait for. */
92 background?: boolean
93 /** Present only when answers were entered; a recorded retry sends nothing. */
94 record?: WorkerRecord
95}
96
97export function message(error: unknown): string {
98 return error instanceof Error ? error.message : String(error)
99}
100
101/** The ten-minute ceiling $.process.run allows, less room to wind down. */
102export const LONGEST_CALL_MS = 590_000
103
104/**
105 * Runs one bridge operation. Its time limit is the one budget for everything
106 * beneath: the bridge, and any SSH call it makes, finish inside it.
107 */
108export async function call<T>(
109 bridge: Bridge,
110 op: string,
111 params: Record<string, unknown> = {},
112 timeoutMs = 60_000,
113): Promise<T> {
114 const run = await bridge.run(['node', `${bridge.root}/bridge/cli.mjs`], {
115 stdin: JSON.stringify({ op, ...params, budgetMs: timeoutMs - 2_000 }),
116 timeoutMs,
117 })
118 const line = run.stdout.trim().split('\n').at(-1) ?? ''
119 let reply: { ok: true; result: T } | { ok: false; error: { message: string } }
120 try {
121 reply = JSON.parse(line)
122 } catch {
123 throw new Error(
124 `Shepherdr bridge failed (exit ${run.exitCode}): ${run.stderr.trim() || run.stdout.trim() || 'no output'}`,
125 )
126 }
127 if (!reply.ok) throw new Error(reply.error.message)
128 return reply.result
129}
130
131/** Every line carries `machine` when it came from a remote machine's watcher. */
132export type WatchLine =
133 | { type: 'ready' }
134 | { type: 'event'; event: string; pane: string | null; status?: string }
135 | { type: 'session'; path: string; ask?: Ask }
136
137/** Starts one machine's watcher (this one when `machine` is absent); the returned function stops it. */
138export function watchWorkers(
139 bridge: Bridge,
140 machine: string | undefined,
141 records: readonly WorkerRecord[],
142 onLine: (line: WatchLine) => void,
143 onExit: (error: string) => void,
144): () => void {
145 const stream = bridge.spawn({
146 argv: ['node', `${bridge.root}/bridge/watch.mjs`],
147 input: `${JSON.stringify({
148 machine,
149 targets: records.map(({ terminalId, sessionPath }) => ({ terminalId, sessionPath })),
150 })}\n`,
151 })
152 let stopped = false
153 void (async () => {
154 let buffer = ''
155 let stderr = ''
156 let error: string | undefined
157 try {
158 for await (const { stream: name, text } of stream) {
159 if (name === 'stderr') {
160 stderr = (stderr + text).slice(-2000)
161 continue
162 }
163 buffer += text
164 for (let newline = buffer.indexOf('\n'); newline >= 0; newline = buffer.indexOf('\n')) {
165 const line = buffer.slice(0, newline)
166 buffer = buffer.slice(newline + 1)
167 if (!line) continue
168 const value = JSON.parse(line) as WatchLine | { type: 'error'; message: string }
169 if (value.type === 'error') error = value.message
170 else onLine(value)
171 }
172 }
173 } catch (thrown) {
174 error = message(thrown)
175 }
176 if (!stopped) onExit(error ?? (stderr.trim() || 'monitor exited'))
177 })()
178 return () => {
179 stopped = true
180 void stream.return(undefined as never)
181 }
182}
183hooks/contract.ts 186 lines1// The model-facing `agents` contract: schema, request validation and help.
2
3export const TOOL_NAME = 'agents'
4
5const ACTIONS = ['help', 'list', 'spawn', 'send', 'assign', 'answer', 'read', 'watch', 'unwatch'] as const
6const STATUSES = ['idle', 'working', 'blocked', 'done', 'unknown'] as const
7const PLACEMENTS = ['new_tab', 'new_workspace', 'pane'] as const
8const SOURCES = ['latest', 'visible', 'recent'] as const
9
10export type Action = (typeof ACTIONS)[number]
11
12const FIELDS: Record<Action, readonly string[]> = {
13 help: ['commands', 'target', 'machine'],
14 list: ['machine', 'query', 'status'],
15 spawn: ['machine', 'agent_type', 'label', 'name', 'placement', 'workspace', 'pane', 'cwd', 'message', 'base', 'blocking'],
16 send: ['machine', 'target', 'message'],
17 assign: ['machine', 'target', 'message', 'blocking'],
18 answer: ['machine', 'target', 'ask_id', 'answers', 'blocking'],
19 read: ['machine', 'target', 'source', 'lines'],
20 watch: ['machine', 'target'],
21 unwatch: ['machine', 'target'],
22}
23
24const REQUIRED: Record<Action, readonly string[]> = {
25 help: [],
26 list: [],
27 spawn: ['agent_type', 'label', 'message'],
28 send: ['target', 'message'],
29 assign: ['target', 'message'],
30 answer: ['target', 'ask_id', 'answers'],
31 read: ['target'],
32 watch: ['target'],
33 unwatch: ['target'],
34}
35
36export const TOOL_SPEC = {
37 name: TOOL_NAME,
38 description:
39 'Spawn, delegate to and monitor Pi and Claude Code agents running in Herdr. Call with {} first for profiles and rules.',
40 isDeferred: false,
41 inputSchema: {
42 type: 'object',
43 additionalProperties: false,
44 properties: {
45 action: { type: 'string', enum: ACTIONS },
46 target: { type: 'string', description: 'Agent name or pane ID' },
47 machine: { type: 'string', description: 'Machine ID from list; omit for this machine' },
48 commands: { type: 'boolean' },
49 agent_type: { type: 'string' },
50 label: { type: 'string' },
51 name: { type: 'string' },
52 placement: { type: 'string', enum: PLACEMENTS },
53 workspace: { type: 'string' },
54 pane: { type: 'string' },
55 cwd: { type: 'string' },
56 message: { type: 'string' },
57 base: { type: 'string', description: 'Review base branch' },
58 blocking: { type: 'boolean' },
59 query: { type: 'string' },
60 status: { type: 'string', enum: STATUSES },
61 source: { type: 'string', enum: SOURCES },
62 lines: { type: 'integer', minimum: 1, maximum: 200 },
63 ask_id: { type: 'string' },
64 answers: {
65 type: 'array',
66 description: 'One per prompt, in order',
67 items: {
68 type: 'object',
69 additionalProperties: false,
70 properties: {
71 selections: { type: 'array', items: { type: 'string' } },
72 other: { type: 'string' },
73 comment: { type: 'string' },
74 },
75 },
76 },
77 },
78 },
79} as const
80
81export type AgentsRequest = {
82 action: Action
83 target?: string
84 machine?: string
85 commands?: boolean
86 agent_type?: string
87 label?: string
88 name?: string
89 placement?: (typeof PLACEMENTS)[number]
90 workspace?: string
91 pane?: string
92 cwd?: string
93 message?: string
94 base?: string
95 blocking?: boolean
96 query?: string
97 status?: (typeof STATUSES)[number]
98 source?: (typeof SOURCES)[number]
99 lines?: number
100 ask_id?: string
101 answers?: { selections?: string[]; other?: string; comment?: string }[]
102}
103
104const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId'])
105
106export function parseRequest(input: Record<string, unknown>): AgentsRequest {
107 // Omitting machine means this one; "local" is accepted as the same.
108 const request = Object.fromEntries(
109 Object.entries(input).filter(
110 ([key, value]) => !RESERVED.has(key) && value !== undefined && !(key === 'machine' && value === 'local'),
111 ),
112 )
113 const action = request['action'] ?? 'help'
114 if (typeof action !== 'string' || !(ACTIONS as readonly string[]).includes(action))
115 throw new Error(`action must be one of: ${ACTIONS.join(', ')}`)
116 const allowed = new Set(['action', ...FIELDS[action as Action]])
117 const unknown = Object.keys(request).filter(key => !allowed.has(key))
118 if (unknown.length)
119 throw new Error(
120 action === 'send' && unknown.includes('blocking')
121 ? 'send is message-only; omit blocking. Use assign to delegate work'
122 : `unknown ${action} field(s): ${unknown.join(', ')}`,
123 )
124 for (const field of REQUIRED[action as Action]) {
125 const value = request[field]
126 const present = field === 'answers' ? Array.isArray(value) : typeof value === 'string' && value.trim() !== ''
127 if (!present) throw new Error(`${field} is required for ${action}`)
128 }
129 if (action === 'help' && (request['commands'] === true) !== (typeof request['target'] === 'string'))
130 throw new Error('commands:true and target go together: they list the slash commands that agent accepts')
131 if (action === 'help' && 'machine' in request && !request['commands'])
132 throw new Error('machine requires commands:true and target for help')
133 return { ...request, action } as AgentsRequest
134}
135
136/** pi-shepherdr's orchestration guidance, given once per switch and after compaction. */
137export function orchestrationGuidance(enabled: boolean, general: boolean): string {
138 const ownership = "Own a scope or delegate it; don't do both."
139 if (!enabled) return `Work normally. Delegate only when useful or requested. ${ownership}`
140 const guidance = general
141 ? 'Your main goal from now on is to orchestrate agents. Fan out suitable work to general agents, synthesize their results, and report the outcome. Work directly only when asked or for routine local tasks.'
142 : 'Your main goal from now on is to orchestrate agents. Fan out suitable work, synthesize agent results, and report the outcome. Work directly only when asked or for routine local tasks.'
143 return `${guidance} ${ownership}`
144}
145
146export type ProfileSummary = {
147 name: string
148 /** Present for Claude Code profiles; Pi otherwise. */
149 agent?: 'claude'
150 description: string
151 model: string
152 thinking?: string
153 effort?: string
154 accepts?: string[]
155 blocking?: boolean
156}
157
158export function helpText(profiles: { sources: Record<string, string>; profiles: ProfileSummary[] }): string {
159 return JSON.stringify({
160 profiles: profiles.profiles,
161 profile_sources: profiles.sources,
162 requests: {
163 list: '{action:"list", machine?, query?, status?} — this machine and every connected machine unless machine is given',
164 commands: '{commands:true, target, machine?} — the slash commands that agent accepts',
165 spawn: '{action:"spawn", agent_type, label (2-3 words), message, cwd?, placement?: new_tab|new_workspace|pane, workspace?, pane?, name?, base?, blocking?}',
166 send: '{action:"send", target, message}',
167 assign: '{action:"assign", target, message, blocking?}',
168 answer: '{action:"answer", target, ask_id, answers: [{selections?: [choice label], other?, comment?}] one per prompt in order, blocking?}',
169 read: '{action:"read", target, source?: latest|visible|recent, lines?}',
170 watch: '{action:"watch", target}',
171 unwatch: '{action:"unwatch", target}',
172 },
173 rules: [
174 'spawn, assign and answer block until the worker settles unless blocking:false; a profile blocking setting overrides spawn',
175 'Non-blocking work and watched agents report completion, questions and blockage as agent_report messages; never poll',
176 'send delivers input without waiting or watching; use assign only for work whose result you need',
177 'Delegation and watch keep reporting later work until unwatch or the pane closes',
178 'A question report carries its ask_id and prompts; answer it with answer, asking the user first when the choice is theirs. A background question does not stop the worker, and answering it does not wait for its task',
179 'Messages starting with / run as the worker\'s slash commands without a watch; list them first with commands:true',
180 'Agents on another machine need its machine ID from list with every request; omit machine for this one. Claude Code agents work on this machine only',
181 'For work in this repository, create a dedicated worktree and pass it as cwd',
182 'Close finished workers by sending "/quit"; do not send acknowledgements',
183 ],
184 })
185}
186hooks/fleet.ts 502 lines1// Watched workers, their settlement and its delivery to the controller.
2//
3// Records are keyed by machine and Herdr terminal ID (pane IDs change on move)
4// and persisted per Claude Code session. Each machine with watched workers has
5// its own watcher process, so a change on one machine restarts only that one;
6// each change is reconciled with a bridge `check`. A blocking tool call or an
7// answer being entered leases its worker, so the watcher never reports what
8// the call itself returns.
9import {
10 type Ask,
11 type Bridge,
12 type CheckResult,
13 LONGEST_CALL_MS,
14 type Tracked,
15 type WatchLine,
16 type WorkerRecord,
17 call,
18 message,
19 watchWorkers,
20} from './bridge'
21
22/** The session's engine calls, bound in register.ts where `$` is reachable. */
23export type SessionIo = {
24 bridge: Bridge
25 submit: (text: string) => Promise<unknown>
26 status: (text: string | undefined) => void
27 toast: (text: string) => void
28 /** This session's saved state; undefined when none was saved. */
29 readState: () => Promise<unknown>
30 writeState: (state: SavedState) => Promise<void>
31}
32
33type Watched = WorkerRecord & {
34 task?: string
35 status?: string
36 reportedAskId?: string
37 blockedReported?: boolean
38}
39
40type SavedState = { watches: Watched[]; orchestration?: Orchestration }
41
42/** An explicit orchestration choice and the guidance it gave. */
43type Orchestration = { enabled: boolean; guidance: string }
44
45type Machine = { id: string; label: string; enabled: boolean }
46
47type Report = {
48 record: Watched
49 status: 'done' | 'failed' | 'question' | 'blocked' | 'interrupted' | 'closed'
50 reply?: string
51 ask?: Ask
52}
53
54/** One machine's watcher: the targets it covers and how to stop it. */
55type Watcher = { key: string; stop?: () => void; error?: string }
56
57const machineKey = (machine: string | undefined) => machine ?? 'local'
58const keyOf = ({ machine, terminalId }: WorkerRecord) => `${machineKey(machine)}:${terminalId}`
59
60function attribute(value: string): string {
61 return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>')
62}
63
64function questionHint(ask: Ask | undefined): string {
65 if (ask?.handoff) return 'The worker waits on an action only the user can take. Tell the user; they complete it in its pane.'
66 if (ask?.delivery === 'steer')
67 return 'The worker continues meanwhile. Answer with answer and this ask_id, asking the user first when the choice is theirs.'
68 return 'The worker waits. Answer with answer and this ask_id, asking the user first when the choice is theirs, or let them answer in its pane.'
69}
70
71function bodyFor({ record, status, reply, ask }: Report): string[] {
72 switch (status) {
73 case 'done':
74 return [reply || 'Finished without a reply.']
75 case 'failed':
76 return [
77 reply || 'The worker stopped with an error.',
78 'Inspect it with read source:"recent" and assess the failure. If this task was not retried yet and one corrective assign could recover it, try once; if it fails again or the setup looks broken, stop and tell the user.',
79 ]
80 case 'question':
81 return [
82 JSON.stringify({
83 ask_id: ask?.toolCallId,
84 ...(ask?.question ? { question: ask.question } : { prompts: ask?.prompts ?? [] }),
85 }),
86 questionHint(ask),
87 ]
88 case 'blocked': {
89 const herdr = record.machine ? `herdr --machine ${record.machine}` : 'herdr'
90 return [
91 `Waiting on an approval or dialog. Inspect it with read source:"visible"; respond with send, or use \`${herdr} agent send-keys ${record.paneId} <keys>\` for interactive controls. Tell the user if it needs them.`,
92 ]
93 }
94 case 'interrupted':
95 return ['Its session changed before the task finished; the watch continues on the new session.']
96 case 'closed':
97 return ['The pane closed; its watch ended.']
98 }
99}
100
101export function reportText(report: Report): string {
102 const { record, status } = report
103 const machine = record.machine ? ` machine="${record.machine}"` : ''
104 const name = record.name ? ` name="${attribute(record.name)}"` : ''
105 const task = record.task && status !== 'closed' ? [`Task: ${record.task}`] : []
106 const text = [...task, ...bodyFor(report)].join('\n').replaceAll('</agent_report>', '<\\/agent_report>')
107 return `<agent_report target="${attribute(record.paneId)}"${machine}${name} status="${status}">\n${text}\n</agent_report>`
108}
109
110export class Fleet {
111 private readonly records = new Map<string, Watched>()
112 /** Blocking calls and answers per worker; the watcher leaves leased workers alone. */
113 private readonly leases = new Map<string, number>()
114 /** Ask IDs whose answers are being entered. */
115 private readonly answering = new Set<string>()
116 private readonly checking = new Map<string, boolean>()
117 private readonly watchers = new Map<string, Watcher>()
118 /** Reports waiting for the running turn's next tool boundary or its end. */
119 private outbox: string[] = []
120 /** Context lost to compaction, restored before the next model request. */
121 private briefingDue = false
122 /** A main turn is running, or a submitted report has yet to start one. */
123 private busy = false
124 private loaded = false
125 private orchestration: Orchestration | undefined
126
127 constructor(private readonly io: SessionIo) {}
128
129 async load(): Promise<void> {
130 const state = (await this.io.readState()) as SavedState | undefined
131 if (state !== undefined && !Array.isArray(state?.watches)) throw new Error('saved Shepherdr state is malformed')
132 for (const record of state?.watches ?? []) this.records.set(keyOf(record), record)
133 this.orchestration = state?.orchestration
134 this.loaded = true
135 this.ensureWatchers()
136 this.updateStatus()
137 }
138
139 stop(): void {
140 for (const watcher of this.watchers.values()) watcher.stop?.()
141 this.watchers.clear()
142 // Retire in-flight checks too, without changing this session's saved state.
143 this.records.clear()
144 this.outbox = []
145 }
146
147 find(target: string, machine?: string): Watched | undefined {
148 return [...this.records.values()].find(
149 record => record.machine === machine && (record.paneId === target || record.name === target),
150 )
151 }
152
153 isWatched(terminalId: string, machine?: string): boolean {
154 return this.records.has(keyOf({ machine, terminalId } as WorkerRecord))
155 }
156
157 /**
158 * Watches a delivered task, a worker named by `watch` (no task) or one
159 * resuming from an answered Ask, whose next question reports again.
160 */
161 track({ agent, record: delivered }: Tracked, task?: string, answered = false): Watched {
162 const existing = this.records.get(keyOf(delivered))
163 const record: Watched = {
164 ...existing,
165 ...delivered,
166 ...(existing && !task ? { seenInputId: existing.seenInputId } : {}),
167 status: agent.status,
168 ...(task ? { task } : {}),
169 }
170 if (answered) delete record.reportedAskId
171 this.records.set(keyOf(record), record)
172 this.changed()
173 return record
174 }
175
176 untrack(record: Watched): void {
177 this.records.delete(keyOf(record))
178 this.changed()
179 }
180
181 /**
182 * Claims an Ask for answering and leases a watched worker, so the watcher
183 * leaves it alone while keys are entered and its Ask closes. The returned
184 * function ends both.
185 */
186 beginAnswer(target: string, machine: string | undefined, askId: string): () => void {
187 if (this.answering.has(askId)) throw new Error(`An answer to ${askId} is already being entered; wait for its result`)
188 this.answering.add(askId)
189 const record = this.find(target, machine)
190 const release = record ? this.lease(keyOf(record)) : () => {}
191 return () => {
192 this.answering.delete(askId)
193 release()
194 }
195 }
196
197 /** Records an explicit orchestration choice, given again after compaction. */
198 orchestrate(enabled: boolean, guidance: string): void {
199 this.orchestration = { enabled, guidance }
200 this.persist()
201 this.updateStatus()
202 }
203
204 /** Compaction dropped earlier context; brief again before the next model request. */
205 compacted(): void {
206 this.briefingDue = true
207 }
208
209 turnStarted(): string[] {
210 this.busy = true
211 return this.drain()
212 }
213
214 /** Context and reports to append before the running turn's next model request. */
215 drain(): string[] {
216 const briefing = this.briefingDue ? this.briefing() : []
217 this.briefingDue = false
218 const queued = [...briefing, ...this.outbox]
219 this.outbox = []
220 return queued
221 }
222
223 /** Submits reports the turn did not reach; a submitted prompt resolves only once its turn starts. */
224 turnEnded(): void {
225 this.busy = false
226 const queued = this.outbox
227 this.outbox = []
228 if (queued.length) this.submit(queued.join('\n\n'))
229 }
230
231 /** Holds a tool call until the worker settles, through the calling hook's own Bridge. */
232 async waitFor(bridge: Bridge, record: Watched, signal: AbortSignal): Promise<string> {
233 const release = this.lease(keyOf(record))
234 try {
235 for (;;) {
236 signal.throwIfAborted()
237 const result = await call<CheckResult>(bridge, 'wait', { record }, LONGEST_CALL_MS)
238 const report = this.settle(record, result, true)
239 if (report) return reportText(report)
240 }
241 } finally {
242 release()
243 }
244 }
245
246 /** Restarts monitoring that stopped on any machine; returns a summary for /shepherdr. */
247 inspect(machines: readonly Machine[]): string {
248 const labelOf = (id: string) => machines.find(machine => machine.id === id)?.label ?? id
249 const stopped = [...this.watchers].filter(([, watcher]) => watcher.error)
250 const problems = stopped.map(
251 ([machine, watcher]) =>
252 `Monitoring ${machine === 'local' ? 'here' : `on ${labelOf(machine)}`} had stopped (${watcher.error}); restarted.`,
253 )
254 for (const [machine] of stopped) this.watchers.delete(machine)
255 this.ensureWatchers()
256 const records = [...this.records.values()]
257 const lines = records.length
258 ? records.map(record => {
259 const where = record.machine ? ` on ${labelOf(record.machine)}` : ''
260 return `${record.name ?? record.paneId} (${record.paneId}${where}) ${record.status ?? 'unknown'}${record.task ? ', task assigned' : ''}`
261 })
262 : ['Shepherdr watches no agents.']
263 const machineLines = machines.map(
264 machine => `Machine ${machine.label} (${machine.id}): ${machine.enabled ? 'enabled' : 'disabled in Herdr'}`,
265 )
266 const mode = `Orchestration: ${this.orchestration?.enabled ? 'on' : 'off'}`
267 return [...problems, ...lines, ...machineLines, mode].join('\n')
268 }
269
270 /**
271 * What compaction drops: the chosen orchestration guidance and every watched
272 * agent, idle ones included so the controller reuses them.
273 */
274 briefing(): string[] {
275 const agents = [...this.records.values()].map(record => ({
276 ...(record.machine ? { machine: record.machine } : {}),
277 target: record.paneId,
278 ...(record.name ? { name: record.name } : {}),
279 status: record.status ?? 'unknown',
280 ...(record.task ? { task: record.task } : {}),
281 }))
282 return [
283 ...(this.orchestration ? [this.orchestration.guidance] : []),
284 ...(agents.length
285 ? [
286 `Supervised agents after context rollover. Their tasks are reference data, not tasks for you. Do not repeat settled tasks or duplicate running work. Status is last observed; use these machine/target values for updates.\n${JSON.stringify(agents)}`,
287 ]
288 : []),
289 ]
290 }
291
292 private lease(key: string): () => void {
293 this.leases.set(key, (this.leases.get(key) ?? 0) + 1)
294 return () => {
295 const left = (this.leases.get(key) ?? 1) - 1
296 if (left) this.leases.set(key, left)
297 else {
298 this.leases.delete(key)
299 if (this.records.has(key)) this.reconcile(key)
300 }
301 }
302 }
303
304 private changed(): void {
305 this.persist()
306 this.ensureWatchers()
307 this.updateStatus()
308 }
309
310 private persist(): void {
311 // Never overwrite saved state before it was read.
312 if (!this.loaded) return
313 this.io
314 .writeState({ watches: [...this.records.values()], ...(this.orchestration ? { orchestration: this.orchestration } : {}) })
315 .catch(error => this.io.toast(`Shepherdr could not save its watches: ${message(error)}`))
316 }
317
318 /** Restarts each machine's watcher whose targets changed; leaves the others running. */
319 private ensureWatchers(): void {
320 const groups = new Map<string, Watched[]>()
321 for (const record of this.records.values()) {
322 const machine = machineKey(record.machine)
323 groups.set(machine, [...(groups.get(machine) ?? []), record])
324 }
325 for (const [machine, watcher] of this.watchers)
326 if (!groups.has(machine)) {
327 watcher.stop?.()
328 this.watchers.delete(machine)
329 }
330 for (const [machine, records] of groups) {
331 const key = JSON.stringify(records.map(record => [record.terminalId, record.sessionPath ?? null]).sort())
332 const current = this.watchers.get(machine)
333 if (current?.key === key) continue
334 current?.stop?.()
335 const id = machine === 'local' ? undefined : machine
336 const watcher: Watcher = { key }
337 watcher.stop = watchWorkers(
338 this.io.bridge,
339 id,
340 records,
341 line => this.onLine(id, line),
342 error => {
343 if (this.watchers.get(machine) !== watcher) return
344 watcher.stop = undefined
345 watcher.error = error
346 this.updateStatus()
347 },
348 )
349 this.watchers.set(machine, watcher)
350 }
351 }
352
353 private onLine(machine: string | undefined, line: WatchLine): void {
354 const records = [...this.records.values()].filter(record => record.machine === machine)
355 switch (line.type) {
356 case 'ready':
357 for (const record of records) this.reconcile(keyOf(record))
358 return
359 case 'session':
360 for (const record of records) {
361 if (record.sessionPath !== line.path) continue
362 // A background question reports at once, while its worker continues.
363 if (line.ask) this.report(this.settle(record, { state: 'question', ask: line.ask }, false))
364 // A working worker writes its transcript continuously; Herdr's status
365 // event marks when it settles, so only settled workers' writes matter.
366 else if (record.status !== 'working') this.reconcile(keyOf(record))
367 }
368 return
369 case 'event': {
370 // Moves and closes name the pane by the ID the record knows.
371 const record = records.find(candidate => candidate.paneId === line.pane)
372 if (!record) return
373 if (line.status === 'working' || line.status === 'unknown') {
374 // Starting work settles nothing.
375 record.status = line.status
376 record.blockedReported = false
377 this.persist()
378 this.updateStatus()
379 return
380 }
381 this.reconcile(keyOf(record))
382 }
383 }
384 }
385
386 private reconcile(key: string): void {
387 if (this.checking.has(key)) {
388 this.checking.set(key, true)
389 return
390 }
391 void (async () => {
392 try {
393 do {
394 this.checking.set(key, false)
395 const record = this.records.get(key)
396 if (!record || this.leases.has(key)) break
397 const result = await call<CheckResult>(this.io.bridge, 'check', { record }, 90_000)
398 if (this.leases.has(key) || this.records.get(key) !== record) continue
399 this.report(this.settle(record, result, false))
400 } while (this.checking.get(key))
401 } catch (error) {
402 this.io.toast(`Shepherdr could not check a worker: ${message(error)}`)
403 } finally {
404 this.checking.delete(key)
405 }
406 })()
407 }
408
409 /**
410 * Applies a check to its record; returns what the controller has not heard
411 * yet. `force` reports a settlement even if it was reported before.
412 */
413 private settle(record: Watched, result: CheckResult, force: boolean): Report | undefined {
414 const before = JSON.stringify(record)
415 const identity = [record.paneId, record.sessionPath]
416 if (result.agent) {
417 record.paneId = result.agent.target
418 record.status = result.agent.status
419 if (result.agent.name) record.name = result.agent.name
420 }
421 // A transcript first written after the watch began.
422 if (result.state !== 'replaced' && result.sessionPath) record.sessionPath = result.sessionPath
423 let report: Report | undefined
424 switch (result.state) {
425 case 'closed':
426 this.records.delete(keyOf(record))
427 report = { record, status: 'closed' }
428 break
429 case 'replaced':
430 record.sessionPath = result.sessionPath
431 record.seenInputId = result.inputId ?? null
432 delete record.reportedAskId
433 record.blockedReported = false
434 if (record.task || force) report = { record: { ...record }, status: 'interrupted' }
435 delete record.task
436 break
437 case 'working':
438 record.blockedReported = false
439 break
440 case 'idle':
441 break
442 case 'done':
443 if (!force && result.inputId === record.seenInputId) break
444 record.seenInputId = result.inputId ?? null
445 record.blockedReported = false
446 report = {
447 record: { ...record },
448 status: result.reply?.stopReason === 'error' ? 'failed' : 'done',
449 ...(result.reply?.text ? { reply: result.reply.text } : {}),
450 }
451 delete record.task
452 break
453 case 'question':
454 if (!force && record.reportedAskId === result.ask?.toolCallId) break
455 record.reportedAskId = result.ask?.toolCallId
456 report = { record, status: 'question', ...(result.ask ? { ask: result.ask } : {}) }
457 break
458 case 'blocked':
459 if (!force && record.blockedReported) break
460 record.blockedReported = true
461 report = { record, status: 'blocked' }
462 break
463 }
464 // A moved pane or new transcript needs the watcher on its new identity.
465 const moved = identity[0] !== record.paneId || identity[1] !== record.sessionPath
466 if (result.state === 'closed' || moved) this.changed()
467 else if (JSON.stringify(record) !== before) {
468 this.persist()
469 this.updateStatus()
470 }
471 return report
472 }
473
474 /** Queues for the running turn; otherwise starts a turn with the report as its prompt. */
475 private report(report: Report | undefined): void {
476 if (!report) return
477 const text = reportText(report)
478 if (this.busy) this.outbox.push(text)
479 else this.submit(text)
480 }
481
482 private submit(text: string): void {
483 this.busy = true
484 this.io.submit(text).catch(error => {
485 this.busy = false
486 this.io.toast(`Shepherdr could not deliver an agent report: ${message(error)}`)
487 })
488 }
489
490 private updateStatus(): void {
491 const records = [...this.records.values()]
492 const stopped = [...this.watchers.values()].find(watcher => watcher.error)?.error
493 const mode = this.orchestration?.enabled ? 'orchestrating' : undefined
494 if (stopped) this.io.status(`shepherdr: monitoring stopped (${stopped}); run /shepherdr`)
495 else if (!records.length) this.io.status(mode && `shepherdr: ${mode}`)
496 else {
497 const working = records.filter(record => record.status === 'working').length
498 this.io.status(`shepherdr: ${records.length} watched, ${working} working${mode ? `, ${mode}` : ''}`)
499 }
500 }
501}
502hooks/render.tsx 248 lines1// How the `agents` tool's calls, results and pushed reports look to the
2// person. The model keeps reading the same JSON and agent_report text; these
3// rows only draw it. Each takes the surface's element table from the hook.
4import type { BoxProps, ElementConstructor, MarkdownProps, TextProps } from 'claude-code'
5
6export type Draw = {
7 Box: ElementConstructor<BoxProps>
8 Text: ElementConstructor<TextProps>
9 Markdown: ElementConstructor<MarkdownProps>
10}
11
12type Input = Record<string, unknown>
13
14const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
15
16function firstLine(text: string, max = 80): string {
17 const line = text.trim().split('\n')[0] ?? ''
18 return line.length > max ? `${line.slice(0, max - 1)}…` : line
19}
20
21/** `target` on `machine`, as the call names them. */
22function where(input: Input): string {
23 const machine = str(input['machine'])
24 return `${str(input['target']) ?? ''}${machine ? ` on ${machine.slice(0, 8)}` : ''}`
25}
26
27/** One line saying what the call does. */
28export function callSummary(input: Input): string {
29 const action = str(input['action']) ?? 'help'
30 switch (action) {
31 case 'help':
32 return input['commands'] ? `commands ${where(input)}` : 'help'
33 case 'list': {
34 const filters = [str(input['machine']), str(input['status']), str(input['query'])].filter(Boolean)
35 return filters.length ? `list ${filters.join(' ')}` : 'list'
36 }
37 case 'spawn': {
38 const machine = str(input['machine'])
39 return `spawn ${str(input['agent_type'])} "${str(input['label'])}"${machine ? ` on ${machine.slice(0, 8)}` : ''}: ${firstLine(str(input['message']) ?? '', 60)}`
40 }
41 case 'assign':
42 case 'send':
43 return `${action} ${where(input)}: ${firstLine(str(input['message']) ?? '', 60)}`
44 case 'answer': {
45 const answers = Array.isArray(input['answers']) ? (input['answers'] as Input[]) : []
46 const picked = answers
47 .map(answer => [...((answer['selections'] as string[] | undefined) ?? []), str(answer['other'])].filter(Boolean).join(', '))
48 .join(' / ')
49 return `answer ${where(input)}: ${picked || '(custom)'}`
50 }
51 default:
52 return `${action} ${where(input)}`
53 }
54}
55
56export function CallRow({ Box, Text }: Draw, input: Input, state: { isRunning: boolean; isErrored: boolean }) {
57 const blocking = input['blocking'] !== false && ['spawn', 'assign', 'answer'].includes(str(input['action']) ?? '')
58 return (
59 <Box>
60 <Text bold color="claude">shepherdr </Text>
61 <Text>{callSummary(input)}</Text>
62 {state.isRunning && <Text dimColor>{blocking ? ' waiting for the worker…' : ' …'}</Text>}
63 </Box>
64 )
65}
66
67type Report = { status: string; target: string; name?: string; machine?: string; body: string }
68
69// Attributes are quoted and escaped (fleet.ts reportText), so a quoted value
70// may hold anything but a quote.
71const REPORT = /<agent_report((?:\s+[a-z_]+="[^"]*")*)>\n?([\s\S]*?)\n?<\/agent_report>/g
72
73const unescape = (value: string) =>
74 value.replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&')
75
76/** The agent_report blocks a text holds, in order. */
77export function parseReports(text: string): Report[] {
78 return [...text.matchAll(REPORT)].map(([, attributes = '', body = '']) => {
79 const value = (name: string) => {
80 const found = attributes.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1]
81 return found === undefined ? undefined : unescape(found)
82 }
83 return {
84 status: value('status') ?? 'done',
85 target: value('target') ?? '',
86 ...(value('name') ? { name: value('name') } : {}),
87 ...(value('machine') ? { machine: value('machine') } : {}),
88 body: body.replaceAll('<\\/agent_report>', '</agent_report>'),
89 }
90 })
91}
92
93const STATUS_COLOR: Record<string, string> = {
94 done: 'success',
95 failed: 'error',
96 question: 'warning',
97 blocked: 'warning',
98 interrupted: 'warning',
99 closed: 'subtle',
100}
101
102type Prompt = { title: string; body?: string; multiple: boolean; choices: { label: string; description?: string }[] }
103
104/** An Ask's prompts with everything needed to choose: body, choices and their descriptions. */
105function prompts({ Box, Text }: Draw, list: readonly Prompt[], askId?: string) {
106 return (
107 <Box flexDirection="column">
108 {list.map(prompt => (
109 <Box flexDirection="column">
110 <Text>
111 <Text bold>{prompt.title}</Text>
112 {prompt.multiple && <Text dimColor> (several)</Text>}
113 </Text>
114 {prompt.body && <Text>{prompt.body}</Text>}
115 {prompt.choices.map(choice => (
116 <Text>
117 {` · ${choice.label}`}
118 {choice.description && <Text dimColor>{` ${choice.description}`}</Text>}
119 </Text>
120 ))}
121 </Box>
122 ))}
123 {askId && <Text dimColor>{`ask_id ${askId}`}</Text>}
124 </Box>
125 )
126}
127
128/** A question's body is its JSON line and a hint. */
129function questionBody(draw: Draw, body: string) {
130 const [json = '', ...hint] = body.split('\n').filter(line => !line.startsWith('Task: '))
131 try {
132 const ask = JSON.parse(json) as { ask_id?: string; prompts?: Prompt[]; question?: string }
133 return (
134 <draw.Box flexDirection="column">
135 {ask.question && <draw.Text>{ask.question}</draw.Text>}
136 {prompts(draw, ask.prompts ?? [], ask.ask_id)}
137 <draw.Text dimColor>{hint.join(' ')}</draw.Text>
138 </draw.Box>
139 )
140 } catch {
141 return <draw.Text>{body}</draw.Text>
142 }
143}
144
145export function ReportRows(draw: Draw, reports: readonly Report[]) {
146 const { Box, Text, Markdown } = draw
147 return (
148 <Box flexDirection="column">
149 {reports.map(report => {
150 const lines = report.body.split('\n')
151 const task = lines.find(line => line.startsWith('Task: '))
152 const rest = lines.filter(line => line !== task).join('\n').trim()
153 return (
154 <Box flexDirection="column" marginBottom={reports.length > 1 ? 1 : 0}>
155 <Text>
156 <Text bold color={STATUS_COLOR[report.status] ?? 'text'}>{report.status}</Text>
157 <Text>{` ${report.name ?? report.target}`}</Text>
158 <Text dimColor>{` ${report.target}${report.machine ? ` on ${report.machine.slice(0, 8)}` : ''}`}</Text>
159 </Text>
160 {task && <Text dimColor>{firstLine(task, 120)}</Text>}
161 {report.status === 'question' ? questionBody(draw, rest) : rest ? <Markdown text={rest} /> : null}
162 </Box>
163 )
164 })}
165 </Box>
166 )
167}
168
169/** Reports the mod pushed into the session as a prompt of their own. */
170export function PushedReports(draw: Draw, reports: readonly Report[]) {
171 const { Box, Text } = draw
172 return (
173 <Box flexDirection="column">
174 <Text bold color="claude">shepherdr report</Text>
175 {ReportRows(draw, reports)}
176 </Box>
177 )
178}
179
180type Agent = { target: string; name?: string; kind: string; status: string; cwd?: string; machine?: string; machine_name?: string; watched?: boolean }
181
182/** A result the call's own JSON line describes; undefined draws the engine's row. */
183export function ResultRow(draw: Draw, output: string) {
184 const { Box, Text } = draw
185 const reports = parseReports(output)
186 const head = output.split('\n')[0] ?? ''
187 let value: Input | undefined
188 try {
189 value = head.startsWith('{') ? (JSON.parse(head) as Input) : undefined
190 } catch {
191 value = undefined
192 }
193 if (reports.length && !value) return ReportRows(draw, reports)
194 if (!value) return undefined
195 const line = (text: string) => <Text dimColor>{text}</Text>
196 // An answer's status, then the worker's settlement when it waited for one.
197 if ('ask_id' in value)
198 return (
199 <Box flexDirection="column">
200 {line(`answer ${String(value['status'])}${value['input_sent'] === false ? ' (already recorded; nothing sent)' : ''}`)}
201 {reports.length > 0 && ReportRows(draw, reports)}
202 </Box>
203 )
204 if (Array.isArray(value['agents'])) {
205 const agents = value['agents'] as Agent[]
206 const unavailable = (value['unavailable'] as { machine_name?: string; machine?: string; error: string }[] | undefined) ?? []
207 return (
208 <Box flexDirection="column">
209 {agents.length === 0 && line('no agents')}
210 {agents.map(agent => (
211 <Text>
212 <Text color={agent.status === 'working' ? 'claude' : agent.status === 'blocked' ? 'warning' : 'text'}>{agent.status.padEnd(8)}</Text>
213 <Text>{` ${agent.name ?? agent.target}`}</Text>
214 <Text dimColor>{` ${agent.target} ${agent.kind}${agent.machine ? ` on ${agent.machine_name ?? ''} (${agent.machine})` : ''}${agent.watched ? ' · watched' : ''}${agent.cwd ? ` ${agent.cwd}` : ''}`}</Text>
215 </Text>
216 ))}
217 {unavailable.map(entry => (
218 <Text color="error">{`${entry.machine_name ?? entry.machine ?? 'this machine'}: ${entry.error}`}</Text>
219 ))}
220 </Box>
221 )
222 }
223 if (Array.isArray(value['commands']))
224 return line((value['commands'] as { name: string }[]).map(command => `/${command.name}`).join(' '))
225 // Help is reference text: the default row keeps all of it.
226 if (Array.isArray(value['profiles'])) return undefined
227 if ('watching' in value) return line(`${value['watching'] ? 'watching' : 'stopped watching'} ${String(value['target'])}`)
228 if ('commandSubmitted' in value) return line(`command sent to ${String(value['target'])} (${String(value['delivery'])})`)
229 if ('delivery' in value)
230 return line(
231 `${value['status'] === 'working' ? 'working in the background' : 'delivered'} · ${String(value['name'] ?? value['target'])} ${String(value['target'])} (${String(value['delivery'])})`,
232 )
233 if ('reply' in value || 'text' in value || 'ask' in value) {
234 const agent = value['agent'] as Agent | undefined
235 const ask = value['ask'] as { toolCallId: string; prompts: Prompt[] } | undefined
236 return (
237 <Box flexDirection="column">
238 {line(`${agent?.name ?? agent?.target ?? ''} ${agent?.status ?? ''}`)}
239 {typeof value['reply'] === 'string' && <draw.Markdown text={value['reply']} />}
240 {typeof value['text'] === 'string' && <Text>{value['text']}</Text>}
241 {ask && <Text color="warning">pending question</Text>}
242 {ask && prompts(draw, ask.prompts, ask.toolCallId)}
243 </Box>
244 )
245 }
246 return undefined
247}
248hooks/worker.ts 57 lines1// The worker side, in a Claude Code session another Shepherdr controls: its
2// open AskUserQuestion takes the controller's `answer` in place of the dialog,
3// whichever comes first. Claude Code records a question only once settled, so
4// the hook publishes the open one where the controller's bridge reads it.
5
6export type AnswerPayload = {
7 answers: Record<string, string>
8 annotations?: Record<string, { notes: string }>
9}
10
11// The whole message inside Claude Code's cross-session wrapper, so a task
12// quoting a frame stays a task. The controller escapes `<` in the JSON, so its
13// body never holds the closing tag.
14const FRAME =
15 /^\s*(?:<cross-session-message\b[^>]*>\s*)?<shepherdr_answer ask="([^"]+)">([^<]*)<\/shepherdr_answer>\s*(?:<\/cross-session-message>\s*)?$/
16
17const waiting = new Map<string, (payload: AnswerPayload) => void>()
18
19function isPayload(value: unknown): value is AnswerPayload {
20 if (!value || typeof value !== 'object') return false
21 const { answers } = value as { answers?: unknown }
22 return (
23 !!answers && typeof answers === 'object' && Object.values(answers).every(answer => typeof answer === 'string')
24 )
25}
26
27/** Whether `text` is a controller's answer frame; one for an open question settles it. */
28export function takeAnswer(text: string): 'answered' | 'closed' | undefined {
29 const match = FRAME.exec(text)
30 if (!match) return undefined
31 let payload: unknown
32 try {
33 payload = JSON.parse(match[2]!)
34 } catch {
35 return 'closed'
36 }
37 const settle = waiting.get(match[1]!)
38 if (!settle || !isPayload(payload)) return 'closed'
39 settle(payload)
40 return 'answered'
41}
42
43/** Resolves when a controller answers the question; `cancel` once the dialog settled it instead. */
44export function awaitAnswer(toolUseId: string): { answered: Promise<AnswerPayload>; cancel: () => void } {
45 let settle!: (payload: AnswerPayload) => void
46 const answered = new Promise<AnswerPayload>(resolve => {
47 settle = resolve
48 })
49 waiting.set(toolUseId, settle)
50 return {
51 answered,
52 cancel: () => {
53 if (waiting.get(toolUseId) === settle) waiting.delete(toolUseId)
54 },
55 }
56}
57