pi-subagents 0.48.0 → 0.50.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 (88) hide show
  1. package/CHANGELOG.md +82 -19
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +3 -0
  8. package/docs/configuration.md +75 -8
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +5 -3
  11. package/docs/observability.md +42 -2
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +37 -12
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +13 -7
  23. package/src/extension/config.ts +53 -0
  24. package/src/extension/index.ts +62 -9
  25. package/src/extension/public-execution.ts +32 -4
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +10 -8
  28. package/src/extension/tool-description.ts +13 -9
  29. package/src/inspectors/herdr/actions.ts +13 -8
  30. package/src/inspectors/herdr/inspector-runner.ts +16 -3
  31. package/src/inspectors/herdr/project-panes.ts +2 -6
  32. package/src/inspectors/herdr/shell-command.ts +16 -0
  33. package/src/intercom/intercom-bridge.ts +5 -4
  34. package/src/intercom/native-supervisor-channel.ts +19 -42
  35. package/src/missions/goal-driver.ts +3 -1
  36. package/src/missions/store.ts +8 -3
  37. package/src/runs/background/active-async-capacity.ts +82 -25
  38. package/src/runs/background/active-run-index.ts +71 -1
  39. package/src/runs/background/async-execution.ts +73 -43
  40. package/src/runs/background/async-job-tracker.ts +5 -0
  41. package/src/runs/background/async-resume.ts +14 -6
  42. package/src/runs/background/async-status-snapshot.ts +277 -0
  43. package/src/runs/background/async-status.ts +8 -3
  44. package/src/runs/background/chain-root-attachment.ts +2 -2
  45. package/src/runs/background/completion-replay.ts +11 -1
  46. package/src/runs/background/fleet-view.ts +21 -6
  47. package/src/runs/background/result-files.ts +437 -0
  48. package/src/runs/background/result-watcher.ts +188 -41
  49. package/src/runs/background/resume-guidance.ts +27 -7
  50. package/src/runs/background/retained-children.ts +75 -18
  51. package/src/runs/background/run-id-resolver.ts +30 -24
  52. package/src/runs/background/run-status.ts +101 -4
  53. package/src/runs/background/scheduled-runs.ts +54 -28
  54. package/src/runs/background/stale-run-reconciler.ts +27 -13
  55. package/src/runs/background/subagent-runner.ts +298 -33
  56. package/src/runs/background/subagent-wait.ts +2 -0
  57. package/src/runs/background/wait-completions.ts +5 -2
  58. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  59. package/src/runs/foreground/chain-execution.ts +16 -0
  60. package/src/runs/foreground/execution.ts +219 -15
  61. package/src/runs/foreground/prompt-audit.ts +4 -3
  62. package/src/runs/foreground/subagent-executor.ts +328 -43
  63. package/src/runs/shared/completion-guard.ts +107 -1
  64. package/src/runs/shared/external-cli-runner.ts +4 -0
  65. package/src/runs/shared/llm-intent-arbiter.ts +39 -23
  66. package/src/runs/shared/model-fallback.ts +16 -2
  67. package/src/runs/shared/nested-events.ts +66 -62
  68. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  69. package/src/runs/shared/parallel-utils.ts +2 -0
  70. package/src/runs/shared/subagent-control.ts +15 -0
  71. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  72. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  73. package/src/runs/shared/tool-timeout.ts +93 -0
  74. package/src/shared/agent-stream-options.ts +5 -0
  75. package/src/shared/artifacts.ts +2 -6
  76. package/src/shared/display-text.ts +50 -0
  77. package/src/shared/node-executable.ts +21 -0
  78. package/src/shared/types.ts +51 -5
  79. package/src/slash/slash-commands.ts +34 -25
  80. package/src/slash/slash-live-state.ts +3 -0
  81. package/src/tui/fleet-status.ts +160 -45
  82. package/src/tui/fleet-transcript.ts +1 -48
  83. package/src/tui/fleet.ts +128 -16
  84. package/src/tui/render.ts +122 -44
  85. package/src/watchdog/permission-arbiter.ts +2 -1
  86. package/src/watchdog/review.ts +4 -3
  87. package/src/workflows/chat-progress.ts +10 -2
  88. package/src/workflows/scripted-workflow.ts +272 -76
@@ -31,6 +31,7 @@ import { createSubagentParamsSchema } from "./schemas.ts";
31
31
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
32
32
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
33
33
  import { getActiveAsyncCapacitySnapshot, resolveMaxActiveAsyncRunsPerSession } from "../runs/background/active-async-capacity.ts";
34
+ import { cleanupResultIndexes } from "../runs/background/result-files.ts";
34
35
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
35
36
  import { createScheduledRunManager } from "../runs/background/scheduled-runs.ts";
36
37
  import { registerSlashCommands } from "../slash/slash-commands.ts";
@@ -59,6 +60,7 @@ import { resolveMissionStoreLocation } from "../missions/store.ts";
59
60
  import { listRetainedChildren } from "../runs/background/retained-children.ts";
