pi-subagents 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +246 -525
  3. package/agents/oracle.md +1 -0
  4. package/package.json +12 -4
  5. package/prompts/parallel-context-build.md +1 -1
  6. package/prompts/parallel-handoff-plan.md +1 -1
  7. package/prompts/review-loop.md +1 -1
  8. package/skills/pi-subagents/SKILL.md +6 -6
  9. package/skills/pi-subagents/references/constraints-and-recipes.md +19 -26
  10. package/skills/pi-subagents/references/execution-controls.md +98 -97
  11. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  12. package/skills/pi-subagents/references/prompting-and-roles.md +18 -27
  13. package/src/agents/agent-management.ts +155 -65
  14. package/src/agents/agent-serializer.ts +19 -0
  15. package/src/agents/agents.ts +154 -71
  16. package/src/agents/chain-serializer.ts +10 -7
  17. package/src/agents/frontmatter.ts +5 -3
  18. package/src/agents/identity.ts +1 -1
  19. package/src/agents/proactive-skills.ts +13 -10
  20. package/src/agents/skills.ts +23 -6
  21. package/src/api/control-channel.ts +4 -0
  22. package/src/api/delegation.ts +26 -194
  23. package/src/api/external-runs.ts +129 -0
  24. package/src/api/intercom-bridge.ts +3 -0
  25. package/src/api/pi-args.ts +5 -0
  26. package/src/api/preflight.ts +3 -3
  27. package/src/api/shared-types.ts +19 -0
  28. package/src/extension/config.ts +10 -0
  29. package/src/extension/control-notices.ts +5 -39
  30. package/src/extension/doctor.ts +10 -9
  31. package/src/extension/fanout-child.ts +7 -4
  32. package/src/extension/index.ts +232 -68
  33. package/src/extension/rpc.ts +18 -12
  34. package/src/extension/schemas.ts +48 -37
  35. package/src/extension/tool-description.ts +36 -86
  36. package/src/inspectors/herdr/actions.ts +229 -0
  37. package/src/inspectors/herdr/client.ts +130 -0
  38. package/src/inspectors/herdr/inspector-runner.ts +141 -0
  39. package/src/inspectors/herdr/project-panes.ts +154 -0
  40. package/src/integrations/herdr-status.ts +330 -0
  41. package/src/intercom/intercom-bridge.ts +3 -2
  42. package/src/intercom/result-intercom.ts +5 -1
  43. package/src/missions/actions.ts +372 -0
  44. package/src/missions/lifecycle.ts +314 -0
  45. package/src/missions/store.ts +442 -0
  46. package/src/missions/types.ts +135 -0
  47. package/src/policy/authority.ts +46 -0
  48. package/src/profiles/profiles.ts +29 -3
  49. package/src/runs/background/async-execution.ts +98 -49
  50. package/src/runs/background/async-job-tracker.ts +10 -2
  51. package/src/runs/background/async-resume.ts +6 -6
  52. package/src/runs/background/async-status.ts +29 -1
  53. package/src/runs/background/auto-drain.ts +3 -3
  54. package/src/runs/background/chain-append.ts +3 -2
  55. package/src/runs/background/control-channel.ts +9 -7
  56. package/src/runs/background/fleet-view.ts +3 -4
  57. package/src/runs/background/notify.ts +2 -1
  58. package/src/runs/background/process-terminal.ts +5 -5
  59. package/src/runs/background/result-watcher.ts +13 -5
  60. package/src/runs/background/run-id-resolver.ts +3 -3
  61. package/src/runs/background/run-status.ts +35 -8
  62. package/src/runs/background/scheduled-runs.ts +602 -375
  63. package/src/runs/background/stale-run-reconciler.ts +3 -3
  64. package/src/runs/background/subagent-runner.ts +608 -445
  65. package/src/runs/background/subagent-wait.ts +50 -9
  66. package/src/runs/background/wait-subscriptions.ts +253 -0
  67. package/src/runs/background/wait-tool.ts +12 -4
  68. package/src/runs/foreground/async-steering-action.ts +3 -3
  69. package/src/runs/foreground/chain-clarify.ts +8 -4
  70. package/src/runs/foreground/chain-execution.ts +56 -30
  71. package/src/runs/foreground/execution.ts +15 -2
  72. package/src/runs/foreground/subagent-executor.ts +1023 -273
  73. package/src/runs/shared/acceptance.ts +28 -6
  74. package/src/runs/shared/child-protocol.ts +302 -22
  75. package/src/runs/shared/dynamic-fanout.ts +1 -1
  76. package/src/runs/shared/external-cli-runner.ts +130 -0
  77. package/src/runs/shared/long-running-guard.ts +42 -1
  78. package/src/runs/shared/nested-events.ts +59 -5
  79. package/src/runs/shared/nested-render.ts +9 -4
  80. package/src/runs/shared/parallel-handoff.ts +86 -2
  81. package/src/runs/shared/parallel-utils.ts +11 -2
  82. package/src/runs/shared/permissions.ts +95 -0
  83. package/src/runs/shared/pi-args.ts +11 -1
  84. package/src/runs/shared/pi-spawn.ts +11 -1
  85. package/src/runs/shared/run-history.ts +1 -1
  86. package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
  87. package/src/runs/shared/subagent-startup-retry.ts +5 -2
  88. package/src/runs/shared/turn-budget.ts +6 -6
  89. package/src/runs/shared/worktree.ts +122 -12
  90. package/src/shared/accessible-dir.ts +29 -7
  91. package/src/shared/artifacts.ts +18 -1
  92. package/src/shared/fork-context.ts +3 -2
  93. package/src/shared/launch-contract.ts +1 -0
  94. package/src/shared/settings.ts +10 -0
  95. package/src/shared/types.ts +156 -14
  96. package/src/shared/utils.ts +8 -6
  97. package/src/slash/delegation-adapters.ts +32 -194
  98. package/src/slash/delegation-request.ts +43 -126
  99. package/src/slash/prompt-template-bridge.ts +158 -205
  100. package/src/slash/prompt-workflows.ts +21 -57
  101. package/src/slash/slash-bridge.ts +14 -0
  102. package/src/slash/slash-commands.ts +31 -632
  103. package/src/slash/subagents-admin.ts +18 -14
  104. package/src/tui/fleet-status.ts +156 -21
  105. package/src/tui/fleet-transcript.ts +110 -5
  106. package/src/tui/fleet.ts +56 -24
  107. package/src/tui/render.ts +291 -109
  108. package/src/types/pi-runtime-compat.d.ts +14 -0
  109. package/src/watchdog/lsp-diagnostics.ts +12 -7
  110. package/src/watchdog/model-selection.ts +2 -2
  111. package/src/watchdog/permission-arbiter.ts +145 -0
  112. package/src/watchdog/register-child.ts +1 -1
  113. package/src/watchdog/register-main.ts +1 -1
  114. package/src/watchdog/review.ts +4 -1
  115. package/src/watchdog/runtime.ts +3 -2
  116. package/src/workflows/chat-progress.ts +140 -0
  117. package/src/workflows/scripted-workflow.ts +415 -0
  118. package/agents/advisor.md +0 -73
  119. package/src/extension/chain-validation.ts +0 -181
