/** * Shepherd tool — the model-facing `shepherd` tool for the parent pi session. * * One tool surface: * - spawn/prompt/watch/status/read/close/prune — manage pi agents living in * Herdr panes (machinery in herdr.ts). * * Registered by index.ts in the parent session only. Launched agents get the * in-tab completion extension from shepherd-done.ts instead. */ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { AgentToolResult } from '@earendil-works/pi-agent-core'; import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { StringEnum } from '@earendil-works/pi-ai'; import { Box, Text } from '@earendil-works/pi-tui'; import { Type, type Static } from 'typebox'; import { AgentScopeSchema, SpawnParams, LifecycleDelegateParams, LifecycleMessageParams, LifecyclePromptParams, WatchParams, LifecycleStatusParams, LifecycleCloseParams, TaskIdSchema, PromptIdSchema, } from '../core/types.ts'; import { startAgent, delegateAgent, promptAgent, statusAgent, closeAgent, promptWatcherService, configurePromptWatcherNotifications, taskWatcherService, configureTaskWatcherNotifications, configureStaleWaitNotifications, type StaleWaitInfo, sendParentMessage, configureParentMessageNotifications, } from '../core/lifecycle.ts'; import { fieldnotesEnabled, loadSettings } from './config.ts'; import { lifecycleRegistry, LifecycleError } from '../core/orchestration.ts'; import { formatShepherdCommand, omitMaterializedDefaults } from './cli.ts'; import { resolveOrCreateParentArtifactSession, type ShepherdSession, } from '../core/artifact-sessions.ts'; import type { AgentThinkingLevel, DelegatorModel } from '../core/discovery.ts'; import { discoverAgents, formatAgentList } from '../core/discovery.ts'; import { HERDR_SETUP_HINT, agentSummaries, createHerdrTab, formatSummary, getHerdrWorkspaceId, herdrExec, herdrExecSync, isHerdrAvailable, launchPiInPane, loadCreatedPanes, paneExists, paneIdOf, pruneStaleCreatedPanes, readPaneTail, recordCreatedPane, removeCreatedPaneDir, setCreatedPaneDir, waitForHerdrAgentDetected, waitForHerdrShellReady, } from '../core/herdr.ts'; const execFileAsync = promisify(execFile); export const SourceSchema = StringEnum( ['visible', 'recent', 'recent-unwrapped', 'detection'] as const, { description: 'Terminal snapshot source for read', default: 'recent-unwrapped', } ); const HerdParams = Type.Object({ action: Type.Literal('herd', { description: 'List the live herd: agents detected in Herdr panes.', }), }); const AgentsParams = Type.Object({ action: Type.Literal('agents', { description: 'List all available agents (also called sheep) and their source metadata.', }), agentScope: Type.Optional(AgentScopeSchema), }); const ReadParams = Type.Object({ action: Type.Literal('read', { description: 'Read recent terminal output from an agent or pane.', }), name: Type.String({ description: 'Agent name, Herdr pane id, or opaque agent id of the target.', }), lines: Type.Optional( Type.Integer({ description: 'Number of recent lines for read (default 40)', default: 40 }) ), source: Type.Optional(SourceSchema), }); const PruneParams = Type.Object({ action: Type.Literal('prune', { description: 'Remove stale pi-shepherd pane registrations.' }), }); /** Parameters for the umbrella control-plane tool. Lifecycle operations have * separate flat schemas and registered tools below. */ const AnyShepherdUnion = Type.Union( [ HerdParams, AgentsParams, SpawnParams, LifecycleDelegateParams, LifecycleMessageParams, LifecyclePromptParams, WatchParams, LifecycleStatusParams, LifecycleCloseParams, ReadParams, PruneParams, ], { description: 'Action-discriminated shepherd commands for managing specialized agents (also called sheep), their fieldnotes (artifacts), and their Herdr panes.', } ); export type ShepherdArgs = Static; function prepareForSchema(input: unknown): T { return prepareShepherdArguments(input) as unknown as T; } /** * Some model/provider tool-call transports encode nested JSON values as * strings. Pi validates arguments after this hook, so normalize only known * transport-serialization details here. The public lifecycle protocol uses * opaque id strings; the legacy `handle` form is accepted here temporarily so * existing callers fail soft while migrating. */ export function prepareShepherdArguments(input: unknown): ShepherdArgs { if (!input || typeof input !== 'object' || Array.isArray(input)) return input as ShepherdArgs; const args = { ...(input as Record) }; // Compatibility for transcripts produced before lifecycle calls switched to // scalar ids. Convert `{ handle: { id: ... } }` (or its JSON encoding) into // the current `{ id: ... }` form before schema validation. if (!('id' in args) && 'handle' in args) { let legacy: unknown = args.handle; if (typeof legacy === 'string') { try { legacy = JSON.parse(legacy); } catch { // A plain string is already a usable legacy id. } } if (Array.isArray(legacy)) { args.id = legacy.map(item => item && typeof item === 'object' && typeof (item as any).id === 'string' ? (item as any).id : item ); } else if (legacy && typeof legacy === 'object' && typeof (legacy as any).id === 'string') { args.id = (legacy as any).id; } else { args.id = legacy; } delete args.handle; } // These values are deliberately not part of the spawn protocol. Settings // and the discovered agent definition own them; silently discard legacy // callers' copies so they cannot override those sources before validation. for (const name of ['agentScope', 'confirmProjectAgents', 'omitSystemPrompt', 'direction']) { delete args[name]; } if (typeof args.id === 'string' && args.id.trim().startsWith('[')) { try { const parsed = JSON.parse(args.id); if (Array.isArray(parsed)) args.id = parsed; } catch { // Leave malformed values untouched for normal schema validation. } } for (const name of ['timeout', 'lines']) { const value = args[name]; if (typeof value === 'string' && /^[-+]?\d+$/.test(value.trim())) { args[name] = Number(value.trim()); } } return args as ShepherdArgs; } function unavailableResult( action?: string, args?: ShepherdArgs ): AgentToolResult> { return { content: [ { type: 'text', text: `Herd requires a running Herdr session.\n${HERDR_SETUP_HINT}` }, ], details: { ...(action && args ? { call: publicToolCall(action, args) } : {}), code: 'herdr_unavailable', returnCode: 1, returnValue: { code: 'herdr_unavailable', error: 'herdr not available', returnCode: 1 }, error: 'herdr not available', }, }; } function textResult( text: string, details: Record ): AgentToolResult> { return { content: [{ type: 'text' as const, text }], // Every completed Shepherd operation exposes its return value and a // process-style return code. Individual operations can override these for // a structured/failed/partial result. details: { returnValue: text, returnCode: 0, ...details }, }; } /** Keep the opaque lifecycle id easy to copy from model-visible tool text. */ export function formatIdForModel(id: string): string { return id; } /** Keep the exact public tool invocation visible alongside its result. */ function publicToolCall( action: string, args: ShepherdArgs, defaultCwd = process.cwd() ): Record { const { action: _action, artifactSession: _artifactSession, ...parameters } = args as any; const name = ['herd', 'agents', 'prune'].includes(action) ? 'shepherd' : `shepherd_${action}`; return { name, arguments: omitMaterializedDefaults(action, parameters, defaultCwd) }; } function displayAgentName(agentId: string): string { try { const handle = lifecycleRegistry.getAgent({ id: agentId }).handle; return handle.label ? `${handle.agent}: ${handle.label}` : handle.agent; } catch { return agentId; } } function lifecycleHandleForPane(paneId: unknown): any | undefined { if (typeof paneId !== 'string' || !paneId) return undefined; return lifecycleRegistry.allAgents().find(handle => handle.paneId === paneId); } function shepherdHerdAgents(agents: unknown[]): any[] { return agents .filter(agent => agent && typeof agent === 'object' && (agent as any).shepherd === true) .map(agent => { const record = agent as Record; const handle = lifecycleHandleForPane(record.paneId); const visible: Record = handle ? { agentId: handle.id, agent: handle.agent, ...(handle.label ? { label: handle.label } : {}), } : { agentId: record.focused === true ? 'shepherd' : record.name }; return { ...visible, state: record.state, ...(record.focused === true ? { focused: true } : {}), }; }); } function formatHerdAgentList(agents: unknown[], indent = ' '): string[] { return agents.flatMap((agent, index) => { if (!agent || typeof agent !== 'object' || Array.isArray(agent)) { return [`${indent}${formatHumanScalar(agent)}`]; } const entries = Object.entries(agent); if (entries.length === 0) return [`${indent}agent id: unknown`]; const [[firstKey, firstValue], ...rest] = entries; const firstLines = formatHumanField(humanizeKey(firstKey), firstValue, indent); return [ ...(index > 0 ? [''] : []), ...firstLines, ...rest.flatMap(([key, value]) => formatHumanField(humanizeKey(key), value, `${indent} `)), ]; }); } function formatToolResultText(result: any): string | undefined { const body = result?.content?.[0]?.type === 'text' ? (result.content[0].text ?? '') : undefined; const details = result?.details && typeof result.details === 'object' ? result.details : {}; const call = details.call; if (!call?.name) return body; // executeShepherd embeds the structured text for non-TUI/API callers. Do // not append a second call/return/details block when the renderer sees it. if (typeof body === 'string' && body.includes('\ncall:\n')) return body; const returnValue = details.returnValue ?? details.result; const visibleDetails = Object.entries(details) .filter( ([key]) => ![ 'call', 'agent', 'label', 'model', 'status', 'artifactSession', 'returnValue', 'result', ].includes(key) ) .sort(([left], [right]) => Number(left === 'returnCode') - Number(right === 'returnCode')) .map(([key, value]) => { let displayKey = key.replace(/[A-Z]/g, letter => ` ${letter.toLowerCase()}`); if (key === 'id' && call.name === 'shepherd_spawn') displayKey = 'agent id'; if (key === 'fieldnote' && call.name === 'shepherd_spawn') displayKey = 'agent fieldnote'; const displayValue = value === null && key === 'fieldnote' ? 'none' : typeof value === 'string' ? value : JSON.stringify(value); return ` ${displayKey}: ${displayValue ?? 'null'}`; }); const callText = `${call.name} ${JSON.stringify(call.arguments ?? {})}`; const renderedReturn = formatReturnValue(returnValue); return [ body ?? '(no output)', '', 'call:', ` ${callText}`, '', 'return:', ` ${renderedReturn}`, ...(visibleDetails.length ? ['', 'details:', ...visibleDetails] : []), ].join('\n'); } function formatReturnValue(value: unknown): string { if (typeof value === 'string') return value; if (Array.isArray(value)) { const items = value.map(item => JSON.stringify(item)); return `[${items.join(',\n ')}]`; } return JSON.stringify(value ?? null); } function withToolResultText( result: AgentToolResult> ): AgentToolResult> { const text = formatToolResultText(result); return text === undefined ? result : { ...result, content: [{ type: 'text', text }] }; } function reusableText(lastComponent: unknown): Text { return lastComponent instanceof Text ? lastComponent : new Text('', 0, 0); } /** * Render custom Shepherd notifications from their structured `details` payload. * The message content intentionally remains the detailed protocol text for the * model/API; this formatter is only used by the human-facing message renderer. */ function renderShepherdNotification( message: any, options: { expanded?: boolean; outputPad?: number }, theme: any, formatted?: string ): Box { const content = formatted ?? notificationFallbackText(message); const collapsed = options.expanded !== true; const renderedContent = collapsed ? formatCollapsedNotification(message, content) : content; const lines = renderedContent.split('\n'); const title = lines.shift() ?? 'Shepherd notification'; const renderedRemainder = collapsed ? lines .map(line => { if (/^✓/.test(line)) return theme.fg('success', line); if (/^✗/.test(line)) return theme.fg('error', line); if (/^⚠/.test(line)) return theme.fg('warning', line); return theme.fg('toolOutput', line); }) .join('\n') : styleExpandedToolResult( lines.join('\n'), theme, message?.details?.messageId ? { boldFields: ['message'] } : undefined ); const titleParts = title.split(/\s+/); const titleVerb = titleParts.shift() ?? 'Shepherd'; const titleArgs = titleParts.join(' '); const renderedTitle = theme.fg('toolTitle', theme.bold(titleVerb)) + (titleArgs ? ` ${theme.fg('accent', titleArgs)}` : ''); const rendered = renderedTitle + (lines.length ? `\n${renderedRemainder}` : ''); const box = new Box(options.outputPad ?? 0, 1, (text: string) => theme.bg('customMessageBg', text) ); box.addChild(new Text(rendered, 0, 0)); return box; } /** Compact custom notifications the same way collapsed tool results show only * their useful summary. Ctrl+O still exposes the structured notification. */ function compactNotificationText(value: unknown, maxLength = 160): string { const text = String(value ?? '') .replace(/\s+/g, ' ') .trim(); if (text.length <= maxLength) return text; return `${text.slice(0, maxLength - 1).trimEnd()}…`; } export function formatCollapsedNotification(message: any, formatted: string): string { const lines = formatted.split('\n'); const title = lines[0] ?? 'Shepherd notification'; const details = message?.details; if (details?.messageId) { const text = compactNotificationText(details.content); return text ? `${title}\n${text}` : title; } if (Array.isArray(details?.completions)) { const ids = details.taskIds ?? details.promptIds ?? details.completions .map((completion: any) => completion.taskId ?? completion.promptId) .filter((id: unknown): id is string => typeof id === 'string'); const target = Array.isArray(ids) && ids.length > 0 ? ids.join(', ') : 'completion'; const failed = details.completions.some( (completion: any) => !['completed', 'done'].includes(completion.status) || (completion.returnCode !== undefined && completion.returnCode !== 0) ); return [`shepherd_watch ${target}`, `${failed ? '✗ failed' : '✓ success'}`].join('\n'); } if (details?.taskId) return `${title}: ${details.taskId}`; return title; } function notificationFallbackText(message: any): string { const content = typeof message?.content === 'string' ? message.content : ''; const marker = content.indexOf('\n\ncall:\n'); return marker >= 0 ? content.slice(0, marker) : content; } export function formatParentMessageNotification( envelope: any, customType?: string ): string | undefined { if (!envelope || typeof envelope !== 'object' || !envelope.messageId) return undefined; const isReply = envelope.kind === 'reply' || customType === 'shepherd.message.reply'; const sender = displayAgentName(String(envelope.senderId ?? envelope.from ?? 'unknown')); const lines = [`Shepherd ${isReply ? 'reply' : 'message'} from ${sender}`]; const metadata: string[] = []; for (const [label, value] of [ ['message id', envelope.messageId], ['task id', envelope.taskId], ['thread id', envelope.threadId], ['reply to', envelope.replyTo], ] as const) { if (value !== undefined && value !== null && value !== '') { metadata.push(...formatHumanField(label, value, '')); } } if (metadata.length) lines.push('', ...metadata); lines.push('', ...formatHumanField('message', String(envelope.content ?? ''), '')); return lines.join('\n'); } function compactWatcherCompletion(completion: any): Record { if (!completion || typeof completion !== 'object') return { value: completion }; const keys = [ 'taskId', 'promptId', 'agentId', 'agent', 'label', 'status', 'ok', 'returnCode', 'text', 'error', 'completedAt', ]; return Object.fromEntries( keys .filter( key => completion[key] !== undefined && completion[key] !== null && completion[key] !== '' ) .map(key => [key, completion[key]]) ); } export function formatWatcherNotification( details: any, kind: 'task' | 'prompt' ): string | undefined { if (!details || typeof details !== 'object' || !Array.isArray(details.completions)) return undefined; const lines = ['Shepherd watcher']; const idLabel = kind === 'task' ? 'task ids' : 'prompt ids'; const ids = kind === 'task' ? details.taskIds : details.promptIds; if (details.watcherId) lines.push(...formatHumanField('watcher id', details.watcherId, '')); if (Array.isArray(ids)) lines.push(...formatHumanField(idLabel, ids, '')); // Completion artifacts are durable storage metadata, not watcher output. // Keep the expanded notification focused on operational fields. lines.push( ...formatHumanField('completions', details.completions.map(compactWatcherCompletion), '') ); return lines.join('\n'); } export function formatStaleWaitNotification(info: any): string | undefined { if (!info || typeof info !== 'object' || !info.taskId) return undefined; const owner = info.label ? `${info.agent ?? info.agentId}: ${info.label}` : (info.agent ?? info.agentId); const recipient = info.recipientName ? `${info.recipientName}${info.recipientState ? ` (${info.recipientState})` : ''}` : undefined; const lines = ['Shepherd stale wait']; if (info.elapsedMs !== undefined) { lines.push( ...formatHumanField( 'waiting', `${formatElapsedMs(info.elapsedMs)} (stale after ${info.thresholdMinutes} min)`, '' ) ); } lines.push(...formatHumanField('task id', info.taskId, '')); if (owner) lines.push(...formatHumanField('owner', owner, '')); if (info.description) lines.push(...formatHumanField('description', info.description, '')); if (info.question) lines.push(...formatHumanField('question', info.question, '')); if (info.requestMessageId) { const pending = recipient ? `${info.requestMessageId} (waiting on ${recipient})` : info.requestMessageId; lines.push(...formatHumanField('pending request', pending, '')); } lines.push( ...formatHumanField( 'actions', [ "Reply to the recipient on the owner's behalf via shepherd_message (set replyTo to the pending request).", `Or nudge ${recipient ?? 'the target agent'} with shepherd_message (targetId = recipient).`, "Or let the task's reply deadline settle it as blocked (shepherd_delegate timeout).", ].join('\n'), '' ) ); return lines.join('\n'); } let watcherParentSessionActive = true; /** Enable/disable delivery without changing the core watcher's state model. */ export function setPromptWatcherSessionActive(active: boolean): void { watcherParentSessionActive = active; } let messageParentSessionActive = true; /** Enable/disable message delivery without changing the core service. */ export function setShepherdMessageSessionActive(active: boolean): void { messageParentSessionActive = active; } function registerPromptCompletionRenderer(pi: ExtensionAPI): void { pi.registerMessageRenderer('shepherd.prompt.completion', (message, options, theme) => renderShepherdNotification( message, options, theme, formatWatcherNotification(message?.details, 'prompt') ) ); } /** Enable/disable task-watcher delivery without changing the core service's state model. */ let taskWatcherParentSessionActive = false; export function setTaskWatcherSessionActive(active: boolean): void { taskWatcherParentSessionActive = active; } /** Enable/disable stale-wait delivery without changing the core monitor's state model. */ let staleWaitParentSessionActive = false; export function setStaleWaitSessionActive(active: boolean): void { staleWaitParentSessionActive = active; } function formatElapsedMs(ms: number): string { const s = Math.max(0, Math.floor(ms / 1000)); const m = Math.floor(s / 60); const r = s % 60; return m > 0 ? `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}` : `${s}s`; } function registerStaleWaitRenderer(pi: ExtensionAPI): void { pi.registerMessageRenderer('shepherd.stale.wait', (message, options, theme) => renderShepherdNotification( message, options, theme, formatStaleWaitNotification(message?.details) ) ); } function configureStaleWaitBridge(pi: ExtensionAPI): void { registerStaleWaitRenderer(pi); configureStaleWaitNotifications(info => { if (!staleWaitParentSessionActive) return; const owner = info.label ? `${info.agent ?? info.agentId}: ${info.label}` : (info.agent ?? info.agentId); const recipient = info.recipientName ? `${info.recipientName}${info.recipientState ? ` (${info.recipientState})` : ''}` : 'the target agent'; const body = [ `Waiting ${formatElapsedMs(info.elapsedMs)} for a reply (stale after ${info.thresholdMinutes} min).`, `Task: ${info.taskId}`, `Owner: ${owner} - ${info.description}`, `Question: ${info.question}`, `Pending request: ${info.requestMessageId} (waiting on ${recipient})`, ] .filter(Boolean) .join('\n'); const actions = [ "Reply to the recipient on the owner's behalf via shepherd_message (set replyTo to the pending request).", `Or nudge ${recipient} with shepherd_message (targetId = recipient).`, "Or let the task's reply deadline settle it as blocked (shepherd_delegate timeout).", ].join('\n'); const content = formatToolResultText({ content: [{ type: 'text' as const, text: body }], details: { call: { name: 'shepherd_message (possible follow-up)', arguments: { taskId: info.taskId, replyTo: info.requestMessageId }, }, taskId: info.taskId, agentId: info.agentId, elapsedMs: info.elapsedMs, requestMessageId: info.requestMessageId, action: actions, returnValue: info, }, }); try { const sendResult: any = pi.sendMessage( { customType: 'shepherd.stale.wait', content, display: true, details: info, }, { deliverAs: 'followUp', triggerTurn: false } ); if (sendResult && typeof sendResult.catch === 'function') { sendResult.catch(() => undefined); } } catch { // Delivery is best effort; the reply deadline is the authoritative outcome. } }); } function registerTaskCompletionRenderer(pi: ExtensionAPI): void { pi.registerMessageRenderer('shepherd.task.completion', (message, options, theme) => renderShepherdNotification( message, options, theme, formatWatcherNotification(message?.details, 'task') ) ); } /** * Bridge from core TASK watcher completions to the parent Pi session. A task * completion is a terminal outcome (shepherd_done or an explicit * failure/cancellation/timeout), so it always triggers a parent turn to process * the result — matching the prompt bridge's delivery policy. * * Delivery uses `steer` rather than `followUp`: pi only delivers queued * followUp messages when the parent run has no more tool calls at all, so a * completion observed while the Shepherd is mid-turn (the normal async-watch * flow) would otherwise sit queued until the whole turn ends — after cleanup * tool calls such as shepherd_close. Steer surfaces the completion between * tool rounds, and `triggerTurn` still fires immediately when the parent is * idle. */ function configureTaskWatcherBridge(pi: ExtensionAPI): void { registerTaskCompletionRenderer(pi); configureTaskWatcherNotifications(notification => { if (!taskWatcherParentSessionActive) return; const summary = notification.completions .map(completion => { const identity = completion.label ? `${completion.agent ?? completion.agentId}: ${completion.label}` : (completion.agent ?? completion.agentId); return `${identity} ${completion.status}`; }) .join(', '); const returnCode = notification.completions.find(completion => completion.returnCode !== 0)?.returnCode ?? 0; // Keep the model-visible notification compact. The structured payload is // retained in `details` for the expanded custom renderer and consumers. const content = `shepherd_watcher completion${notification.completions.length === 1 ? '' : 's'}: ${summary}`; try { const sendResult: any = pi.sendMessage( { customType: 'shepherd.task.completion', content, display: true, details: notification, }, { deliverAs: 'steer', triggerTurn: true } ); if (sendResult && typeof sendResult.catch === 'function') { sendResult.catch(() => undefined); } } catch { // Delivery is best effort. The completion is already durable in the task // registry and can be retrieved via shepherd_status. } }); } /** Narrow extension-owned bridge from core watcher completions to pi. * Also wires child-originated messages (Phase 6) into the parent session. */ export function parentMessageDeliveryOptions(message: { kind: string; expectsReply?: boolean }): { deliverAs: 'followUp' | 'steer'; triggerTurn: boolean; } { // Ordinary child notifications remain passive, but a request or reply is a // decision point: the parent must get a turn to answer or resume waiting // work. Wake-ups use `steer` so pi surfaces them between tool rounds while // the Shepherd is mid-turn (followUp would queue until the whole run ends, // after cleanup calls such as shepherd_close); when the parent is idle, // `triggerTurn` still starts a turn immediately. const wake = message.kind === 'reply' || message.expectsReply === true; return { deliverAs: wake ? 'steer' : 'followUp', triggerTurn: wake, }; } function configurePromptWatcherBridge(pi: ExtensionAPI): void { registerPromptCompletionRenderer(pi); registerShepherdMessageRenderer(pi); configurePromptWatcherNotifications(notification => { if (!watcherParentSessionActive) return; const summary = notification.completions .map(completion => { const identity = completion.label ? `${completion.agent ?? completion.agentId}: ${completion.label}` : (completion.agent ?? completion.agentId); return `${identity} ${completion.status}`; }) .join(', '); const returnCode = notification.completions.find(completion => completion.returnCode !== 0)?.returnCode ?? 0; // Keep the model-visible notification compact. The structured payload is // retained in `details` for the expanded custom renderer and consumers. const content = `shepherd_watcher completion${notification.completions.length === 1 ? '' : 's'}: ${summary}`; try { const sendResult: any = pi.sendMessage( { customType: 'shepherd.prompt.completion', content, display: true, details: notification, }, { deliverAs: 'steer', triggerTurn: true } ); if (sendResult && typeof sendResult.catch === 'function') { sendResult.catch(() => undefined); } } catch { // Delivery is best effort. The completion remains in the lifecycle // prompt registry and can still be retrieved by shepherd_watch. } }); configureParentMessageNotifications(notification => { if (!messageParentSessionActive) return; const { envelope } = notification; if (envelope.kind === 'runtime') return; // task-state mirror only; never a user-facing message const sender = displayAgentName(envelope.senderId); const title = envelope.kind === 'reply' ? 'Shepherd reply' : 'Shepherd message'; // Keep the notification fallback readable too: the custom renderer uses // `details` for collapsed/expanded views, while the message content keeps // the sender heading and full message on separate lines for other clients. const content = `${title} from ${sender}\n${String(envelope.content ?? '')}`; try { const sendResult: any = pi.sendMessage( { customType: envelope.kind === 'reply' ? 'shepherd.message.reply' : 'shepherd.message.incoming', content, display: true, details: envelope, }, parentMessageDeliveryOptions(envelope) ); if (sendResult && typeof sendResult.catch === 'function') { sendResult.catch(() => undefined); } } catch { // Delivery is best effort; the envelope remains in the parent's // processed storage and the task state is already reconciled. } }); } function registerShepherdMessageRenderer(pi: ExtensionAPI): void { const render = (message: any, options: any, theme: any) => renderShepherdNotification( message, options, theme, formatParentMessageNotification(message?.details, message?.customType) ); pi.registerMessageRenderer('shepherd.message.incoming', render); pi.registerMessageRenderer('shepherd.message.reply', render); } type ShepherdContext = { cwd: string; model?: DelegatorModel; thinkingLevel?: AgentThinkingLevel; hasUI?: boolean; ui?: any; sessionManager?: { getSessionId(): string; getSessionFile?(): string | undefined }; }; function parentArtifactSession(ctx: ShepherdContext): ShepherdSession | undefined { // The setting is snapshotted when the parent pi session starts. This means // disabling fieldnotes does not change the contract of agents already // running in this session; start a new pi session to stop using them. if (!fieldnotesEnabled()) return undefined; const parentPiSessionId = ctx.sessionManager?.getSessionId(); if (!parentPiSessionId) throw new Error('Unable to resolve the parent pi session identity.'); return resolveOrCreateParentArtifactSession({ parentPiSessionId, parentSessionFile: ctx.sessionManager?.getSessionFile?.(), projectRoot: ctx.cwd, }); } export async function doAction( args: ShepherdArgs, ctx: ShepherdContext, signal?: AbortSignal, onUpdate?: (partial: AgentToolResult>) => void ): Promise>> { switch (args.action) { case 'spawn': { const a: any = args; // Explicit artifactSession wins (the command adapter pre-resolves it // tolerantly); otherwise resolve/require the parent artifact session. const artifactSession = 'artifactSession' in a ? a.artifactSession : parentArtifactSession(ctx); // Startup readiness has its own fixed internal grace periods; timeout // settings apply only to submitted prompts and their waits. Emit a // partial result so the TUI can show progress while Herdr starts the // child pane. onUpdate?.({ content: [{ type: 'text', text: `Spawning ${a.agent}…` }], details: { agent: a.agent, label: a.label, placement: a.placement, }, }); const handle = await startAgent( a.agent, { label: a.label, placement: a.placement, cwd: a.cwd, artifactSession, }, { ...ctx, sessionId: ctx.sessionManager?.getSessionId() } ); return textResult( `shepherd_spawn spawned ${handle.label ? `${handle.agent}: ${handle.label}` : handle.agent}`, { id: handle.id, agent: handle.agent, label: handle.label, model: handle.model ?? null, returnValue: { id: handle.id, agent: handle.agent, label: handle.label, model: handle.model ?? null, }, fieldnote: artifactSession?.sessionRelativePath ?? null, ...(artifactSession ? { artifactSession } : {}), } ); } case 'delegate': { const a: any = args; const timeoutMinutes = a.timeout ?? loadSettings(ctx.cwd).timeout; if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) { throw new Error('Delegated task timeout must be a positive number of minutes.'); } const task = await delegateAgent(a.target, a.task, { timeoutMs: timeoutMinutes * 60_000, sessionId: ctx.sessionManager?.getSessionId(), }); const agent = lifecycleRegistry.getAgent(task.agentId).handle; return textResult( `Delegated task to ${agent.agent}${agent.label ? `: ${agent.label}` : ''}`, { id: task.id, taskId: task.id, agentId: task.agentId, state: 'running', returnValue: { taskId: task.id, agentId: task.agentId, state: 'running', }, } ); } case 'message': { const a: any = args; const result = sendParentMessage({ target: a.target, message: a.message, taskId: a.taskId ?? a.id, threadId: a.threadId, replyTo: a.replyTo, expectsReply: a.expectsReply, delivery: a.delivery, }); return textResult( `Message queued to ${displayAgentName(result.targetId)}` + (result.targetTaskState ? ` (task ${result.targetTaskState})` : ''), { id: result.messageId, messageId: result.messageId, returnValue: { messageId: result.messageId, accepted: result.accepted, delivery: result.delivery, ...(result.requestId ? { requestId: result.requestId } : {}), targetId: result.targetId, }, ...(result.requestId ? { requestId: result.requestId } : {}), ...(result.targetTaskState ? { targetTaskState: result.targetTaskState } : {}), } ); } case 'prompt': { const a: any = args; // Convert timeout from minutes to milliseconds for internal use. // Default: from settings (20 minutes). const defaultTimeout = loadSettings(ctx.cwd).timeout; const timeoutMinutes = a.timeout ?? defaultTimeout; const timeoutMs = timeoutMinutes * 60_000; const handle = await promptAgent(a.id ?? a.handle, a.message, { timeout: timeoutMs }); const agent = lifecycleRegistry.getAgent(handle.agentId).handle; const artifact = lifecycleRegistry.promptArtifact(handle); return textResult(`Prompted ${agent.agent}${agent.label ? `: ${agent.label}` : ''}`, { id: handle.id, returnValue: { id: handle.id, ...(artifact.artifact ? { artifact: artifact.artifact } : {}), }, ...(artifact.artifact ? { artifact: artifact.artifact } : {}), ...(artifact.session ? { artifactSession: artifact.session } : {}), }); } case 'watch': { const a: any = args; const ids: string[] = Array.isArray(a.id) ? a.id : [a.id]; if (ids.length === 0) { throw new LifecycleError( 'invalid_handle', 'Expected one or more task (or legacy prompt) ids to watch.' ); } // A tracker id is a task first; anything else must be a known prompt id. // Agent ids and Herdr pane ids are rejected with a clear error here so // the model is never silently pointing a watcher at the wrong scope. const taskIds = ids.filter(id => lifecycleRegistry.isTaskId(id)); const promptIds: string[] = []; for (const id of ids) { if (taskIds.includes(id)) continue; try { lifecycleRegistry.getPrompt(id); } catch { throw new LifecycleError( 'invalid_handle', `Unknown watcher target "${id}". Pass a task id from shepherd_delegate or a legacy prompt id from shepherd_prompt; agent ids and Herdr pane ids are not valid watcher targets.` ); } promptIds.push(id); } if (taskIds.length > 0 && promptIds.length > 0) { throw new LifecycleError( 'invalid_handle', 'A single shepherd_watch call cannot mix task ids and legacy prompt ids; watch tasks in one call and prompts in another.' ); } if (taskIds.length > 0) { const registration = taskWatcherService.watch(taskIds); const summary = registration.pending.length > 0 ? `watching ${registration.pending.length} task${registration.pending.length === 1 ? '' : 's'} asynchronously` : 'watch registered; all tasks were already settled'; return textResult(summary, { watcherId: registration.watcherId, taskIds: registration.taskIds, pending: registration.pending, completed: registration.completed, returnValue: registration, }); } const registration = promptWatcherService.watch(promptIds); const summary = registration.pending.length > 0 ? `watching ${registration.pending.length} prompt${registration.pending.length === 1 ? '' : 's'} asynchronously` : 'watch registered; all prompts were already settled'; return textResult(summary, { watcherId: registration.watcherId, promptIds: registration.promptIds, pending: registration.pending, completed: registration.completed, returnValue: registration, }); } case 'status': { const a: any = args; const result = statusAgent(a.id ?? a.handle); const publicResult = { id: result.handle.id, state: result.state, ...(result.task ? { task: { id: result.task.id, state: result.task.state, ...(result.task.waitingMs !== undefined ? { waitingMs: result.task.waitingMs } : {}), ...(result.task.pendingRequestMessageId ? { pendingRequest: result.task.pendingRequestMessageId } : {}), ...(result.task.waitingRecipient ? { waitingOn: result.task.waitingRecipient } : {}), ...(result.task.stale ? { stale: true } : {}), }, } : {}), ...(result.error ? { error: result.error } : {}), }; const taskNote = result.task ? `; task ${result.task.id} ${result.task.state}` : ''; return textResult(`agent ${result.state}${taskNote}.`, { status: publicResult, returnValue: publicResult, }); } case 'close': { const a: any = args; const handle = closeAgent(a.id ?? a.handle); return textResult( `closed ${handle.label ? `${handle.agent}: ${handle.label}` : handle.agent}`, { id: handle.id, returnValue: { id: handle.id }, } ); } case 'agents': { // List available agent definitions for the shepherd's herd. const scope = args.agentScope ?? loadSettings(ctx.cwd).agentScope; const { agents, projectDirs } = discoverAgents(ctx.cwd, scope, { includeBundled: loadSettings(ctx.cwd).includeBundledAgents, }); if (agents.length === 0) return textResult( `No agent definitions found in ${scope} scope. Do not guess an agent name; add a definition or choose another scope.`, { agents: [], projectDirs, scope } ); const lines = agents.map(a => `${a.name} (${a.source}): ${a.description}`); return textResult( `Available agent names (copy the name exactly; names are case-sensitive):\n${lines.join('\n')}`, { agents, projectDirs, scope } ); } case 'herd': { // Silently drop registrations for panes that no longer exist so a // long-lived session doesn't accumulate stale entries. pruneStaleCreatedPanes(); const out = herdrExecSync(['agent', 'list']); const agents = agentSummaries(out); if (agents.length === 0) return textResult('No agents detected in Herdr.', { agents }); return textResult(agents.map(formatSummary).join('\n'), { agents }); } case 'read': { const target = args.name?.trim(); if (!target) return textResult('Provide a name/pane target (action=read).', {}); const lines = args.lines ?? 40; const source = args.source ?? 'recent-unwrapped'; // Resolve a shepherd pane by its recorded paneId or label (same as // prompt/close) so `read scout` works after a lifecycle start. const created = loadCreatedPanes(); const match = created.find(p => p.paneId === target || p.name === target); let resolved = match?.paneId ?? target; // Diagnostics are often invoked from the lifecycle result, where the // caller has the opaque agent id rather than the Herdr pane id. // Resolve that id only through our in-memory registry; never guess a // pane from an arbitrary id. if (!match) { try { resolved = lifecycleRegistry.getAgent({ id: target }).handle.paneId ?? target; } catch { // Keep the original target so Herdr returns the useful not-found // error for unknown names/panes. } } try { const { stdout } = await execFileAsync( 'herdr', [ 'agent', 'read', resolved, '--source', source, '--lines', String(lines), '--format', 'text', ], { encoding: 'utf8' } ); return textResult(stdout.trim() || '(no terminal output)', { target, lines, source }); } catch { // Agent detection is dropped once the pane's pi exited — fall back // to a plain terminal read so finished runs stay inspectable. try { const { stdout } = await execFileAsync( 'herdr', [ 'pane', 'read', resolved, '--source', source, '--lines', String(lines), '--format', 'text', ], { encoding: 'utf8' } ); return textResult(stdout.trim() || '(no terminal output)', { target, lines, source, fallback: true, }); } catch (error: any) { return { content: [ { type: 'text', text: `Could not read "${target}": ${error?.message ?? String(error)}`, }, ], details: { target, error: String(error?.message ?? error) }, }; } } } case 'prune': { const pruned = pruneStaleCreatedPanes(); const remaining = loadCreatedPanes().length; return textResult( pruned === 0 ? `No stale pi-shepherd panes found (${remaining} registered).` : `Removed ${pruned} stale pane registration(s); ${remaining} remain.`, { pruned, remaining } ); } default: return textResult(`Unknown shepherd action: ${String((args as any).action)}`, {}); } } /** * Shared execution path for every registered shepherd tool: gate on a running * Herdr, delegate to doAction() (the single source of truth), and map thrown * errors to a friendly text result. `label` is the action verb used in errors. */ async function executeShepherd( label: string, args: ShepherdArgs, ctx: any, signal?: AbortSignal, onUpdate?: (partial: AgentToolResult>) => void ): Promise>> { // Watch registration/unregistration operate on the parent lifecycle // registry and remain useful even if Herdr briefly disappears. The watcher // will resume polling when the runtime is reachable again. if (!isHerdrAvailable() && label !== 'watch') { const failure = unavailableResult(label, args); throw new Error(formatToolResultText(failure) ?? HERDR_SETUP_HINT); } try { const result = await doAction(args, ctx, signal, onUpdate); const details = result.details && typeof result.details === 'object' ? result.details : {}; return withToolResultText({ ...result, details: { call: publicToolCall(label, args, ctx.cwd), ...(details as Record), }, }); } catch (error: any) { const message = String(error?.message ?? error); const returnCode = typeof error?.returnCode === 'number' ? error.returnCode : 1; const code = typeof error?.code === 'string' ? error.code : 'shepherd_error'; const failure = withToolResultText({ content: [ { type: 'text', text: `Herd ${label} failed (return code ${returnCode}): ${message}` }, ], details: { call: publicToolCall(label, args, ctx.cwd), action: label, code, returnCode, returnValue: { code, error: message, returnCode }, error: message, }, }); // Pi colors the complete tool shell using its native error state only // when execute() throws. Preserve our structured protocol text in the // thrown message while allowing Pi to set isError=true for the TUI. throw new Error(failure.content[0]?.type === 'text' ? failure.content[0].text : message); } } export function registerShepherdTools(pi: ExtensionAPI) { configurePromptWatcherBridge(pi); configureTaskWatcherBridge(pi); configureStaleWaitBridge(pi); pi.registerTool({ name: 'shepherd', label: 'Shepherd (manage Herdr agents)', description: [ 'Shepherd control plane: subagent framework for native Herdr agent orchestration inside Herdr panes.', 'Terminology: the Shepherd is this parent pi session and acts as the orchestrator; the herd is the collection of agents; agents or subagents or sheep are the created workers.', 'When enabled, fieldnotes are the durable session notes commonly called artifacts: one shared fieldnotes collection (the shepherd.md index) links the individual note assigned to each agent invocation.', 'This tool only lists: herd (live agents in Herdr), agents (discoverable definitions), prune (drop stale registrations). All lifecycle operations are separate tools: shepherd_spawn, shepherd_delegate, shepherd_message, shepherd_prompt, shepherd_watch, shepherd_status, shepherd_close, shepherd_read.', 'Lifecycle references are opaque session-scoped ids. Tool results print the id in their text and expose it as details.id; pass it as the top-level id argument, never as a Herdr pane id.', 'Requires a running Herdr session (HERDR_ENV=1 or headless server).', ].join(' '), promptSnippet: 'Subagent orchestration tool for herdr.', promptGuidelines: [ 'Use the Shepherd tool family as one lifecycle: discover an agent definition with shepherd/agents, create it with shepherd_spawn, submit tracked work with shepherd_delegate, collect results with shepherd_watch, inspect with shepherd_status or shepherd_read, and explicitly finish with shepherd_close.', 'Use shepherd_watch after shepherd_delegate when the parent should continue without blocking; it accepts task ids and sends custom completion follow-ups. Waiting is non-blocking; close each agent explicitly when finished.', 'When fieldnotes are enabled, read the shared shepherd.md fieldnotes index before assigning or reviewing work, and write only to the assigned note for note-producing prompts.', 'Fieldnotes can be enabled or disabled in /shepherd settings; the change applies when the next parent pi session starts.', ], parameters: Type.Object( { action: StringEnum(['herd', 'agents', 'prune'] as const, { description: 'herd: list live agents detected in Herdr panes. agents: list available agent definitions and source metadata. prune: drop stale pane registrations.', }), agentScope: Type.Optional(AgentScopeSchema), }, { description: 'Shepherd control plane: subagent framework for native Herdr agent orchestration. List the live herd, discover agent definitions, or prune stale panes.', } ), async execute(_toolCallId, params, signal, onUpdate, ctx) { return executeShepherd(String(params.action), params as ShepherdArgs, ctx, signal, onUpdate); }, renderCall(args, theme, context) { const render = formatShepherdCommand( String(args.action ?? 'unknown'), args as Record, context.expanded ); const component = reusableText(context.lastComponent); component.setText( theme.fg('toolTitle', theme.bold('shepherd ')) + theme.fg('accent', render.verb) + (render.rest ? theme.fg('dim', ` ${render.rest}`) : '') ); return component; }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_spawn', label: 'Shepherd: spawn agent', description: 'Spawn an idle, persistent agent in a Herdr pane (no task submitted). Provide a short task-specific label (for example label: "code review"). Use shepherd({ action: "agents" }) first if you do not know an exact agent name. ' + 'The result prints an opaque agent id; pass it as the top-level id argument to shepherd_prompt, shepherd_status, or shepherd_close. Defaults to the configured working directory, inherited parent model and thinking level, and a new tab; the agent definition can override both. Use placement pane_right or pane_down to split the current pane.', promptSnippet: 'Spawn a new agent in a Herdr pane.', promptGuidelines: [ 'When using shepherd_spawn, copy the printed agent id into the top-level id argument of shepherd_prompt, shepherd_status, or shepherd_close. After shepherd_prompt, copy the printed prompt id into shepherd_watch. Do not use a Herdr pane id; lifecycle ids are session-scoped.', ], parameters: Type.Object({ agent: Type.String({ description: 'Exact discovered agent name (case-sensitive). If unsure, call shepherd with action "agents" first.', }), label: Type.String({ description: 'Short task-specific label (max 64 characters).' }), placement: Type.Optional( StringEnum(['pane_right', 'pane_down', 'tab', 'workspace'] as const, { description: 'Optional placement: pane_right or pane_down splits the current pane, tab creates a new tab, and workspace creates a new workspace. If omitted, uses a background tab.', }) ), cwd: Type.Optional( Type.String({ description: 'Working directory for the child; defaults to the parent cwd.' }) ), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd( 'spawn', { action: 'spawn', ...params } as ShepherdArgs, ctx, signal, onUpdate ), // Render a useful invocation preview instead of the default tool-name-only // row; the result renderer below supplies the completion state. renderCall(args, theme, context) { const component = reusableText(context.lastComponent); const agent = typeof args.agent === 'string' ? args.agent : 'agent'; const label = typeof args.label === 'string' && args.label ? ` · ${args.label}` : ''; const placement = typeof args.placement === 'string' ? ` · ${args.placement}` : ''; component.setText( theme.fg('toolTitle', theme.bold('shepherd_spawn')) + ` ${theme.fg('accent', agent)}` + theme.fg('dim', `${label}${placement}`) ); return component; }, renderResult: (result, options, theme, context) => renderSpawnResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_delegate', label: 'Shepherd: delegate task', description: 'Start tracked asynchronous work on a spawned agent and return a task id immediately. The task can span multiple Pi turns; use shepherd_watch to observe terminal completion. Do not treat an idle child or ended turn as task completion.', promptSnippet: 'Delegate tracked asynchronous work without blocking the parent.', promptGuidelines: [ 'Use shepherd_delegate for work that may span multiple child turns or wait for another agent. The returned task id, not a prompt id or pane id, is the completion handle.', 'Use shepherd_watch after delegation when the parent should continue without blocking. A child must explicitly call shepherd_done before a successful task result is reported.', ], parameters: Type.Object({ target: Type.String({ description: 'Opaque agent id returned by shepherd_spawn. Do not use a Herdr pane id.', }), task: Type.String({ description: 'Non-empty delegated task description.' }), timeout: Type.Optional( Type.Integer({ default: 20, description: 'Optional task deadline in minutes.' }) ), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd( 'delegate', { action: 'delegate', ...params } as ShepherdArgs, ctx, signal, onUpdate ), renderCall(args, theme, context) { return renderLifecycleCall( 'shepherd_delegate', [ args.target, args.task, args.timeout !== undefined ? `timeout ${args.timeout}m` : undefined, ], theme, context ); }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_message', label: 'Shepherd: message agent', description: 'Send one asynchronous message to a spawned agent and return immediately. target MUST be the exact opaque agent id returned by shepherd_spawn; copy that id verbatim. Never pass an agent definition name such as "planner", a display label such as "manual planner", a Herdr pane id, or a placeholder such as ""—the call will be rejected. The returned message id identifies this message for reply correlation. An accepted message means the broker queued it for delivery; it does not mean the recipient read it or replied. With expectsReply and a taskId the task enters waiting until a matching reply arrives.', promptSnippet: 'send an asynchronous message using the exact opaque id returned by shepherd_spawn.', promptGuidelines: [ 'Before calling shepherd_message, copy target from the id field in shepherd_spawn output. Do not infer or substitute the agent definition name, display label, pane id, or any angle-bracket placeholder; invalid targets fail instead of being resolved by name.', 'Use shepherd_message for questions to an agent while it is busy or idle; the recipient receives it as a queued follow-up. Do not use it to submit tracked work — that is shepherd_delegate.', "When expectsReply is set with a taskId, the task waits for the reply; a matching reply (replyTo = the returned message id) returns it to running. For peer replies, use the requester's task ID—the task whose request is being answered—not the responder's task ID. A plain message never alters task state.", ], parameters: Type.Object({ target: Type.String({ description: 'Exact opaque agent id returned by shepherd_spawn; copy it verbatim. Agent names (for example "planner"), labels, Herdr pane ids, and placeholders are rejected.', }), message: Type.String({ description: 'Non-empty message content.' }), taskId: Type.Optional(TaskIdSchema), threadId: Type.Optional(Type.String({ description: 'Conversation/thread correlation id.' })), replyTo: Type.Optional( Type.String({ description: 'Message id of the request being answered.' }) ), expectsReply: Type.Optional( Type.Boolean({ description: 'Track this message as a request that expects a reply.' }) ), delivery: Type.Optional( Type.Union([Type.Literal('followUp'), Type.Literal('steer')], { description: 'Delivery mode; followUp is the default, steer is for urgent input.', }) ), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd( 'message', { action: 'message', ...params } as ShepherdArgs, ctx, signal, onUpdate ), renderCall(args, theme, context) { return renderLifecycleCall( 'shepherd_message', [args.target, args.message, args.expectsReply ? 'expects reply' : undefined, args.delivery], theme, context ); }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_prompt', label: 'Shepherd: prompt agent (deprecated)', description: 'Deprecated compatibility path: submits one message to a spawned agent and returns a prompt id without waiting, tying completion to a single child turn. For tracked work that must survive peer replies, prefer shepherd_delegate (a task) + shepherd_watch; use shepherd_message for follow-ups. Pass the agent id printed by shepherd_spawn as the top-level id argument, not a Herdr pane id. The result prints a prompt id; pass that id to shepherd_watch.', promptSnippet: 'Deprecated: prompt a spawned agent with a one-turn message (prefer shepherd_delegate for tracked work).', parameters: Type.Object({ id: Type.String({ description: 'Opaque agent id returned by shepherd_spawn. Do not use a Herdr pane id.', }), message: Type.String({ description: 'One-turn message to submit to the spawned agent. For multi-step or reply-dependent work use shepherd_delegate instead. Submission returns immediately; use shepherd_watch for the result.', }), timeout: Type.Optional( Type.Integer({ default: 20, description: 'Optional readiness wait before submission; normally omit. It is capped at 15 seconds internally. Completion is reported asynchronously by shepherd_watch.', }) ), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd( 'prompt', { action: 'prompt', ...params } as ShepherdArgs, ctx, signal, onUpdate ), renderCall(args, theme, context) { return renderLifecycleCall( 'shepherd_prompt', [ args.id, args.message, args.timeout !== undefined ? `timeout ${args.timeout}m` : undefined, ], theme, context ); }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_watch', label: 'Shepherd: watch task', description: 'Register a non-blocking one-shot watcher for one task or an array of task ids returned by shepherd_delegate. A watcher reports only terminal task outcomes—a child must explicitly call shepherd_done (or the task must fail, time out, or be cancelled) before a result is reported; idle, agent_end, and waiting states do not complete it. Returns immediately with pending and already-completed results; later completions arrive as a custom Shepherd follow-up message. Legacy prompt ids from shepherd_prompt are still accepted.', promptSnippet: 'Watch task completion asynchronously without blocking the parent turn.', promptGuidelines: [ 'Pass task ids returned by shepherd_delegate; legacy prompt ids from shepherd_prompt are also accepted. Never pass agent ids or Herdr pane ids.', 'Array watchers report each task as it settles and may coalesce close-together completions into one notification.', 'Use shepherd_status between watcher notifications to inspect running or waiting task state without blocking the parent turn.', ], parameters: Type.Object({ id: Type.Union( [ Type.String({ description: 'Opaque task id returned by shepherd_delegate. A legacy prompt id from shepherd_prompt is also accepted. Do not use an agent id or pane id.', }), Type.Array( Type.String({ description: 'Opaque task id returned by shepherd_delegate; a legacy prompt id from shepherd_prompt is also accepted.', }), { minItems: 1, description: 'Array of opaque task ids; completions are reported independently as each task settles.', } ), ], { description: 'One opaque task id or a non-empty array of task ids returned by shepherd_delegate; legacy prompt ids are also accepted.', } ), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd( 'watch', { action: 'watch', ...params } as ShepherdArgs, ctx, signal, onUpdate ), renderCall(args, theme, context) { const ids = Array.isArray(args.id) ? args.id.join(', ') : args.id; return renderLifecycleCall('shepherd_watch', [ids], theme, context); }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_status', label: 'Shepherd: status of agent', description: "Inspect an agent's current state without focusing or mutating its Herdr pane. Pass the agent id printed by shepherd_spawn; do not pass a prompt id or Herdr pane id.", promptSnippet: 'Check the current state of a spawned agent.', parameters: Type.Object({ id: Type.String({ description: 'Opaque agent id returned by shepherd_spawn. Do not use a Herdr pane id.', }), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd( 'status', { action: 'status', ...params } as ShepherdArgs, ctx, signal, onUpdate ), renderCall(args, theme, context) { return renderLifecycleCall('shepherd_status', [args.id], theme, context); }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_close', label: 'Shepherd: close agent', description: 'Close an owned agent and cancel any unresolved prompt. Pass the agent id printed by shepherd_spawn, not a Herdr pane id. Waiting does not close agents, so close each agent when finished.', promptSnippet: 'Close an owned agent and cancel any unresolved prompt.', parameters: Type.Object({ id: Type.String({ description: 'Opaque agent id returned by shepherd_spawn. Do not use a Herdr pane id.', }), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd( 'close', { action: 'close', ...params } as ShepherdArgs, ctx, signal, onUpdate ), renderCall(args, theme, context) { return renderLifecycleCall('shepherd_close', [args.id], theme, context); }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); pi.registerTool({ name: 'shepherd_read', label: 'Shepherd: read terminal output', description: 'Read recent terminal output for diagnostics. Pass an agent name, Herdr pane id, or an agent id; unlike lifecycle tools, this diagnostic tool intentionally accepts several target forms.', promptSnippet: 'Read recent terminal output for diagnostics.', parameters: Type.Object({ name: Type.String({ description: 'Agent name, Herdr pane id, or opaque agent id of the target.', }), lines: Type.Optional( Type.Integer({ description: 'Number of recent lines for read (default 40)', default: 40 }) ), source: Type.Optional(SourceSchema), }), prepareArguments: input => prepareForSchema, 'action'>>(input), execute: (_id, params, signal, onUpdate, ctx) => executeShepherd('read', { action: 'read', ...params } as ShepherdArgs, ctx, signal, onUpdate), renderCall(args, theme, context) { return renderLifecycleCall( 'shepherd_read', [args.name, args.source, args.lines !== undefined ? `${args.lines} lines` : undefined], theme, context ); }, renderResult: (result, options, theme, context) => renderUserFacingResult(result, options, theme, context), }); } function compactCallValue(value: unknown, maxLength = 72): string { const text = String(value ?? '') .replace(/\s+/g, ' ') .trim(); if (text.length <= maxLength) return text; return `${text.slice(0, Math.max(1, maxLength - 1))}…`; } function renderLifecycleCall(name: string, parts: unknown[], theme: any, context: any): Text { const component = reusableText(context.lastComponent); const values = parts .filter(value => value !== undefined && value !== null && value !== '') .map(value => compactCallValue(value)); component.setText( theme.fg('toolTitle', theme.bold(name)) + (values.length ? ` ${theme.fg('accent', values[0])}` : '') + (values.length > 1 ? theme.fg( 'dim', ` ${values .slice(1) .map(value => `· ${value}`) .join(' ')}` ) : '') ); return component; } function renderSpawnResult( result: any, options: { expanded?: boolean; isPartial?: boolean }, theme: any, context: any ) { const component = reusableText(context.lastComponent); const details = result?.details && typeof result.details === 'object' ? result.details : {}; const failed = context?.isError === true || details.error !== undefined || (details.returnCode !== undefined && Number(details.returnCode) !== 0); if (options.isPartial) { component.setText(theme.fg('warning', 'spawning…')); return component; } const rendered = formatToolResultText(result); if (rendered === undefined) return renderToolResult(result, options, theme, context); const firstLine = rendered.split('\n')[0] ?? ''; const errorMessage = failed ? typeof details.error === 'string' ? details.error : firstLine.replace(/^Herd spawn failed(?: \(return code \d+\))?:\s*/, '') || 'unknown error' : undefined; const status = failed ? theme.fg('error', `✗ failed${errorMessage ? ` · ${errorMessage}` : ''}`) : theme.fg('success', '✓ success'); // The call row already contains the agent, label, and placement. Keep the // result row to a status only and reserve protocol details for expansion. if (!options.expanded) { component.setText(status); return component; } const expanded = formatExpandedToolResult(result); if (expanded !== undefined) { component.setText(`${status}\n\n${styleExpandedToolResult(expanded, theme)}`); return component; } const newline = rendered.indexOf('\n'); const remainder = newline >= 0 ? rendered.slice(newline) : ''; const styledRemainder = remainder .split('\n') .map(line => ['call:', 'return:', 'details:'].includes(line) ? theme.fg('accent', line) : theme.fg('toolOutput', line) ) .join('\n'); component.setText(status + styledRemainder); return component; } /** Render every Shepherd result with the same user-facing structure. */ function renderUserFacingResult( result: any, options: { expanded?: boolean }, theme: any, context: any ) { const rendered = formatToolResultText(result); if (rendered === undefined) return renderToolResult(result, options, theme, context); const component = reusableText(context.lastComponent); const callName = result?.details?.call?.name; if (typeof callName !== 'string') { component.setText(theme.fg('toolOutput', rendered)); return component; } if (options.expanded) { const expanded = formatExpandedToolResult(result); if (expanded !== undefined) { // Keep the expanded result visually separate from the outer invocation // row. Spawn adds its own status block; the shared result path needs one // leading blank line before the `call` section. component.setText(`\n${styleExpandedToolResult(expanded, theme)}`); return component; } } const lifecycleStatus = renderCollapsedLifecycleResult(result, callName, theme, context); if (lifecycleStatus !== undefined) { component.setText(lifecycleStatus); return component; } const lines = rendered.split('\n'); const firstLine = lines.shift() ?? ''; const prefix = `${callName} `; const summary = firstLine.startsWith(prefix) ? firstLine.slice(prefix.length) : firstLine; const styled = theme.fg('toolTitle', theme.bold(callName)) + (summary ? ` ${theme.fg('accent', summary)}` : ''); const remainder = lines .map(line => ['call:', 'return:', 'details:'].includes(line) ? theme.fg('accent', line) : theme.fg('toolOutput', line) ) .join('\n'); component.setText(styled + (lines.length ? `\n${remainder}` : '')); return component; } export function formatExpandedToolResult(result: any): string | undefined { const details = result?.details && typeof result.details === 'object' ? result.details : {}; const call = details.call; if (!call || typeof call.name !== 'string') return undefined; const callArguments = call.arguments && typeof call.arguments === 'object' ? call.arguments : {}; const lines = ['call']; // Keep top-level call arguments flat. This lets raw message blocks start at // the same visual edge as their label and avoids a misleading hanging // indent when Pi wraps a long single-line value. Include optional arguments // whenever they were present in the invocation. lines.push(...formatHumanRecord(callArguments, '')); const body = resultTextBody(result); const summary = humanReturnSummary(call.name, body, details); if (lines.at(-1) !== '') lines.push(''); lines.push('return', `status: ${summary}`); if (call.name === 'shepherd' && Array.isArray(details.agents) && details.scope === undefined) { const activeCount = details.agents.filter((agent: any) => agent?.shepherd === true).length; lines.push(...formatHumanField('active agents', activeCount, '')); } else if (body.includes('\n')) { lines.push(...formatHumanField('output', body, '')); } const returnValueKeys = ['returnValue', 'result'].filter(key => Object.prototype.hasOwnProperty.call(details, key) ); for (const key of returnValueKeys) { lines.push( ...formatHumanReturnValue( key === 'returnValue' ? details[key] : details[key], call.name, callArguments, '', body ) ); } const returnValueObject = returnValueKeys.length === 1 && details[returnValueKeys[0]] && typeof details[returnValueKeys[0]] === 'object' && !Array.isArray(details[returnValueKeys[0]]) ? details[returnValueKeys[0]] : undefined; const returnedKeys = new Set(returnValueObject ? Object.keys(returnValueObject) : []); const extraEntries = Object.entries(details).filter(([key]) => { if ( ['call', 'returnValue', 'result', 'artifactSession', 'fieldnote', 'returnCode'].includes(key) ) return false; if (call.name === 'shepherd_status' && key === 'status') return false; if (key === 'error') return true; return !returnedKeys.has(key) && !['agent', 'label', 'model', 'id'].includes(key); }); for (const [key, value] of extraEntries) { if (call.name === 'shepherd' && key === 'agents' && Array.isArray(value)) { lines.push('agents:', ...formatHerdAgentList(shepherdHerdAgents(value))); continue; } lines.push(...formatHumanField(humanizeKey(key), value, '')); } if (typeof details.returnCode === 'number' && details.returnCode !== 0) { lines.push(...formatHumanField('return code', details.returnCode, '')); } return lines.join('\n'); } function humanReturnSummary(callName: string, body: string, details: Record): string { if (details.error !== undefined || /failed/i.test(body)) return 'failed'; if (callName === 'shepherd_spawn' && /spawned/i.test(body)) return 'spawned'; if (callName === 'shepherd_message' && /queued/i.test(body)) return 'queued'; if (callName === 'shepherd_delegate' && /delegated/i.test(body)) return 'delegated'; if (callName === 'shepherd_prompt' && /prompted/i.test(body)) return 'prompted'; if (callName === 'shepherd_watch' && /watching|already settled/i.test(body)) { return /watching/i.test(body) ? 'watching…' : 'completed'; } if (callName === 'shepherd_status') { const returned = details.returnValue && typeof details.returnValue === 'object' ? details.returnValue : details.result && typeof details.result === 'object' ? details.result : undefined; if (typeof returned?.state === 'string') return returned.state; const state = body.match(/\bagent\s+([a-z-]+)/i)?.[1]; return (state ?? body) || 'completed'; } if (callName === 'shepherd_close' && /closed/i.test(body)) return 'closed'; if (body.includes('\n')) return 'output'; return body || 'completed'; } export function renderCollapsedLifecycleResult( result: any, callName: string, theme: any, context: any ): string | undefined { const details = result?.details && typeof result.details === 'object' ? result.details : {}; const failed = context?.isError === true || details.error !== undefined || (details.returnCode !== undefined && Number(details.returnCode) !== 0); if (failed) { const body = resultTextBody(result); const firstLine = body.split('\n')[0] ?? ''; const error = typeof details.error === 'string' ? details.error : firstLine.replace(/^Herd \w+ failed(?: \(return code \d+\))?:\s*/, '') || 'unknown error'; return theme.fg('error', `✗ failed${error ? ` · ${error}` : ''}`); } if (callName === 'shepherd' && Array.isArray(details.agents)) { if (details.scope === undefined) { const agents = details.agents.filter((agent: any) => agent?.shepherd === true); return theme.fg('toolOutput', `Active agents: ${agents.length}`); } const names = details.agents .map((agent: any) => (agent && typeof agent.name === 'string' ? agent.name : undefined)) .filter((name: string | undefined): name is string => name !== undefined); return ( theme.fg('accent', 'Available agents:') + (names.length > 0 ? ` ${theme.fg('toolOutput', names.join(', '))}` : '') ); } if (callName === 'shepherd_delegate') return theme.fg('success', '✓ delegated'); if (callName === 'shepherd_message') { return details.targetTaskState === 'waiting' ? theme.fg('warning', 'waiting…') : theme.fg('success', '✓ queued'); } if (callName === 'shepherd_prompt') return theme.fg('success', '✓ prompted'); if (callName === 'shepherd_watch') { const pending = Array.isArray(details.pending) ? details.pending.length : 0; return pending > 0 ? theme.fg('warning', 'watching…') : theme.fg('success', '✓ completed'); } if (callName === 'shepherd_close') return theme.fg('success', '✓ closed'); // Status, read, and umbrella actions do not need a synthetic success icon; // their first result line is already the useful collapsed summary. Never // fall back to the protocol-oriented call/return/details text here. const body = resultTextBody(result); const firstLine = body.split('\n')[0] ?? ''; return theme.fg('toolOutput', firstLine || 'completed'); } function formatHumanReturnValue( value: unknown, callName: string, callArguments: Record, indent: string, body: string ): string[] { if (typeof value === 'string' && value === body) return []; if (callName === 'shepherd_watch' && Array.isArray(value)) { return formatHumanField('completions', value.map(compactWatcherCompletion), indent); } if (!value || typeof value !== 'object' || Array.isArray(value)) { return formatHumanField('value', value, indent); } return Object.entries(value).flatMap(([key, entry]) => { if (callName === 'shepherd_watch' && key === 'completed' && Array.isArray(entry)) { return formatHumanField('completions', entry.map(compactWatcherCompletion), indent); } if (callName === 'shepherd_spawn' && ['agent', 'label'].includes(key)) return []; if (callName === 'shepherd_status' && key === 'id' && callArguments.id === entry) return []; if (callName === 'shepherd_status' && key === 'state') return []; if (key === 'id' && callName === 'shepherd_spawn') { return formatHumanField('agent id', entry, indent); } if (key === 'placement' && callArguments.placement === entry) return []; return formatHumanField(humanizeKey(key), entry, indent); }); } function resultTextBody(result: any): string { const body = result?.content?.[0]?.type === 'text' ? String(result.content[0].text ?? '') : ''; const protocolMarker = '\n\ncall:\n'; const markerIndex = body.indexOf(protocolMarker); return markerIndex >= 0 ? body.slice(0, markerIndex) : body; } function formatHumanRecord(value: unknown, indent: string): string[] { if (!value || typeof value !== 'object' || Array.isArray(value)) { return formatHumanField('value', value, indent); } return Object.entries(value).flatMap(([key, entry]) => formatHumanField(humanizeKey(key), entry, indent) ); } function formatHumanField(label: string, value: unknown, indent: string): string[] { const isTextBlock = typeof value === 'string' && (value.includes('\n') || ['message', 'task', 'question', 'description', 'output', 'text'].includes(label)); if (isTextBlock) { return [`${indent}${label}:`, value as string, '']; } if (value && typeof value === 'object' && !Array.isArray(value)) { return [`${indent}${label}:`, ...formatHumanRecord(value, `${indent} `)]; } if (Array.isArray(value)) { if (value.length === 0) return [`${indent}${label}: []`]; return [ `${indent}${label}:`, ...value.flatMap(item => { if (item && typeof item === 'object' && !Array.isArray(item)) { const entries = Object.entries(item); if (entries.length === 0) return [`${indent} -`]; const [[firstKey, firstValue], ...rest] = entries; const firstLines = formatHumanField(humanizeKey(firstKey), firstValue, `${indent} `); const firstLine = firstLines.shift() ?? `${indent} ${humanizeKey(firstKey)}:`; return [ `${indent} - ${firstLine.trimStart()}`, ...firstLines, ...rest.flatMap(([key, value]) => formatHumanField(humanizeKey(key), value, `${indent} `) ), ]; } return [`${indent} - ${formatHumanScalar(item)}`]; }), ]; } return [`${indent}${label}: ${formatHumanScalar(value)}`]; } function formatHumanScalar(value: unknown): string { if (value === null || value === undefined) return 'null'; if (typeof value === 'boolean') return value ? 'yes' : 'no'; return String(value); } function humanizeKey(key: string): string { return key .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/[_-]+/g, ' ') .toLowerCase(); } export function styleExpandedToolResult( text: string, theme: any, options: { boldFields?: string[] } = {} ): string { // A raw text field (notably `message`) is deliberately left untouched. In // particular, lines such as `Status: pending` inside the message are data, // not human-readable field labels. The formatter emits a blank separator // after these blocks, which gives us an unambiguous end marker while keeping // the block flat and copy-friendly. let rawBlock = false; return text .split('\n') .map(line => { if (/^(call|return)$/.test(line)) { rawBlock = false; return theme.bold(line); } if (rawBlock) { if (line === '') rawBlock = false; return theme.fg('toolOutput', line); } const field = line.match(/^(\s*)(-\s+)?([A-Za-z][A-Za-z0-9 _-]*):(\s*)(.*)$/); if (field) { const isRawField = field[5] === '' && ['message', 'task', 'question', 'description', 'output', 'actions', 'text'].includes( field[3].toLowerCase() ); rawBlock = isRawField; const label = options.boldFields?.some( name => name.toLowerCase() === field[3].toLowerCase() ) ? theme.bold(`${field[3]}:`) : theme.fg('accent', `${field[3]}:`); return field[1] + (field[2] ?? '') + label + field[4] + theme.fg('toolOutput', field[5]); } return theme.fg('toolOutput', line); }) .join('\n'); } function renderToolResult(result: any, options: { expanded?: boolean }, theme: any, context: any) { const text = result.content[0]; const body = text?.type === 'text' ? (text.text ?? '') : ''; const expanded = options?.expanded ?? false; let rendered: string; if (!expanded && body.includes('\n')) { const firstLine = body.split('\n')[0]; rendered = theme.fg('accent', firstLine) + `\n${theme.fg('muted', `… +${body.split('\n').length - 1} more lines (Ctrl+O to expand)`)}`; } else { rendered = theme.fg('toolOutput', body || '(no output)'); } const component = reusableText(context.lastComponent); component.setText(rendered); return component; }