60
61
  import {
61
62
  type Details,
63
+ type MainWindowRendererConfig,
62
64
  type SubagentState,
63
65
  DIRS,
64
66
  DEFAULT_ARTIFACT_CONFIG,
@@ -81,6 +83,13 @@ import {
81
83
 
82
84
  export { loadConfig, resolveAsyncByDefault } from "./config.ts";
83
85
 
86
+ const SLOW_RELOAD_PHASE_MS = 250;
87
+
88
+ function logSlowPhase(label: string, startedAt: number): void {
89
+ const elapsed = Date.now() - startedAt;
90
+ if (elapsed >= SLOW_RELOAD_PHASE_MS) console.error(`Subagent reload phase '${label}' took ${elapsed}ms.`);
91
+ }
92
+
84
93
  function workflowLaneKeys(script: string): string[] {
85
94
  const keys: string[] = [];
86
95
  const seen = new Set<string>();
@@ -253,12 +262,14 @@ function rebuildSlashResultContainer(
253
262
  result: AgentToolResult<Details>,
254
263
  options: { expanded: boolean },
255
264
  theme: ExtensionContext["ui"]["theme"],
265
+ rendererConfig?: MainWindowRendererConfig,
266
+ foregroundDetachShortcut?: string,
256
267
  ): void {
257
268
  container.clear();
258
269
  container.addChild(new Spacer(1));
259
270
  const boxTheme = isSlashResultRunning(result) ? "toolPendingBg" : isSlashResultError(result) ? "toolErrorBg" : "toolSuccessBg";
260
271
  const box = new Box(1, 1, (text: string) => theme.bg(boxTheme, text));
261
- box.addChild(renderSubagentResult(result, options, theme));
272
+ box.addChild(renderSubagentResult(result, options, theme, undefined, rendererConfig, foregroundDetachShortcut));
262
273
  container.addChild(box);
263
274
  }
264
275
 
@@ -266,6 +277,8 @@ function createSlashResultComponent(
266
277
  details: SlashMessageDetails,
267
278
  options: { expanded: boolean },
268
279
  theme: ExtensionContext["ui"]["theme"],
280
+ rendererConfig?: MainWindowRendererConfig,
281
+ foregroundDetachShortcut?: string,
269
282
  ): Container {
270
283
  const container = new Container();
271
284
  let lastVersion = -1;
@@ -273,7 +286,7 @@ function createSlashResultComponent(
273
286
  const snapshot = getSlashRenderableSnapshot(details);
274
287
  if (snapshot.version !== lastVersion || isSlashResultRunning(snapshot.result)) {
275
288
  lastVersion = snapshot.version;
276
- rebuildSlashResultContainer(container, snapshot.result, options, theme);
289
+ rebuildSlashResultContainer(container, snapshot.result, options, theme, rendererConfig, foregroundDetachShortcut);
277
290
  }
278
291
  return Container.prototype.render.call(container, width);
279
292
  };
@@ -370,6 +383,14 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
370
383
  const tempArtifactsDir = getArtifactsDir(null);
371
384
  const artifactCleanupDays = config.artifactConfig?.cleanupDays ?? DEFAULT_ARTIFACT_CONFIG.cleanupDays;
372
385
  cleanupAllArtifactDirs(artifactCleanupDays);
386
+ const resultIndexCleanupTimer = setTimeout(() => {
387
+ try {
388
+ cleanupResultIndexes(DIRS.results);
389
+ } catch (error) {
390
+ console.error("Failed to clean stale subagent result indexes:", error);
391
+ }
392
+ }, 30_000);
393
+ resultIndexCleanupTimer.unref?.();
373
394
 
374
395
  const state: SubagentState = {
375
396
  baseCwd: "",
@@ -378,6 +399,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
378
399
  ...(config.authorityPolicy ? { authorityPolicy: config.authorityPolicy } : {}),
379
400
  ...(config.missions ? { missionStoreConfig: config.missions } : {}),
380
401
  parentSessionFile: null,
402
+ trustedSessionRoots: [],
381
403
  subagentInProgress: false,
382
404
  subagentSpawns: {
383
405
  sessionId: null,
@@ -451,6 +473,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
451
473
  );
452
474
 
453
475
  const runtimeCleanup = () => {
476
+ clearTimeout(resultIndexCleanupTimer);
454
477
  stopResultWatcher();
455
478
  state.currentSessionId = null;
456
479
  completionNotifier.dispose();
@@ -481,7 +504,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
481
504
  pi.registerMessageRenderer<SlashMessageDetails>(SLASH_RESULT_TYPE, (message, options, theme) => {
482
505
  const details = resolveSlashMessageDetails(message.details);
483
506
  if (!details) return undefined;
484
- return createSlashResultComponent(details, options, theme);
507
+ return createSlashResultComponent(details, options, theme, config.mainWindowRenderer, config.foregroundDetachShortcut);
485
508
  });
486
509
 
487
510
  pi.registerMessageRenderer<undefined>(SLASH_TEXT_RESULT_TYPE, (message, _options, _theme) => {
@@ -581,22 +604,24 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
581
604
  },
582
605
 
583
606
  renderCall(args, theme) {
607
+ const gap = " ".repeat(config.mainWindowRenderer?.horizontalSpacing ?? 1);
608
+ const title = theme.fg("toolTitle", theme.bold("subagent"));
584
609
  if (args.action) {
585
610
  const target = args.agent || args.chainName || "";
586
611
  return new Text(
587
- `${theme.fg("toolTitle", theme.bold("subagent "))}${args.action}${target ? ` ${theme.fg("accent", target)}` : ""}`,
612
+ `${title}${gap}${args.action}${target ? `${gap}${theme.fg("accent", target)}` : ""}`,
588
613
  0, 0,
589
614
  );
590
615
  }
591
616
  if (args.workflowScript)
592
617
  return new Text(
593
- `${theme.fg("toolTitle", theme.bold("subagent "))}${formatWorkflowManifest(args.workflowScript, args.async, false)}`,
618
+ `${title}${gap}${formatWorkflowManifest(args.workflowScript, args.async, false)}`,
594
619
  0,
595
620
  0,
596
621
  );
597
- const asyncLabel = args.async === true ? theme.fg("warning", " [async]") : "";
622
+ const asyncLabel = args.async === true ? `${gap}${theme.fg("warning", "[async]")}` : "";
598
623
  return new Text(
599
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent || "?")}${asyncLabel}`,
624
+ `${title}${gap}${theme.fg("accent", args.agent || "?")}${asyncLabel}`,
600
625
  0,
601
626
  0,
602
627
  );
@@ -607,7 +632,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
607
632
  const renderedResult = { ...result, isError: context.isError };
608
633
  return summaryInlineToolDisplay
609
634
  ? renderSubagentSummary(renderedResult, options, theme)
610
- : renderSubagentResult(renderedResult, options, theme);
635
+ : renderSubagentResult(renderedResult, options, theme, undefined, config.mainWindowRenderer, config.foregroundDetachShortcut);
611
636
  },
612
637
 
613
638
  };
@@ -637,7 +662,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
637
662
  }
638
663
  });
639
664
 
640
- registerSlashCommands(pi, state, { fleetKeybindings: config.fleetKeybindings });
665
+ registerSlashCommands(pi, state, {
666
+ fleetKeybindings: config.fleetKeybindings,
667
+ foregroundDetachShortcut: config.foregroundDetachShortcut,
668
+ });
641
669
 
642
670
  const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
643
671
  const controlNoticeSeenStoreKey = "__piSubagentVisibleControlNotices";
@@ -754,6 +782,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
754
782
  goalTurnId = 0;
755
783
  state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
756
784
  state.parentSessionFile = ctx.sessionManager.getSessionFile();
785
+ state.trustedSessionRoots = [...new Set([
786
+ ...(config.defaultSessionDir ? [path.resolve(expandTilde(config.defaultSessionDir))] : []),
787
+ ...(state.parentSessionFile ? [getSubagentSessionRoot(state.parentSessionFile)] : []),
788
+ ])];
757
789
  state.subagentSpawns = {
758
790
  sessionId: state.currentSessionId,
759
791
  count: 0,
@@ -773,18 +805,38 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
773
805
  }
774
806
  }
775
807
  state.lastUiContext = ctx;
808
+ let phaseStartedAt = Date.now();
776
809
  refreshActiveAsyncCapacity();
810
+ logSlowPhase("active-capacity", phaseStartedAt);
811
+ phaseStartedAt = Date.now();
777
812
  cleanupSessionArtifacts(ctx);
813
+ logSlowPhase("session-artifact-cleanup", phaseStartedAt);
778
814
  state.foregroundControls.clear();
779
815
  state.lastForegroundControlId = null;
816
+ phaseStartedAt = Date.now();
780
817
  resetJobs(ctx);
818
+ logSlowPhase("reset-jobs", phaseStartedAt);
819
+ phaseStartedAt = Date.now();
781
820
  restoreForegroundRunHistory(state, { resultsDir: DIRS.results });
821
+ logSlowPhase("foreground-history", phaseStartedAt);
822
+ phaseStartedAt = Date.now();
782
823
  restoreActiveJobs(ctx);
824
+ logSlowPhase("active-job-restore", phaseStartedAt);
825
+ phaseStartedAt = Date.now();
783
826
  scheduledRunManager.bindSession(ctx);
827
+ logSlowPhase("scheduled-runs", phaseStartedAt);
828
+ phaseStartedAt = Date.now();
784
829
  restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
830
+ logSlowPhase("slash-snapshots", phaseStartedAt);
831
+ phaseStartedAt = Date.now();
785
832
  waitSubscriptionManager.restore();
833
+ logSlowPhase("wait-subscriptions", phaseStartedAt);
834
+ phaseStartedAt = Date.now();
786
835
  startResultWatcher();
836
+ logSlowPhase("result-watcher-start", phaseStartedAt);
837
+ phaseStartedAt = Date.now();
787
838
  primeExistingResults({ triggerTurn: !recovering });
839
+ logSlowPhase("result-prime", phaseStartedAt);
788
840
  fleetStatus?.setContext(ctx);
789
841
  };
790
842
 
@@ -827,6 +879,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
827
879
 
828
880
  pi.on("session_shutdown", async () => {
829
881
  state.widgetsSuspended = false;
882
+ clearTimeout(resultIndexCleanupTimer);
830
883
  stopResultWatcher();
831
884
  state.currentSessionId = null;
832
885
  state.parentSessionFile = null;
@@ -9,6 +9,7 @@ export interface PublicSubagentExecutionParams {
9
9
  concurrency?: unknown;
10
10
  chainDir?: unknown;
11
11
  workflowScript?: unknown;
12
+ output?: unknown;
12
13
  resume?: unknown;
13
14
  clarify?: unknown;
14
15
  runFanoutBudget?: unknown;
@@ -47,7 +48,7 @@ export function normalizePublicSubagentExecution<T extends PublicSubagentExecuti
47
48
  if (normalizedAction !== undefined) {
48
49
  const legacyAction = normalizedAction.toLowerCase();
49
50
  if (legacyAction === "single") {
50
- return { ok: false, error: "Direct execution was removed. Use workflowScript: \"return runs.run('main', { agent, task })\".", mode: "workflow" };
51
+ return { ok: false, error: "action='single' is not supported. Omit action and pass { agent, task } for one child.", mode: "workflow" };
51
52
  }
52
53
  if (legacyAction === "parallel" || legacyAction === "tasks" || legacyAction === "chain") {
53
54
  return { ok: false, error: "Legacy top-level chain and parallel inputs were removed; use workflowScript.", mode: "workflow" };
@@ -64,13 +65,40 @@ export function normalizePublicSubagentExecution<T extends PublicSubagentExecuti
64
65
  if (params.workflowScript !== undefined) {
65
66
  return { ok: false, error: "workflowScript execution must omit action; only schedule.create accepts action with workflowScript.", mode: "management" };
66
67
  }
68
+ if (params.task !== undefined) {
69
+ return { ok: false, error: "Structured single-child task cannot be combined with a management/control action.", mode: "management" };
70
+ }
67
71
  return { ok: true, params: { ...params, action: normalizedAction } };
68
72
  }
69
- if (params.agent !== undefined || params.task !== undefined || params.step !== undefined) {
70
- return { ok: false, error: "Direct execution was removed. Use workflowScript: \"return runs.run('main', { agent, task })\".", mode: "workflow" };
73
+ if (params.step !== undefined) {
74
+ return { ok: false, error: "step is only available with action='append-step'; it is not an execution mode.", mode: "workflow" };
75
+ }
76
+ if (params.workflowScript !== undefined && (params.agent !== undefined || params.task !== undefined)) {
77
+ return { ok: false, error: "Structured single-child execution cannot be combined with workflowScript.", mode: "workflow" };
78
+ }
79
+ if (params.agent !== undefined || params.task !== undefined) {
80
+ if (typeof params.agent !== "string" || !params.agent.trim()) {
81
+ return { ok: false, error: "Structured single-child execution requires agent to be a non-empty string.", mode: "workflow" };
82
+ }
83
+ if (params.task !== undefined && typeof params.task !== "string") {
84
+ return { ok: false, error: "Structured single-child task must be a string when provided.", mode: "workflow" };
85
+ }
86
+ const { agent: _agent, task: _task, output, ...workflowDefaults } = params;
87
+ const child = {
88
+ agent: params.agent.trim(),
89
+ ...(params.task !== undefined ? { task: params.task } : {}),
90
+ output: output === undefined ? true : output,
91
+ };
92
+ return {
93
+ ok: true,
94
+ params: {
95
+ ...workflowDefaults,
96
+ workflowScript: `console.info("Converted structured single-child request to workflow runs.run('main', ...)."); return runs.run("main", ${JSON.stringify(child)})`,
97
+ } as T,
98
+ };
71
99
  }
72
100
  if (typeof params.workflowScript !== "string" || !params.workflowScript.trim()) {
73
- return { ok: false, error: "Execution requires a non-empty workflowScript. Direct execution was removed; use workflowScript: \"return runs.run('main', { agent, task })\".", mode: "workflow" };
101
+ return { ok: false, error: "Execution requires either { agent, task? } for one child or a non-empty workflowScript for orchestration.", mode: "workflow" };
74
102
  }
75
103
  return { ok: true, params };
76
104
  }
@@ -20,6 +20,7 @@ import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text
20
20
  import { readStatus } from "../shared/utils.ts";
21
21
  import { SubagentParams } from "./schemas.ts";
22
22
  import { normalizePublicSubagentExecution } from "./public-execution.ts";
23
+ import { ASYNC_STATUS_SNAPSHOT_KIND, ASYNC_STATUS_SNAPSHOT_VERSION, buildAsyncStatusSnapshotForState } from "../runs/background/async-status-snapshot.ts";
23
24
 
24
25
  export const SUBAGENT_RPC_PROTOCOL_VERSION = 1;
25
26
  export const SUBAGENT_RPC_REQUEST_EVENT = "subagents:rpc:v1:request";
@@ -375,6 +376,7 @@ function pingData(ctx: ExtensionContext | null) {
375
376
  capabilities: {
376
377
  status: true,
377
378
  fleetStatus: { version: 1 },
379
+ asyncStatusSnapshot: { kind: ASYNC_STATUS_SNAPSHOT_KIND, version: ASYNC_STATUS_SNAPSHOT_VERSION },
378
380
  asyncSpawn: true,
379
381
  steer: true,
380
382
  nonRecoveringSteer: true,
@@ -544,13 +546,15 @@ async function handleRequest(
544
546
  request.method,
545
547
  { action: "status", ...normalizeTargetParams(request.params, "status") },
546
548
  );
549
+ const sessionId = resolveCurrentSessionId(ctx.sessionManager);
547
550
  return {
548
551
  ...status,
549
552
  fleet: buildFleetStatus(
550
553
  options.state,
551
554
  fleetKeys,
552
- resolveCurrentSessionId(ctx.sessionManager),
555
+ sessionId,
553
556
  ),
557
+ asyncSnapshot: buildAsyncStatusSnapshotForState(options.state, sessionId),
554
558
  };
555
559
  }
556
560
  if (request.method === "steer") {
@@ -93,7 +93,7 @@ const AcceptanceOverride = Type.Unsafe({
93
93
  });
94
94
 
95
95
  const AgentContractOverride = Type.Object({
96
- version: Type.Integer({ enum: [1], description: "Enable compatibility behavior for this run/child." }),
96
+ version: Type.Integer({ minimum: 1, maximum: 1, description: "Enable compatibility behavior for this run/child." }),
97
97
  }, { additionalProperties: false, description: "Compatibility behavior. Omit for the default behavior." });
98
98
 
99
99
  const ChainGateOverride = Type.String({
@@ -255,21 +255,22 @@ const ControlOverrides = Type.Object({
255
255
  });
256
256
 
257
257
  const SubagentParamProperties = {
258
- agent: Type.Optional(Type.String({ description: "Agent target for management actions such as get, update, delete, and models." })),
258
+ agent: Type.Optional(Type.String({ description: "Agent for one-child execution, or target for agent management actions." })),
259
+ task: Type.Optional(Type.String({ description: "Optional one-child task. Requires agent; cannot combine with action or workflowScript." })),
259
260
  resume: Type.Optional(Type.String({ description: "Retained child run id for a workflowScript runs.run/runs.all item. Mutually exclusive with agent; task supplies the follow-up." })),
260
261
  // Management action (when present, tool operates in management mode)
261
262
  action: Type.Optional(Type.String({ minLength: 1,
262
- description: "Optional management/control action. Omit this field for workflowScript execution; use it only for management/control actions."
263
+ description: "Optional management/control action. Omit this field for structured single-child or workflowScript execution; use it only for management/control actions."
263
264
  })),
264
265
  name: Type.Optional(Type.String({ description: "Human-readable name for action='schedule.create'." })),
265
266
  id: Type.Optional(Type.String({
266
- description: "Run id or prefix for status, interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, mission.attach-run, or the decision id for mission.resolve-decision."
267
+ description: "Run id/prefix for status/debug.run, interrupt, steer, append-step, approve-checkpoint, reject-checkpoint, or mission."
267
268
  })),
268
269
  runId: Type.Optional(Type.String({
269
- description: "Target run ID for interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
270
+ description: "Target run ID for debug.run, interrupt, steer, append-step, or mission.attach-run. Prefer id."
270
271
  })),
271
272
  dir: Type.Optional(Type.String({
272
- description: "Async run directory for action='status', action='stop', action='resume', or action='steer'."
273
+ description: "Async run directory for status/debug.run, stop, resume, or steer."
273
274
  })),
274
275
  handoffPath: Type.Optional(Type.String({ description: "worktree.discard manifest." })),
275
276
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based child index for actions that target a specific child or transcript." })),
@@ -326,6 +327,7 @@ const SubagentParamProperties = {
326
327
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
327
328
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Timeout. Foreground and single async runs use config timeoutMs, else 30m; async composites have no default parent deadline. Alias maxRuntimeMs." })),
328
329
  maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs. Foreground and single async runs use config timeoutMs, else 30m; async composites have no default parent deadline." })),
330
+ toolTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Optional hard per-tool-call timeout in milliseconds; known-fast built-in tools have a five-minute default." })),
329
331
  turnBudget: Type.Optional(TurnBudgetOverride),
330
332
  toolBudget: Type.Optional(ToolBudgetOverride),
331
333
  usageBudget: Type.Optional(UsageBudgetOverride),
@@ -359,10 +361,10 @@ const { step: _legacyChainStep, ...subagentParamPropertiesWithoutStep } = Subage
359
361
  const trimmedSubagentParamProperties = {
360
362
  ...subagentParamPropertiesWithoutStep,
361
363
  id: Type.Optional(Type.String({
362
- description: "Run id or prefix for status, interrupt, stop, resume, steer, mission.attach-run, or the decision id for mission.resolve-decision."
364
+ description: "Run id/prefix for status/debug.run, interrupt, steer, or mission.attach-run."
363
365
  })),
364
366
  runId: Type.Optional(Type.String({
365
- description: "Target run ID for interrupt, stop, resume, steer, or mission.attach-run. Prefer id for new calls."
367
+ description: "Target run ID for debug.run, interrupt, steer, or mission.attach-run. Prefer id."
366
368
  })),
367
369
  };
368
370
  const SubagentParamsSchema = Type.Object(SubagentParamProperties);
@@ -8,24 +8,26 @@ const CUSTOM_TOOL_DESCRIPTION_MAX_BYTES = 50 * 1024;
8
8
 
9
9
  export const SUBAGENT_SAFETY_GUIDANCE = `SAFETY-CRITICAL SUBAGENT GUIDANCE:
10
10
  • Use { action: "list" } before execution and only run executable/non-disabled agents.
11
- • Keep execution and management separate: omit action for 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.
11
+ • Keep execution and management separate: omit action for structured single-child or 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. After an async launch, continue independent work only until its next dependency barrier; consume the result before work that depends on it. Do not sleep or poll status just to wait; use subagent_wait only when the current request must finish in this turn.
13
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
+ • Oracle/advisor consultations should use supervisor dialogue for material unknowns when available; request one-shot only when desired.
14
15
  • 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
+ • Async runs expose asyncId/asyncDir with status.json, events.jsonl, output logs, status via { action: "status", id }, and lifecycle diagnostics via { action: "debug.run", id }. Include output paths and residual risks when reporting results.`;
16
17
 
17
- export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { workflowScript }; omit action. Use action only for management/control actions.
18
+ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for orchestration. Omit action for execution. Use action only for management/control actions.
18
19
 
19
20
  EXECUTION:
20
21
  • Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
21
- • WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. 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, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use await prompts.render("package:name" | "user:name" | "project:name", vars?) for reusable plain task text, then pass the result explicitly as task. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, resume keeps the stored agent/model/tool contract, workflow resumes wait for completed output, and loops must continue from each latest returned runId. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; 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, prompts.render, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
22
+ • SINGLE CHILD: { agent:"worker", task:"..." }. This structured form starts exactly one child through the workflow runtime. Workflow-level fields such as model, context, cwd, worktree, output, budgets, acceptance, and async remain defaults for that child. Do not combine agent/task with action or workflowScript.
23
+ • WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. 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, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use await prompts.render("package:name" | "user:name" | "project:name", vars?) for reusable plain task text, then pass the result explicitly as task. Use {action:"children.list"} to list recent retained workflow children with resumable/not-resumable reasons. Resume only rows reported resumable. For a simple follow-up or implementation challenge, use {action:"resume", id:"run-id", message:"..."}. Resume keeps the stored agent/model/tool contract. If no resumable child is listed, launch a same-role fallback challenge and label it as fallback. Inside workflowScript, continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); workflow resumes wait for completed output, and loops must continue from each latest returned runId. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; 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, prompts.render, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
22
24
  • Sequential example: { 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" }
23
25
  • Parallel example: { 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}" }
24
26
  • Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
25
27
  • 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. A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
26
28
 
27
29
  MANAGEMENT / CONTROL (use action; omit execution fields):
28
- • list, get, models, guide, children.list, create, update, delete, eject, disable, enable, reset, doctor, grant-spawn-budget, worktree.discard, refine/refine.show/refine.rollback, mission.create/list/show/update/resolve-decision/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available. Use {action:"guide", topic:"overview"} for packaged current-version help; topics are overview, workflows, agents, missions, observability, tool-reference, configuration, models, watchdog, and extension-api.
30
+ • list, get, models, guide, children.list, create, update, delete, eject, disable, enable, reset, status, debug.run, doctor, grant-spawn-budget, worktree.discard, refine/refine.show/refine.rollback, mission.create/list/show/update/resolve-decision/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available. Use {action:"guide", topic:"overview"} for packaged current-version help; topics are overview, workflows, agents, missions, observability, tool-reference, configuration, models, watchdog, and extension-api.
29
31
  • 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.
30
32
  • { 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.
31
33
  • approve-checkpoint and reject-checkpoint decide a paused durable legacy chain checkpoint.
@@ -33,11 +35,12 @@ MANAGEMENT / CONTROL (use action; omit execution fields):
33
35
 
34
36
  ${SUBAGENT_SAFETY_GUIDANCE}`;
35
37
 
36
- export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { workflowScript }; omit action. Use action only for management/control actions.
38
+ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for orchestration. Omit action for execution. Use action only for management/control actions.
37
39
 
38
40
  EXECUTE:
39
41
  • Call { action:"list" } first and use only executable/non-disabled agents.
40
- • SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use await prompts.render("package:name" | "user:name" | "project:name", vars?) for reusable task text and pass it explicitly to runs.run. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract; workflow resumes wait for completion and loops continue from the latest returned runId. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. 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.
42
+ • SINGLE {agent:"worker",task:"..."} starts exactly one child through the workflow runtime. Workflow-level fields remain child defaults. Do not combine agent/task with action or workflowScript.
43
+ • SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use await prompts.render("package:name" | "user:name" | "project:name", vars?) for reusable task text and pass it explicitly to runs.run. Use {action:"children.list"} for recent retained workflow children and resume only rows reported resumable. Use {action:"resume",id:"run-id",message:"..."} for a simple follow-up or challenge; resume keeps the stored agent/model/tool contract. If none is resumable, launch a same-role fallback challenge and label it as fallback. Inside workflowScript use runs.run(key,{resume:"run-id",task:"follow-up"}) when the script must wait for completion and continue from the latest returned runId. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. 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.
41
44
  • 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]"}
42
45
  • context can be fresh or fork. timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls.
43
46
 
@@ -47,8 +50,9 @@ MANAGE / CONTROL:
47
50
  • A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
48
51
 
49
52
  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.
53
+ • Omitted async detaches background work. Continue independent work only until its next dependency barrier; consume the result before work that depends on it. Do not sleep or poll merely to wait; use subagent_wait only when this turn must receive results.
51
54
  • Ordinary children are not orchestrators. Keep one writer per cwd/worktree and use fresh read-only reviewers for independent checks.
55
+ • Oracle/advisor consultations use available supervisor dialogue for material unknowns; request one-shot when desired.
52
56
  • Status and artifacts live under asyncId/asyncDir with status.json, events.jsonl, output logs, and {action:"status",id:"..."}.`;
53
57
 
54
58
 
@@ -10,7 +10,9 @@ import { writeAtomicJson } from "../../shared/atomic-json.ts";
10
10
  import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
11
11
  import { readStatus } from "../../shared/utils.ts";
12
12
  import { resolveSubagentRunId } from "../../runs/background/run-id-resolver.ts";
13
+ import { resolveNodeExecutable } from "../../shared/node-executable.ts";
13
14
  import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode, type HerdrResult } from "./client.ts";
15
+ import { formatShellCommand } from "./shell-command.ts";
14
16
 
15
17
  export const HERDR_INSPECTOR_ACTIONS = ["inspector.open", "inspector.status", "inspector.close"] as const;
16
18
  export type HerdrInspectorAction = typeof HERDR_INSPECTOR_ACTIONS[number];
@@ -45,6 +47,7 @@ interface InspectorDeps {
45
47
  client?: HerdrClient;
46
48
  missions?: MissionStoreConfig;
47
49
  authorityPolicy?: AuthorityPolicyConfig;
50
+ sessionRoots?: string[];
48
51
  cwd: string;
49
52
  signal?: AbortSignal;
50
53
  now?: () => Date;
@@ -84,16 +87,11 @@ function extractPaneId(value: unknown): string | undefined {
84
87
  return undefined;
85
88
  }
86
89
 
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, input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop)];
90
+ function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId: string; index?: number; missionPath?: string; allowSteer: boolean; allowStop: boolean; sessionRoots: string[] }): string {
91
+ const args = [input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop), "--session-roots", JSON.stringify(input.sessionRoots)];
94
92
  if (input.index !== undefined) args.push("--index", String(input.index));
95
93
  if (input.missionPath) args.push("--mission-path", input.missionPath);
96
- return `${process.platform === "win32" ? "& " : ""}${args.map(shellQuote).join(" ")}`;
94
+ return formatShellCommand(resolveNodeExecutable(), args);
97
95
  }
98
96
 
99
97
  function missionForRun(asyncDir: string, cwd: string, config: MissionStoreConfig | undefined, runId: string): { id: string; path: string } | undefined {
@@ -114,6 +112,12 @@ function pathWithin(base: string, candidate: string): boolean {
114
112
  return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
115
113
  }
116
114
 
115
+ function herdrSessionRoots(target: { runId: string }, deps: InspectorDeps): string[] {
116
+ const roots = deps.sessionRoots ?? deps.state?.trustedSessionRoots ?? [];
117
+ const job = deps.state?.asyncJobs.get(target.runId) ?? deps.state?.fleetJobs?.get(target.runId);
118
+ return [...new Set([...roots, ...(job?.sessionRoot ? [job.sessionRoot] : [])])];
119
+ }
120
+
117
121
  function isTrustedAsyncDir(asyncDir: string, deps: InspectorDeps): boolean {
118
122
  try {
119
123
  if (fs.lstatSync(asyncDir).isSymbolicLink() || !fs.statSync(asyncDir).isDirectory()) return false;
@@ -204,6 +208,7 @@ export async function handleHerdrInspectorAction(action: HerdrInspectorAction, p
204
208
  missionPath: mission?.path,
205
209
  allowSteer: resolveAuthorityDecision({ action: "steerRun", policy: deps.authorityPolicy }) === "auto",
206
210
  allowStop: resolveAuthorityDecision({ action: "stopRun", policy: deps.authorityPolicy }) === "auto",
211
+ sessionRoots: herdrSessionRoots(target, deps),
207
212
  });
208
213
  const started = await client.run(["pane", "run", paneId, command], { timeoutMs: 15_000, signal: deps.signal });
209
214
  if (started.ok === false) {
@@ -17,6 +17,7 @@ export interface RunnerOptions {
17
17
  refreshMs: number;
18
18
  allowSteer?: boolean;
19
19
  allowStop?: boolean;
20
+ sessionRoots: string[];
20
21
  }
21
22
 
22
23
  function readMission(filePath: string | undefined): MissionRecord | undefined {
@@ -24,7 +25,7 @@ function readMission(filePath: string | undefined): MissionRecord | undefined {
24
25
  try { return parseMissionRecord(JSON.parse(fs.readFileSync(filePath, "utf-8")), filePath); } catch { return undefined; }
25
26
  }
26
27
 
27
- export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir: string; index?: number; mission?: MissionRecord; allowSteer?: boolean; allowStop?: boolean }): string {
28
+ export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir: string; index?: number; mission?: MissionRecord; allowSteer?: boolean; allowStop?: boolean; sessionRoots?: string[] }): string {
28
29
  const { status, asyncDir, mission } = input;
29
30
  const lines = [
30
31
  `pi-subagents inspector for ${status.runId}`,
@@ -37,7 +38,7 @@ export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir:
37
38
  if (open.length) lines.push(`Open decisions: ${open.map((decision) => `${decision.id}: ${decision.title}`).join(" | ")}`);
38
39
  lines.push("");
39
40
  }
40
- lines.push(formatAsyncRunTranscript(status, asyncDir, { index: input.index, lines: 60 }));
41
+ lines.push(formatAsyncRunTranscript(status, asyncDir, { index: input.index, lines: 60, sessionRoots: input.sessionRoots }));
41
42
  const controls = [input.allowSteer === false ? undefined : "steer <message>", input.allowStop === false ? undefined : "stop", "status"].filter(Boolean);
42
43
  lines.push("", `Controls: ${controls.join(" | ")}`, "Supervisor replies remain in the parent Pi session (subagent_supervisor/intercom).");
43
44
  return lines.join("\n");
@@ -57,6 +58,17 @@ function parseArgs(argv: string[]): RunnerOptions {
57
58
  const indexRaw = values.get("--index");
58
59
  const childIndex = indexRaw === undefined ? undefined : Number(indexRaw);
59
60
  if (childIndex !== undefined && (!Number.isInteger(childIndex) || childIndex < 0)) throw new Error("--index must be a non-negative integer.");
61
+ const sessionRootsRaw = values.get("--session-roots");
62
+ let sessionRoots: string[] = [];
63
+ if (sessionRootsRaw !== undefined) {
64
+ try {
65
+ const parsed = JSON.parse(sessionRootsRaw) as unknown;
66
+ if (!Array.isArray(parsed) || parsed.some((root) => typeof root !== "string")) throw new Error();
67
+ sessionRoots = parsed;
68
+ } catch {
69
+ throw new Error("--session-roots must be a JSON array of strings.");
70
+ }
71
+ }
60
72
  const refreshRaw = values.get("--refresh-ms");
61
73
  const refreshMs = refreshRaw === undefined ? 1_500 : Number(refreshRaw);
62
74
  if (!Number.isInteger(refreshMs) || refreshMs < 250) throw new Error("--refresh-ms must be an integer >= 250.");
@@ -65,6 +77,7 @@ function parseArgs(argv: string[]): RunnerOptions {
65
77
  runId,
66
78
  ...(childIndex !== undefined ? { index: childIndex } : {}),
67
79
  ...(values.get("--mission-path") ? { missionPath: path.resolve(values.get("--mission-path")!) } : {}),
80
+ sessionRoots,
68
81
  refreshMs,
69
82
  allowSteer: values.get("--allow-steer") !== "false",
70
83
  allowStop: values.get("--allow-stop") !== "false",
@@ -115,7 +128,7 @@ export function runInspector(argv = process.argv.slice(2)): void {
115
128
  process.stdout.write(`\x1b[2J\x1b[Hpi-subagents inspector\n\nLifecycle status for ${options.runId} is unavailable.\n`);
116
129
  return;
117
130
  }
118
- process.stdout.write(`\x1b[2J\x1b[H${formatInspectorDashboard({ status, asyncDir: options.asyncDir, index: options.index, mission: readMission(options.missionPath), allowSteer: options.allowSteer, allowStop: options.allowStop })}${notice ? `\n\n${notice}` : ""}\n> `);
131
+ process.stdout.write(`\x1b[2J\x1b[H${formatInspectorDashboard({ status, asyncDir: options.asyncDir, index: options.index, mission: readMission(options.missionPath), allowSteer: options.allowSteer, allowStop: options.allowStop, sessionRoots: options.sessionRoots })}${notice ? `\n\n${notice}` : ""}\n> `);
119
132
  if (isTerminal(status) && timer) {
120
133
  clearInterval(timer);
121
134
  timer = undefined;
@@ -6,6 +6,7 @@ import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
6
6
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
7
  import type { Details } from "../../shared/types.ts";
8
8
  import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode } from "./client.ts";
9
+ import { formatShellCommand } from "./shell-command.ts";
9
10
 
10
11
  export const HERDR_PROJECT_PANE_ACTIONS = ["project.open", "project.status", "project.close"] as const;
11
12
  export type HerdrProjectPaneAction = typeof HERDR_PROJECT_PANE_ACTIONS[number];
@@ -257,11 +258,6 @@ function projectPaneRuntime(value: unknown): ProjectPaneRuntime | undefined {
257
258
  };
258
259
  }
259
260
 
260
- function shellQuote(value: string): string {
261
- if (process.platform === "win32") return `"${value.replaceAll('"', '\\"')}"`;
262
- return `'${value.replaceAll("'", "'\\''")}'`;
263
- }
264
-
265
261
  function resolveProjectRoot(requested: string): ProjectPaneResult<string> {
266
262
  const resolved = path.resolve(requested);
267
263
  try {
@@ -284,7 +280,7 @@ async function inspectPane(client: HerdrClient, paneId: string, signal?: AbortSi
284
280
  function projectPaneCommand(message: string | undefined): string {
285
281
  const args = message?.trim() ? [message.trim()] : [];
286
282
  const command = getPiSpawnCommand(args);
287
- return `${process.platform === "win32" ? "& " : ""}${[command.command, ...command.args].map(shellQuote).join(" ")}`;
283
+ return formatShellCommand(command.command, command.args);
288
284
  }
289
285
 
290
286
  function canonicalRuntimePath(value: string | undefined): string | undefined {
@@ -0,0 +1,16 @@
1
+ function shellQuote(value: string, platform: NodeJS.Platform): string {
2
+ if (platform === "win32") return `"${value.replaceAll('"', '\\"')}"`;
3
+ return `'${value.replaceAll("'", "'\\''")}'`;
4
+ }
5
+
6
+ function isBareExecutable(value: string): boolean {
7
+ return /^[\w./@:-]+$/.test(value);
8
+ }
9
+
10
+ export function formatShellCommand(exe: string, args: readonly string[], platform: NodeJS.Platform = process.platform): string {
11
+ const quotedArgs = args.map((arg) => shellQuote(arg, platform));
12
+ if (platform === "win32") return `& ${[shellQuote(exe, platform), ...quotedArgs].join(" ")}`;
13
+ // Nushell treats a leading quoted token as a string, so use a bare invoker for paths that need quoting.
14
+ const invocation = isBareExecutable(exe) ? exe : `sh -c 'exec "$0" "$@"' ${shellQuote(exe, platform)}`;
15
+ return [invocation, ...quotedArgs].join(" ");
16
+ }