SLOPSHOPPER

shepherdr

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

newrowsguardcommandtoaststatus
v0.2.5no licenseupdated 2026-10-16ramtinJ95/claude-mods/plugins/shepherdr
A shopper browsing a rack in a slop shop
README

shepherdr

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.

Install

/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.

What the agent gets

One tool, agents. Called with {} it returns the available profiles, request shapes and rules.

ActionResult
listAgents 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
spawnStart a profiled Pi or Claude Code worker in a new tab (or new_workspace, or an existing pane) and give it a task
assignGive an existing Pi or Claude Code agent a task whose result is needed
sendDeliver a message without waiting or watching
answerAnswer a worker's question (Pi Ask or Claude Code's AskUserQuestion) by its ask_id
readThe worker's latest reply, or its visible or recent terminal output
watch / unwatchStart 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 workers

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 machines

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.

Orchestration mode

/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.

Status

/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

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.

Sessions and storage

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.

Source 6 files
hooks/register.ts 358 lines
1import 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}
358
hooks/bridge.ts 183 lines
1// 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}
183
hooks/contract.ts 186 lines
1// 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}
186
hooks/fleet.ts 502 lines
1// 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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
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}
502
hooks/render.tsx 248 lines
1// 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('&quot;', '"').replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&')
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}
248
hooks/worker.ts 57 lines
1// 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