@@ -79,7 +79,7 @@ const AcceptanceEvidenceKinds = [
79
79
 
80
80
  const AcceptanceOverride = Type.Unsafe({
81
81
  anyOf: [
82
- { type: "string", enum: ["auto", "attested", "checked", "verified"] },
82
+ { type: "string", enum: ["auto", "attested", "checked"] },
83
83
  {
84
84
  type: "string",
85
85
  enum: ["reviewed"],
@@ -89,16 +89,16 @@ const AcceptanceOverride = Type.Unsafe({
89
89
  { type: "boolean", enum: [false] },
90
90
  { type: "object", additionalProperties: true },
91
91
  ],
92
- description: `Optional acceptance policy. For reviewer/read-only calls, omit acceptance. Example: { level: "checked", evidence: ["commands-run", "changed-files"] }. Supported evidence kinds: ${AcceptanceEvidenceKinds.join(", ")}. Evidence levels end at verified; use acceptance.review.required for review. Omitted means auto-inferred unless agentContract.version=1.`,
92
+ description: `Optional acceptance policy. For reviewer/read-only calls, omit acceptance. Example: { level: "checked", evidence: ["commands-run", "changed-files"] }. Supported evidence kinds: ${AcceptanceEvidenceKinds.join(", ")}. Evidence levels end at verified; use acceptance.review.required for review. Omitted means auto-inferred unless agentContract compatibility behavior is enabled.`,
93
93
  });
94
94
 
95
95
  const AgentContractOverride = Type.Object({
96
- version: Type.Integer({ enum: [1], description: "Opt into generic agent contract v1 for this run/child." }),
97
- }, { additionalProperties: false, description: "Opt-in compatibility contract. Omit to use current default behavior." });
96
+ version: Type.Integer({ enum: [1], description: "Enable compatibility behavior for this run/child." }),
97
+ }, { additionalProperties: false, description: "Compatibility behavior. Omit for the default behavior." });
98
98
 
99
99
  const ChainGateOverride = Type.String({
100
100
  enum: ["execution", "acceptance"],
101
- description: "For agentContract.version=1 chain steps, choose whether the chain advances on execution success or acceptance success. Defaults to execution.",
101
+ description: "For chain steps with agentContract, choose whether the chain advances on execution success or acceptance success. Defaults to execution.",
102
102
  });
103
103
 
104
104
  const TurnBudgetOverride = Type.Object({
@@ -129,23 +129,6 @@ const UsageBudgetOverride = Type.Object({
129
129
  costUsd: Type.Optional(UsageBudgetLimitOverride),
130
130
  }, { additionalProperties: false, description: "Optional root-only reported-usage budget. Hard limits prevent future child launches; running children are not stopped." });
131
131
 
132
- const TaskItem = Type.Object({
133
- agent: Type.String(),
134
- task: Type.String(),
135
- cwd: Type.Optional(Type.String()),
136
- count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
137
- output: Type.Optional(OutputOverride),
138
- outputMode: Type.Optional(OutputModeOverride),
139
- reads: Type.Optional(ReadsOverride),
140
- progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking for this task" })),
141
- model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
142
- skill: Type.Optional(SkillOverride),
143
- toolBudget: Type.Optional(ToolBudgetOverride),
144
- outputSchema: Type.Optional(JsonSchemaObject),
145
- acceptance: Type.Optional(AcceptanceOverride),
146
- agentContract: Type.Optional(AgentContractOverride),
147
- });
148
-
149
132
  // Parallel task item (within a parallel step)
150
133
  export const ParallelTaskSchema = Type.Object({
151
134
  agent: Type.String(),
@@ -245,6 +228,17 @@ export const ChainItem = Type.Object({
245
228
  additionalProperties: false,
246
229
  });
247
230
 
231
+ // Runtime mission handlers validate these untrusted nested objects loudly. Keeping
232
+ // their provider schema shallow avoids repeating a full durable-record schema in
233
+ // every tool request.
234
+ const MissionLaunchOverride = Type.Unsafe({
235
+ anyOf: [
236
+ { type: "object", additionalProperties: true },
237
+ { type: "boolean", enum: [false] },
238
+ ],
239
+ });
240
+ const MissionUpdateOverride = Type.Unsafe({ type: "object", additionalProperties: true });
241
+
248
242
  const ControlOverrides = Type.Object({
249
243
  enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control attention tracking for this run" })),
250
244
  needsAttentionAfterMs: Type.Optional(Type.Integer({ minimum: 1, description: "No-observed-activity window before a run needs attention" })),
@@ -265,31 +259,48 @@ const SubagentParamsSchema = Type.Object({
265
259
  task: Type.Optional(Type.String({ description: "Task (SINGLE mode, optional for self-contained agents)" })),
266
260
  // Management action (when present, tool operates in management mode)
267
261
  action: Type.Optional(Type.String({
268
- description: "Optional management/control action. Omit this field entirely for execution/delegation ({agent, task}, {tasks}, or {chain}); use it only for management/control actions."
262
+ description: "Optional management/control action. Omit this field entirely for execution/delegation ({agent, task} or {workflowScript}); use it only for management/control actions."
269
263
  })),
264
+ name: Type.Optional(Type.String({ description: "Human-readable name for action='schedule.create'." })),
270
265
  id: Type.Optional(Type.String({
271
- description: "Run id or prefix for action='status', action='interrupt', action='stop', action='resume', action='steer', action='append-step', action='approve-checkpoint', or action='reject-checkpoint'."
266
+ description: "Run id or prefix for status, interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run."
272
267
  })),
273
268
  runId: Type.Optional(Type.String({
274
- description: "Target run ID for action='interrupt', action='stop', action='resume', action='steer', action='append-step', action='approve-checkpoint', or action='reject-checkpoint'. Prefer id for new calls."
269
+ description: "Target run ID for interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
275
270
  })),
276
271
  dir: Type.Optional(Type.String({
277
272
  description: "Async run directory for action='status', action='stop', action='resume', or action='steer'."
278
273
  })),
274
+ handoffPath: Type.Optional(Type.String({ description: "worktree.discard manifest." })),
279
275
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based child index for actions that target a specific child or transcript." })),
280
276
  view: Type.Optional(Type.String({
281
277
  enum: ["fleet", "transcript"],
282
278
  description: "Optional status view. Use view='fleet' for a read-only active foreground/async fleet surface, or view='transcript' with id/dir (and optional index) to tail a run transcript.",
283
279
  })),
284
280
  lines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, description: "Maximum transcript lines for action='status', view='transcript'. Defaults to 80." })),
285
- message: Type.Optional(Type.String({ description: "Follow-up message for action='resume' (revive paused, completed, or failed children, or reach a routed nested run) or live async guidance for action='steer'. Stopped runs are non-resumable. Use index to choose a child from multi-child runs." })),
281
+ message: Type.Optional(Type.String({ description: "Follow-up message for resume, live guidance for steer, or optional startup prompt for project.open." })),
286
282
  steeringRecovery: Type.Optional(Type.Boolean({ description: "For action='steer', allow pause-and-revive recovery after a missed acknowledgment. Defaults true for direct tool calls; extension RPC steering forces false so callers retain exact child ownership." })),
287
283
  additional: Type.Optional(Type.Integer({ minimum: 1, description: "Positive launches to add with action='grant-spawn-budget'. Root interactive parent with native user confirmation only; total grants cannot exceed the original configured cap." })),
288
284
  scope: Type.Optional(Type.String({ enum: ["session", "user", "project"], description: "Scope for action='watchdog.configure'. Defaults to session to avoid persistent settings writes unless user/project is explicit." })),
289
- target: Type.Optional(Type.String({ enum: ["main", "children", "child"], description: "Target for action='watchdog.configure'. Defaults to main. Use target='child' with agent for a per-agent child watchdog override." })),
285
+ target: Type.Optional(Type.String({ enum: ["main", "children", "child"], description: "Target for watchdog actions." })),
286
+ focus: Type.Optional(Type.Boolean({ description: "Focus the new Herdr pane for inspector.open or project.open." })),
290
287
  thinking: Type.Optional(Type.Unsafe({ anyOf: [{ type: "string" }, { type: "boolean", enum: [false] }], description: "Thinking level for action='watchdog.configure' (off/minimal/low/medium/high/xhigh/max, inherit, or false for off)." })),
291
- schedule: Type.Optional(Type.String({ description: "Explicit one-shot schedule for action='schedule'. Only honored when scheduledRuns.enabled is true. Use '+10m' or a future ISO timestamp with timezone; scheduled runs always launch async with fresh context." })),
292
- scheduleName: Type.Optional(Type.String({ description: "Optional display name for action='schedule'." })),
288
+ schedule: Type.Optional(Type.String({ deprecated: true, description: "Removed one-shot schedule field. Use action='schedule.create' with at." })),
289
+ scheduleName: Type.Optional(Type.String({ deprecated: true, description: "Removed schedule display field. Use name." })),
290
+ at: Type.Optional(Type.String({ description: "One-shot trigger for action='schedule.create': a relative delay such as '+10m' or an ISO timestamp with timezone." })),
291
+ every: Type.Optional(Type.String({ description: "Fixed recurring interval for action='schedule.create', such as '30m', '6h', '2d', or '2w'." })),
292
+ on: Type.Optional(Type.Unsafe({ anyOf: [{ type: "string" }, { type: "integer" }], description: "Calendar selector reserved for a later schedule slice." })),
293
+ timezone: Type.Optional(Type.String({ description: "IANA timezone reserved for a later calendar schedule slice." })),
294
+ overlap: Type.Optional(Type.String({ enum: ["skip"], description: "Overlap policy. This slice supports skip only." })),
295
+ catchUp: Type.Optional(Type.String({ enum: ["none", "latest"], description: "Missed occurrence policy for recurring schedules. Defaults to latest." })),
296
+ missionId: Type.Optional(Type.String({ description: "Mission id." })),
297
+ mission: Type.Optional(Type.Unsafe({ ...MissionLaunchOverride, description: "Mission object, or false for no mission." })),
298
+ missionUpdate: Type.Optional(Type.Unsafe({ ...MissionUpdateOverride, description: "Mission update: summary, labels, decisions, artifacts, or delivery receipts." })),
299
+ missionStatus: Type.Optional(Type.String({ description: "Mission status." })),
300
+ missionScope: Type.Optional(Type.String({ description: "Mission list scope: project (default) or global pointer index." })),
301
+ runMode: Type.Optional(Type.String({ description: "Attached run mode." })),
302
+ runStatus: Type.Optional(Type.String({ description: "Attached run status." })),
303
+ summary: Type.Optional(Type.String({ description: "Mission close summary." })),
293
304
  // Chain identifier for management (can't reuse 'chain' — that's the execution array)
294
305
  chainName: Type.Optional(Type.String({
295
306
  description: "Chain name for get/update/delete management actions"
@@ -302,17 +313,14 @@ const SubagentParamsSchema = Type.Object({
302
313
  ],
303
314
  description: "Agent/chain config for create/update. Object or JSON string; presence of steps creates a chain."
304
315
  })),
305
- tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, outputMode?, reads?, progress?}, ...]" })),
306
- concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
307
- worktree: Type.Optional(Type.Boolean({
308
- description: "Create isolated git worktrees for parallel tasks; requires clean git state."
309
- })),
310
- chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential steps; each result becomes {previous}. append-step takes one tail step and may use {chain_dir}/{outputs.name}." })),
316
+ workflowScript: Type.Optional(Type.String({ minLength: 1, description: "Trusted inline JavaScript orchestration. Starts asynchronously by default; pass async:false for a small foreground run. Use await runs.run(key, {agent, task, worktree?}), runs.all([...]), runs.status(id), runs.ref(s), emit(value), console, and return. Set worktree:true at workflow or child level for a separate managed worktree per child; child fields override workflow defaults. runs.run accepts one child only. No filesystem, shell, Pi tools, or host globals." })),
317
+ chatProgress: Type.Optional(Type.String({ enum: ["auto", "off", "terminal", "milestones", "live-card"], description: "WorkflowScript chat progress projection. auto shows a live in-chat card only for watched foreground workflows in the same Git repository; background and other-repo workflows stay quieter with terminal/milestone summaries." })),
318
+ worktree: Type.Optional(Type.Boolean({ description: "Managed child isolation. true gives a direct single child or each workflow child a separate git worktree; an individual runs.run/runs.all item can override a workflow default with worktree:false." })),
319
+ step: Type.Optional(Type.Unsafe({ ...ChainItem, description: "One chain step for action='append-step' only. Not an execution mode." })),
311
320
  context: Type.Optional(Type.String({
312
321
  enum: ["fresh", "fork"],
313
322
  description: "'fresh' or 'fork' to branch from parent session. Explicit context overrides every child in the invocation. If omitted, each requested agent uses its own defaultContext; agents without defaultContext: 'fork' run fresh.",
314
323
  })),
315
- chainDir: Type.Optional(Type.String({ description: "Persistent chain artifact directory; defaults to user-scoped temp storage." })),
316
324
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
317
325
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Timeout for foreground and async/background runs; foreground defaults to 30m absent call/agent. Alias maxRuntimeMs." })),
318
326
  maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs for foreground and async/background runs; foreground defaults to 30m absent call/agent." })),
@@ -320,7 +328,7 @@ const SubagentParamsSchema = Type.Object({
320
328
  toolBudget: Type.Optional(ToolBudgetOverride),
321
329
  usageBudget: Type.Optional(UsageBudgetOverride),
322
330
  agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
323
- cwd: Type.Optional(Type.String()),
331
+ cwd: Type.Optional(Type.String({ description: "Execution cwd, or target project directory for project.open/status/close." })),
324
332
  artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
325
333
  includeProgress: Type.Optional(Type.Boolean({ description: "Include full progress in result (default: false)" })),
326
334
  share: Type.Optional(Type.Boolean({ description: "Upload session to GitHub Gist for sharing (default: false)" })),
@@ -352,6 +360,9 @@ const SubagentWaitParamsSchema = Type.Object({
352
360
  id: Type.Optional(Type.String({
353
361
  description: "Async run or remembered detached foreground run id/prefix to wait for one specific run. Omit to wait across every active async run started in this session.",
354
362
  })),
363
+ nonBlocking: Type.Optional(Type.Boolean({
364
+ description: "When true, resolve id to one exact run, persist a wake subscription, and return immediately. The originating session is woken on completion, failure, attention, reconciliation failure, or timeout. Requires id and cannot be combined with all.",
365
+ })),
355
366
  all: Type.Optional(Type.Boolean({
356
367
  description: "Wait for ALL active runs to finish. Default false: return as soon as the first run finishes, so a fleet manager can spawn a replacement and wait again. Ignored when id targets a single run.",
357
368
  })),
@@ -7,99 +7,49 @@ const CUSTOM_TOOL_DESCRIPTION_FILE = "subagent-tool-description.md";
7
7
  const CUSTOM_TOOL_DESCRIPTION_MAX_BYTES = 50 * 1024;
8
8
 
9
9
  export const SUBAGENT_SAFETY_GUIDANCE = `SAFETY-CRITICAL SUBAGENT GUIDANCE:
10
- • Use { action: "list" } before execution and only run executable/non-disabled agents or chains.
11
- • Keep execution and management separate: omit action for SINGLE/PARALLEL/CHAIN execution; use action only for list/get/models/create/update/delete/status/grant-spawn-budget/interrupt/stop/resume/steer/append-step/approve-checkpoint/reject-checkpoint/doctor.
12
- • Async/background runs: launch with async:true only when work can proceed independently. Do not sleep or poll status just to wait. In an interactive session, normally return control and let Pi wake you; do not call subagent_wait merely to wait. Override that default and call subagent_wait when the current request is run-to-completion — for example, the user asked you to report results back before continuing or a skill must finish in one turn. Headless sessions auto-drain current-session work at agent_end; use subagent_wait when this turn must receive results before it ends.
13
- • Child-safety boundary: ordinary child subagents are not orchestrators and must not run subagents. Only explicitly configured fanout children may use the child-safe subagent tool, still bounded by depth/session limits.
14
- • Writing/review safety: keep one writer for the same cwd/worktree. Use fresh-context read-only reviewers/validators for independent review, then have the parent synthesize and apply fixes as the sole writer unless an isolated worktree was intentionally requested.
15
- • Artifacts/status essentials: chain outputs live under {chain_dir}; async runs expose asyncId/asyncDir with status.json, events.jsonl, output logs, and status via { action: "status", id }. Include output paths and residual risks when reporting results.`;
16
-
17
- export const FULL_SUBAGENT_TOOL_DESCRIPTION = `To delegate work, call with { agent, task }, { tasks }, or { chain }; omit action. Use action only for management/control actions listed below.
18
-
19
- EXECUTION (use exactly ONE mode):
20
- • Before executing, use { action: "list" } to inspect configured agents/chains. Only execute agents listed as executable/non-disabled.
21
- • SINGLE: { agent, task? } - one task; omit task for self-contained agents
22
- • CHAIN: { chain: [{agent:"agent-a"}, {checkpoint:"review"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional approval checkpoints and parallel fan-out
23
- • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
24
- • Optional context: { context: "fresh" | "fork" } (explicit value overrides every child; when omitted, each requested agent uses its own defaultContext, otherwise "fresh"; inspect agent defaults via { action: "list" })
25
- • Fork thinking: model strings accept a thinking suffix (provider/model:off|minimal|low|medium|high|xhigh|max). Forking over a parent transcript that carries signed Anthropic thinking blocks forces thinking off only when a child's effective primary or fallback model resolves to the Anthropic provider or anthropic-messages API; unresolved models are treated conservatively. The result notes affected children, including on failures. Use fresh context when an Anthropic child needs thinking.
26
- • Optional timeout: { timeoutMs } or { maxRuntimeMs } sets a run-level max runtime for foreground and async/background runs; foreground defaults to 30 minutes only when neither value nor an agent timeout is provided
27
- • Acceptance: omit acceptance for reviewer/read-only calls. Evidence levels end at verified. Use acceptance.review.required to require independent review of a writer result. Never request acceptance:"reviewed"; reviewed is achieved only after an independent reviewer result.
28
- • If { action: "list" } shows proactive skill subagent suggestions, consider a small fresh-context fanout for broad tasks where one of those skills would materially help
29
-
30
- CHAIN TEMPLATE VARIABLES (use in task strings):
31
- • {task} - The original task/request from the user
32
- • {previous} - Text response from the previous step (empty for first step)
33
- • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-subagents-<scope>/chain-runs/abc123/)
34
-
35
- CHAIN EXAMPLES (quick reference for the nested schema):
36
- • Sequential: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", task:"Plan based on {previous}"}] }
37
- • Parallel fan-out: { chain: [{parallel: [{agent:"agent-a", task:"Check part of {task}", count: 3}]}] }
38
- • Mixed: { chain: [{agent:"agent-a", task:"Research {task}"}, {checkpoint:"review", message:"Approve implementation?"}, {parallel: [{agent:"agent-b", task:"Review {previous}", count: 2}]}, {agent:"agent-c", task:"Summarize {previous}"}] }
39
-
40
- MANAGEMENT (use action field, omit agent/task/chain/tasks):
41
- • { action: "list" } - discover executable agents/chains
42
- • { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
43
- • { action: "models", agent?: "name" } - show the runtime-loaded builtin subagent model mapping, optionally filtered to one builtin
44
- • { action: "watchdog.status" | "watchdog.check" | "watchdog.recommend-model" } - inspect the opt-in subagent watchdog and its strong complementary model recommendation
45
- • { action: "watchdog.configure", model: "recommended" | "inherit" | "provider/model[:thinking]", scope?: "session" | "user" | "project", target?: "main" | "children" | "child", agent?: "name", thinking?: "inherit" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" } - configure watchdog model selection; default scope is session, use persistent scopes only when the user asks
46
- • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, acceptance, acceptanceRole: "read-only" | "writer", ... } }
47
- • acceptanceRole affects inferred acceptance only, never tool access. Explicit task mutation/no-edit intent wins; omission preserves name heuristics. Update with false or an empty string to clear it.
48
- • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
49
- • { action: "delete", agent: "code-analysis.custom-agent" }
50
- • { action: "eject", agent: "reviewer", agentScope?: "user" | "project" } - copy a bundled/package agent to user/project scope as an editable custom file that shadows the original (default scope: user)
51
- • { action: "disable", agent: "reviewer", agentScope?: "user" | "project" } - hide any agent from runtime discovery via a reversible settings override (default scope: user)
52
- • { action: "enable", agent: "reviewer", agentScope?: "user" | "project" } - remove a disabled override and restore discovery
53
- • { action: "reset", agent: "reviewer", agentScope?: "user" | "project" } - delete the scope's custom agent file and/or settings override, restoring the bundled default
54
- • { action: "grant-spawn-budget", additional: 10 } - add bounded capacity from the root interactive parent after native user confirmation; grants are rejected while children are active and cumulative grants cannot exceed the original configured cap
55
- • Use chainName for chain operations; packaged chains also use dotted runtime names
56
-
57
- CONTROL:
58
- • { action: "status", id: "..." } - inspect an async/background run by id or prefix
59
- • { action: "status", view: "fleet" } - read-only active foreground/async fleet view with transcript commands
60
- • { action: "status", id: "...", view: "transcript", index?: 0, lines?: 80 } - tail a run or child output/session transcript
61
- • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused
62
- • { action: "stop", id: "..." } - stop a current-session top-level async run; stopped runs finish with state "stopped"
63
- • { action: "resume", id: "...", message: "...", index?: 0 } - revive a paused, completed, or failed async/foreground child from its session; stopped runs are non-resumable; routed nested runs may accept live follow-ups; use steer for a live top-level async child
64
- • { action: "steer", id: "...", message: "...", index?: 0 } - await correlated child-Pi input acceptance for up to 3 seconds; returns delivered, scheduled, pending, partial, recovered, or failed with a request id. Only top-level single runs may recover after a further 15-second pause/revival bound; chain, parallel, and nested runs never auto-interrupt.
65
- • { action: "append-step", id: "...", chain: [{agent:"agent-c", task:"Use {previous}"}] } - append one step to the tail of a running async chain
66
- • { action: "approve-checkpoint", id: "..." } / { action: "reject-checkpoint", id: "..." } - decide a paused current-session async chain checkpoint
67
-
68
- SCHEDULE (opt-in; requires { "scheduledRuns": { "enabled": true } } in config.json):
69
- • { action: "schedule", agent, task?, schedule: "+10m" | "2030-01-01T09:00:00Z", scheduleName? } - defer a subagent launch until a future time. Also accepts tasks[] or chain[]. Scheduled runs always launch async with fresh context; they become normal tracked async runs once they fire. Only schedule explicit delayed runs the user asked for.
70
- • { action: "schedule-list" } - list scheduled runs for this session
71
- • { action: "schedule-status", id: "..." } - inspect one scheduled run
72
- • { action: "schedule-cancel", id: "..." } - cancel a scheduled run before it fires
73
-
74
- DIAGNOSTICS:
75
- • { action: "doctor" } - read-only report for runtime paths, discovery, sessions, and intercom
10
+ • Use { action: "list" } before execution and only run executable/non-disabled agents.
11
+ • Keep execution and management separate: omit action for single-child and workflowScript execution; use action only for management/control.
12
+ • Async/background runs are the default. Use async:false only when a blocking foreground result is needed. Do not sleep or poll status just to wait; use subagent_wait only when the current request must finish in this turn.
13
+ • Ordinary child subagents are not orchestrators. Only explicitly configured fanout children may use the child-safe subagent tool, still bounded by depth/session limits.
14
+ • Keep one writer for the same cwd/worktree. Use fresh-context read-only reviewers for independent review, then have the parent synthesize and apply fixes.
15
+ • Async runs expose asyncId/asyncDir with status.json, events.jsonl, output logs, and status via { action: "status", id }. Include output paths and residual risks when reporting results.`;
16
+
17
+ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Delegate one child with { agent, task } or compose work with { workflowScript }; omit action. workflowScript is the sole public orchestration surface. Use action only for management/control actions.
18
+
19
+ EXECUTION (use exactly one mode):
20
+ • Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
21
+ • SINGLE: { agent, task? } launches one child. Omit task for a self-contained agent.
22
+ • SCRIPTED WORKFLOW: { workflowScript: "const scan = await runs.run('scan', {agent:'agent-a', task:'...'}); return scan.output" }. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, terminal, milestones, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Direct single-child calls also support worktree:true; use workflowScript only when coordination is needed. For repository mutation lanes, set worktree:true on a direct single child, workflow, or individual runs.run/runs.all item for managed isolation instead of manual Git worktrees; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
23
+ • Sequential replacement: { workflowScript: "const a = await runs.run('analyze', {agent:'agent-a', task:'Analyze the request'}); return (await runs.run('plan', {agent:'agent-b', task:'Plan from: '+a.output})).output" }
24
+ • Parallel replacement: { workflowScript: "const [a,b] = await runs.all([{key:'correctness',agent:'agent-a',task:'Review correctness'},{key:'tests',agent:'agent-b',task:'Review tests'}]); return {correctness:a.output,tests:b.output}" }
25
+ • Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async runs. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
26
+ • Durable mission attachment is automatic by default. Use missionId to attach an existing mission, mission:{...} to override auto-create, or mission:false for ephemeral work.
27
+
28
+ MANAGEMENT / CONTROL (use action; omit execution fields):
29
+ • list, get, models, create, update, delete, eject, disable, enable, reset, doctor, grant-spawn-budget, worktree.discard, mission.create/list/show/update/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available.
30
+ • status, interrupt, stop, resume, and steer manage live or persisted runs. Use status view:"fleet" for an overview or view:"transcript" with id and optional index to tail output.
31
+ • { action: "append-step", id: "...", step: {agent:"agent-c", task:"Use {previous}"} } appends one step to an already-running durable legacy chain. step is control-only, not an execution mode.
32
+ • approve-checkpoint and reject-checkpoint decide a paused durable legacy chain checkpoint.
33
+ • Create durable project schedules with { action:"schedule.create", id?, name?, at:"+10m" | ISO, agent, task? } or { every:"6h", workflowScript }. Manage them with schedule.list/show/history/pause/resume/run/run-due/delete. This first slice supports fixed intervals; calendar schedules and schedule mission attachment are deferred.
76
34
 
77
35
  ${SUBAGENT_SAFETY_GUIDANCE}`;
78
36
 
79
- export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `To delegate work, call with { agent, task }, { tasks }, or { chain }; omit action. Use action only for management/control actions listed below. Use exactly one mode per call.
37
+ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Delegate one child with { agent, task } or orchestrate with { workflowScript }; omit action. workflowScript is the sole public orchestration surface.
80
38
 
81
39
  EXECUTE:
82
- • Before execution, call { action: "list" }; run only executable/non-disabled configured agents/chains.
83
- • SINGLE {agent, task?}; PARALLEL {tasks:[{agent,task,count?,output?,reads?,progress?}], concurrency?, worktree?}; CHAIN {chain:[{agent,task?},{checkpoint:"review"},{parallel:[...]}]}.
84
- • context can be "fresh" or "fork"; omitted uses each agent defaultContext, otherwise fresh. timeoutMs/maxRuntimeMs apply to foreground and async/background runs; foreground defaults to 30 minutes only when neither value nor an agent timeout is provided.
85
- • Omit acceptance for reviewer/read-only calls. Evidence levels end at verified; use acceptance.review.required for independent writer review. reviewed is an achieved status, never an explicit input.
86
- • Chain templates may use {task}, {previous}, {chain_dir}, and named outputs. Parallel worktree isolation requires a clean git repo.
87
- • Chain example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {parallel: [{agent:"agent-b", task:"Check {previous}", count: 3}]}] }
88
- • If list shows proactive skill subagent suggestions, use a small fresh-context fanout only when the task is broad enough.
40
+ • Call { action:"list" } first and use only executable/non-disabled agents.
41
+ • SINGLE {agent, task?}; SCRIPT {workflowScript:"..."} with stable-key runs.run for one child and runs.all for parallel work. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on a direct single child or runs.run/runs.all item for managed isolation instead of manual Git worktrees. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off/terminal/milestones.
42
+ • Example: {workflowScript:"const [a,b]=await runs.all([{key:'a',agent:'agent-a',task:'Implement A',worktree:true},{key:'b',agent:'agent-b',task:'Implement B',worktree:true}]); return [a.output,b.output]"}
43
+ • context can be fresh or fork. timeoutMs/maxRuntimeMs apply to foreground and async runs. Omit acceptance for reviewer/read-only calls.
89
44
 
90
45
  MANAGE / CONTROL:
91
- • Use action without execution fields: list, get, models, create, update, delete, eject, disable, enable, reset, grant-spawn-budget, doctor, watchdog.status, watchdog.check, watchdog.recommend-model, watchdog.configure.
92
- • Agent acceptanceRole (read-only or writer) affects inferred acceptance only, never tools. Explicit task intent wins; omission keeps name heuristics. Update with false or an empty string to clear it.
93
- • Async control actions: status, interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint. Use stop with an id for current-session top-level async runs. Use status view:"fleet" for active-run overview, view:"transcript" to tail child output, steer for acknowledged top-level live async guidance, and resume for paused/completed/failed revival or a routed nested follow-up. Stopped runs are non-resumable. Steering delivery means Pi accepted the correlated user input, not model compliance; use index for a specific child.
94
- • Opt-in schedule actions: schedule, schedule-list, schedule-status, schedule-cancel. Schedule only explicit delayed runs the user asked for.
95
-
96
- ASYNC / WAIT:
97
- • async:true detaches background work. Do not sleep or poll just to wait. In an interactive session, normally return control to the user and let Pi wake you on completion; do not call subagent_wait merely to wait. Override that default with subagent_wait only for run-to-completion requests (the user asked for results back this turn, or a skill must finish in one turn). Non-interactive runs (pi -p) auto-drain current-session work at agent_end; call subagent_wait when this turn must receive results before it ends. Otherwise continue useful work or respond.
98
- • Status and artifacts live under asyncId/asyncDir with status.json, events.jsonl, output logs, session files, and { action:"status", id:"..." }.
99
-
100
- SAFETY:
101
- • Ordinary child subagents are not orchestrators and must not run subagents. Only explicit fanout children may use child-safe subagent, still bounded by depth/session limits.
102
- • Keep one writer per cwd/worktree. Use fresh read-only review/validation fanout, then synthesize and apply fixes from the parent unless isolated worktrees were intentionally requested.`;
46
+ • Use action without execution fields for list/get/models/authoring, mission, watchdog, status, interrupt, stop, resume, steer, scheduling, diagnostics, and other management actions.
47
+ • append-step uses step:{...} only for an already-running durable legacy chain; step is not an execution mode.
48
+
49
+ ASYNC / SAFETY:
50
+ • Omitted async detaches background work. Do not sleep or poll merely to wait; use subagent_wait only when this turn must receive results.
51
+ • Ordinary children are not orchestrators. Keep one writer per cwd/worktree and use fresh read-only reviewers for independent checks.
52
+ • Status and artifacts live under asyncId/asyncDir with status.json, events.jsonl, output logs, and {action:"status",id:"..."}.`;
103
53
 
104
54
  function isToolDescriptionMode(value: unknown): value is ToolDescriptionMode {
105
55
  return value === "full" || value === "compact" || value === "custom";
@@ -0,0 +1,229 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
+ import { readMissionBinding } from "../../missions/lifecycle.ts";
6
+ import { listMissions, missionRecordPath, resolveMissionStoreLocation } from "../../missions/store.ts";
7
+ import type { MissionStoreConfig } from "../../missions/types.ts";
8
+ import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts";
9
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
10
+ import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
11
+ import { readStatus } from "../../shared/utils.ts";
12
+ import { resolveSubagentRunId } from "../../runs/background/run-id-resolver.ts";
13
+ import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode, type HerdrResult } from "./client.ts";
14
+
15
+ export const HERDR_INSPECTOR_ACTIONS = ["inspector.open", "inspector.status", "inspector.close"] as const;
16
+ export type HerdrInspectorAction = typeof HERDR_INSPECTOR_ACTIONS[number];
17
+
18
+ export interface HerdrInspectorBinding {
19
+ schemaVersion: 1;
20
+ kind: "herdr-inspector";
21
+ runId: string;
22
+ asyncDir: string;
23
+ childIndex?: number;
24
+ missionId?: string;
25
+ missionPath?: string;
26
+ paneId: string;
27
+ openedAt: string;
28
+ lastFocusedAt?: string;
29
+ herdrVersion?: string;
30
+ command: string;
31
+ }
32
+
33
+ interface InspectorParams {
34
+ id?: string;
35
+ runId?: string;
36
+ dir?: string;
37
+ index?: number;
38
+ focus?: boolean;
39
+ }
40
+
41
+ interface InspectorDeps {
42
+ state?: SubagentState;
43
+ asyncDirRoot?: string;
44
+ resultsDir?: string;
45
+ client?: HerdrClient;
46
+ missions?: MissionStoreConfig;
47
+ authorityPolicy?: AuthorityPolicyConfig;
48
+ cwd: string;
49
+ signal?: AbortSignal;
50
+ now?: () => Date;
51
+ runnerPath?: string;
52
+ }
53
+
54
+ function result(text: string, isError = false): AgentToolResult<Details> {
55
+ return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}), details: { mode: "management", results: [] } };
56
+ }
57
+
58
+ function formatHerdrError(input: { code: HerdrErrorCode; message: string }): string {
59
+ return `Herdr inspector error (${input.code}): ${input.message}`;
60
+ }
61
+
62
+ function bindingPath(asyncDir: string, index?: number): string {
63
+ return path.join(asyncDir, "inspectors", `herdr${index === undefined ? "" : `-${index}`}.json`);
64
+ }
65
+
66
+ function parseBinding(value: unknown): HerdrInspectorBinding | undefined {
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
68
+ const input = value as Partial<HerdrInspectorBinding>;
69
+ if (input.schemaVersion !== 1 || input.kind !== "herdr-inspector") return undefined;
70
+ if (typeof input.runId !== "string" || typeof input.asyncDir !== "string" || typeof input.paneId !== "string" || typeof input.openedAt !== "string" || typeof input.command !== "string") return undefined;
71
+ if (input.childIndex !== undefined && (!Number.isInteger(input.childIndex) || input.childIndex < 0)) return undefined;
72
+ return input as HerdrInspectorBinding;
73
+ }
74
+
75
+ export function readHerdrInspectorBinding(asyncDir: string, index?: number): HerdrInspectorBinding | undefined {
76
+ try { return parseBinding(JSON.parse(fs.readFileSync(bindingPath(asyncDir, index), "utf-8"))); } catch { return undefined; }
77
+ }
78
+
79
+ function extractPaneId(value: unknown): string | undefined {
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
81
+ const record = value as Record<string, unknown>;
82
+ const pane = record.pane && typeof record.pane === "object" && !Array.isArray(record.pane) ? record.pane as Record<string, unknown> : record;
83
+ for (const key of ["pane_id", "paneId", "id"]) if (typeof pane[key] === "string") return pane[key];
84
+ return undefined;
85
+ }
86
+
87
+ function shellQuote(value: string): string {
88
+ if (process.platform === "win32") return `"${value.replaceAll('"', '\\"')}"`;
89
+ return `'${value.replaceAll("'", "'\\''")}'`;
90
+ }
91
+
92
+ function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId: string; index?: number; missionPath?: string; allowSteer: boolean; allowStop: boolean }): string {
93
+ const args = [process.execPath, "--experimental-strip-types", input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop)];
94
+ if (input.index !== undefined) args.push("--index", String(input.index));
95
+ if (input.missionPath) args.push("--mission-path", input.missionPath);
96
+ return args.map(shellQuote).join(" ");
97
+ }
98
+
99
+ function missionForRun(asyncDir: string, cwd: string, config: MissionStoreConfig | undefined, runId: string): { id: string; path: string } | undefined {
100
+ try {
101
+ const binding = readMissionBinding(asyncDir);
102
+ if (binding) return { id: binding.missionId, path: missionRecordPath(binding.location, binding.missionId) };
103
+ const location = resolveMissionStoreLocation({ projectRoot: cwd, ...(config ? { config } : {}) });
104
+ const mission = listMissions(location).records.find((record) => record.runs.some((run) => run.runId === runId));
105
+ return mission ? { id: mission.id, path: missionRecordPath(location, mission.id) } : undefined;
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ function pathWithin(base: string, candidate: string): boolean {
112
+ const resolvedBase = path.resolve(base);
113
+ const resolvedCandidate = path.resolve(candidate);
114
+ return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
115
+ }
116
+
117
+ function isTrustedAsyncDir(asyncDir: string, deps: InspectorDeps): boolean {
118
+ try {
119
+ if (fs.lstatSync(asyncDir).isSymbolicLink() || !fs.statSync(asyncDir).isDirectory()) return false;
120
+ const realDir = fs.realpathSync(asyncDir);
121
+ const registered = [...(deps.state?.asyncJobs.values() ?? [])].some((job) => {
122
+ try { return fs.realpathSync(job.asyncDir) === realDir; } catch { return false; }
123
+ });
124
+ if (registered) return true;
125
+ const root = deps.asyncDirRoot ?? DIRS.async;
126
+ if (!fs.existsSync(root) || !pathWithin(root, asyncDir)) return false;
127
+ return pathWithin(fs.realpathSync(root), realDir);
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ function resolveAsyncTarget(params: InspectorParams, deps: InspectorDeps): { runId: string; asyncDir: string } | { error: string } {
134
+ const requestedId = params.id ?? params.runId;
135
+ if (params.dir) {
136
+ const asyncDir = path.resolve(params.dir);
137
+ if (!isTrustedAsyncDir(asyncDir, deps)) return { error: `Async run directory '${asyncDir}' is outside trusted run roots.` };
138
+ const status = readStatus(asyncDir);
139
+ if (!status) return { error: `No async run status found in '${asyncDir}'.` };
140
+ if (requestedId && requestedId !== status.runId && !status.runId.startsWith(requestedId)) return { error: `Run '${requestedId}' does not match status run '${status.runId}'.` };
141
+ return { runId: status.runId, asyncDir };
142
+ }
143
+ if (!requestedId) return { error: "Herdr inspector actions require id or dir." };
144
+ try {
145
+ const resolved = resolveSubagentRunId(requestedId, { state: deps.state, asyncDirRoot: deps.asyncDirRoot ?? DIRS.async, resultsDir: deps.resultsDir ?? DIRS.results });
146
+ if (!resolved) return { error: `No subagent run found for '${requestedId}'.` };
147
+ if (resolved.kind !== "async" || !resolved.location.asyncDir) return { error: `Run '${resolved.id}' is not an inspectable async run with lifecycle artifacts.` };
148
+ return { runId: resolved.id, asyncDir: resolved.location.asyncDir };
149
+ } catch (cause) {
150
+ return { error: cause instanceof Error ? cause.message : String(cause) };
151
+ }
152
+ }
153
+
154
+ async function paneExists(client: HerdrClient, paneId: string, signal?: AbortSignal): Promise<HerdrResult<unknown>> {
155
+ return client.run(["pane", "get", paneId], { timeoutMs: 5_000, signal });
156
+ }
157
+
158
+ export async function handleHerdrInspectorAction(action: HerdrInspectorAction, params: InspectorParams, deps: InspectorDeps): Promise<AgentToolResult<Details>> {
159
+ const target = resolveAsyncTarget(params, deps);
160
+ if ("error" in target) return result(target.error, true);
161
+ const status = readStatus(target.asyncDir);
162
+ if (!status) return result(`No lifecycle status exists for async run '${target.runId}'.`, true);
163
+ if (params.index !== undefined && (params.index < 0 || params.index >= (status.steps?.length ?? 0))) {
164
+ return result(`Async run '${target.runId}' has ${status.steps?.length ?? 0} children. Index ${params.index} is out of range.`, true);
165
+ }
166
+ const existing = readHerdrInspectorBinding(target.asyncDir, params.index);
167
+ const client = deps.client ?? createHerdrClient();
168
+
169
+ if (action === "inspector.status") {
170
+ if (!existing) return result(`No Herdr inspector binding exists for async run ${target.runId}${params.index === undefined ? "" : ` child ${params.index}`}.`);
171
+ const live = await paneExists(client, existing.paneId, deps.signal);
172
+ if (live.ok === false) return result(`${formatHerdrError(live.error)}\nBinding: ${bindingPath(target.asyncDir, params.index)}\nRun state remains authoritative: ${status.state}.`, true);
173
+ return result(`Herdr inspector ${existing.paneId} is open for async run ${target.runId}.\nRun state: ${status.state}\nBinding: ${bindingPath(target.asyncDir, params.index)}`);
174
+ }
175
+
176
+
177
+ if (action === "inspector.close") {
178
+ if (!existing) return result(`No Herdr inspector binding exists for async run ${target.runId}.`);
179
+ const closed = await client.run(["pane", "close", existing.paneId], { timeoutMs: 10_000, signal: deps.signal });
180
+ if (closed.ok === false && closed.error.code !== "NOT_FOUND" && closed.error.code !== "PANE_GONE") return result(formatHerdrError(closed.error), true);
181
+ fs.rmSync(bindingPath(target.asyncDir, params.index), { force: true });
182
+ return result(`Closed Herdr inspector pane ${existing.paneId} for async run ${target.runId}. The subagent run was not stopped.`);
183
+ }
184
+
185
+ const detected = await detectHerdr(client, deps.signal);
186
+ if (detected.ok === false) return result(formatHerdrError(detected.error), true);
187
+ if (existing) {
188
+ const live = await paneExists(client, existing.paneId, deps.signal);
189
+ if (live.ok) return result(`Herdr inspector pane ${existing.paneId} is already open for async run ${target.runId}.${params.focus ? " Herdr cannot refocus an arbitrary raw pane id; select it in the Herdr UI." : ""}`);
190
+ }
191
+ const splitArgs = ["pane", "split", "--current", "--direction", "right", "--cwd", status.cwd ?? deps.cwd];
192
+ if (params.focus !== false) splitArgs.push("--focus");
193
+ const split = await client.run(splitArgs, { timeoutMs: 15_000, signal: deps.signal });
194
+ if (split.ok === false) return result(formatHerdrError(split.error), true);
195
+ const paneId = extractPaneId(split.data);
196
+ if (!paneId) return result("Herdr inspector error (PANE_GONE): pane split returned no pane id.", true);
197
+ const mission = missionForRun(target.asyncDir, deps.cwd, deps.missions, target.runId);
198
+ const runnerPath = deps.runnerPath ?? fileURLToPath(new URL("./inspector-runner.ts", import.meta.url));
199
+ const command = inspectorCommand({
200
+ runnerPath,
201
+ asyncDir: target.asyncDir,
202
+ runId: target.runId,
203
+ index: params.index,
204
+ missionPath: mission?.path,
205
+ allowSteer: resolveAuthorityDecision({ action: "steerRun", policy: deps.authorityPolicy }) === "auto",
206
+ allowStop: resolveAuthorityDecision({ action: "stopRun", policy: deps.authorityPolicy }) === "auto",
207
+ });
208
+ const started = await client.run(["pane", "run", paneId, command], { timeoutMs: 15_000, signal: deps.signal });
209
+ if (started.ok === false) {
210
+ await client.run(["pane", "close", paneId], { timeoutMs: 5_000 });
211
+ return result(formatHerdrError(started.error), true);
212
+ }
213
+ const now = (deps.now?.() ?? new Date()).toISOString();
214
+ const binding: HerdrInspectorBinding = {
215
+ schemaVersion: 1,
216
+ kind: "herdr-inspector",
217
+ runId: target.runId,
218
+ asyncDir: target.asyncDir,
219
+ ...(params.index !== undefined ? { childIndex: params.index } : {}),
220
+ ...(mission ? { missionId: mission.id, missionPath: mission.path } : {}),
221
+ paneId,
222
+ openedAt: now,
223
+ ...(params.focus !== false ? { lastFocusedAt: now } : {}),
224
+ herdrVersion: detected.data.versionText,
225
+ command,
226
+ };
227
+ writeAtomicJson(bindingPath(target.asyncDir, params.index), binding);
228
+ return result(`Opened read-only Herdr inspector pane ${paneId} for async run ${target.runId}. Closing the pane does not stop the run.\nControls inside the pane: steer <message>, stop, status.`);
229
+ }