pi-subagents 0.37.1 → 0.38.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.38.0] - 2026-07-30
6
+
7
+ ### Added
8
+ - Added `j`/`k` navigation aliases to the non-filterable `/subagents-stop` selector and clarify step list while preserving text input in editor and search modes. Thanks to @magoz for #686.
9
+ - Added the optional versioned `fleetStatus` RPC capability with bounded, current-session child roles, goals, model/effort, split token usage, elapsed timestamps, stable opaque reconciliation keys, and explicit overflow counts. Thanks to @neumie for #682.
10
+
11
+ ### Fixed
12
+ - Enabled the advertised `j`/`k` navigation aliases after activating the persistent FleetView while leaving printable editor input untouched before activation. Thanks to @magoz for #685.
13
+ - Added opt-in `subagents.projectRootResolution: "git-root"` so monorepos and git worktrees can keep the default nearest-root behavior unless they choose to resolve project packages and `agentOverrides` from the git root. Thanks to @klajdo-f for #677.
14
+ - Recognized structurally compatible custom editors in FleetView focus detection, restoring FleetView arrow-key activation and navigation when a custom editor has focus. Thanks to @magoz for #679.
15
+ - Scoped foreground fleet records to their originating parent session and propagated resolved model, thinking effort, and split input/output usage through live foreground controls.
16
+ - Matched fleet RPC filtering to the canonical session-file identity used by live async and foreground state.
17
+ - Kept pi-intercom stable IDs from leaking into child sessions and used the current intercom runtime ID for unnamed supervisor targets.
18
+ - Improved acceptance policy validation errors and tool-schema guidance for invalid evidence kinds. Thanks to @atimofeev for #672.
19
+ - Tolerated temporary steering inbox scan failures so pending steer requests can be retried on the next poll. Thanks to @hughcars for #670.
20
+ - Retried short, zero-activity child startup exits on the same model with bounded backoff, reducing concurrent subagent launch races without replaying model or tool work. Thanks to @felipeteodorocw for #671.
21
+ - Bounded streamed subagent progress snapshots so a long or deeply nested fan-out no longer emits a `tool_execution_update` line above the child-stdout protocol cap and gets the child killed with `protocol_output_limit`. Streamed `onUpdate` snapshots now carry compact tool-call summaries instead of the full message transcript, cap `recentTools`, and truncate `recentOutput` line length; the returned result and detached-exit recovery keep the full transcript. Thanks to @shaharmor for #680/#681.
22
+
23
+ ## [0.37.2] - 2026-07-28
24
+
25
+ ### Changed
26
+ - Reduced repeated scanning and file reads in live TUI rendering and skill loading.
27
+
28
+ ### Fixed
29
+ - Passed `--no-context-files` to child Pi runs when an agent disables inherited project context, avoiding stale prompt-header parsing as Pi's context block format changes. Thanks to @KorenKrita for #667.
30
+
5
31
  ## [0.37.1] - 2026-07-27
6
32
 
7
33
  ### Added
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nico Bailon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -157,6 +157,18 @@ For a persistent override, edit settings. This example pins the reviewer everywh
157
157
 
158
158
  Use `~/.pi/agent/settings.json` for a user override or the project config settings file (`.pi/settings.json` in standard Pi) for a project override. `subagents.defaultModel` applies to builtin, package, user, and project agents that do not set `model` in frontmatter. Per-run model overrides and `agentOverrides.<name>.model` still win, and explicit agent frontmatter still wins over the global default. The same `agentOverrides` block can change `tools`, `skills`, inherited context, prompt text, or disable a builtin. Matching user and project agents also receive override fields that their frontmatter leaves unset, so a shared project config agent can keep the persona while local settings choose the model.
159
159
 
160
+ By default, project settings resolve from the nearest parent directory that contains `.pi` or `.agents`, preserving existing nested-project behavior. In monorepos or git worktrees where an incidental nested `.pi` directory should not shadow the repository-level config, set this in the repository root `.pi/settings.json`:
161
+
162
+ ```json
163
+ {
164
+ "subagents": {
165
+ "projectRootResolution": "git-root"
166
+ }
167
+ }
168
+ ```
169
+
170
+ `"git-root"` keeps package discovery, project agents, chains, and `agentOverrides` anchored to the git worktree root when that root also has Pi project config. A nested project can still opt back into nearest-root behavior by setting `"projectRootResolution": "nearest"` in its own `.pi/settings.json`.
171
+
160
172
  Set `subagents.defaultThinking` to give builtin, package, user, and project agents without a `thinking` value a shared thinking level, independent of the parent session's default. Project settings win over user settings. Explicit frontmatter, `agentOverrides.<name>.thinking`, and per-run thinking overrides still win; `thinking: false` remains an explicit opt-out:
161
173
 
162
174
  ```json
@@ -274,7 +286,7 @@ To keep subagents inside a budget or compliance profile, enforce a model scope.
274
286
 
275
287
  Foreground runs stream progress in the conversation while they run. They default to a generous 30-minute wall-clock timeout when neither the call nor the selected agent provides a timeout; explicit `timeoutMs`/`maxRuntimeMs` and agent defaults win.
276
288
 
277
- Background runs keep working after control returns to you. Inspect active runs with `subagent({ action: "status" })`, or a specific run with `subagent({ action: "status", id: "..." })`. In the TUI, a persistent FleetView below the editor by default shows `main` plus active children with task, elapsed time, and token totals. Set `fleetViewPlacement` to `"aboveEditor"` to move it above the editor. When the focused editor is empty, use `↑`/`↓` or `j`/`k` to select a child and `Enter` to inspect it; normal editor input is never intercepted.
289
+ Background runs keep working after control returns to you. Inspect active runs with `subagent({ action: "status" })`, or a specific run with `subagent({ action: "status", id: "..." })`. In the TUI, a persistent FleetView below the editor by default shows `main` plus active children with task, elapsed time, and token totals. Set `fleetViewPlacement` to `"aboveEditor"` to move it above the editor. When the focused editor is empty, press `↓` or `←` to activate FleetView, then use `↑`/`↓` or `j`/`k` to select a child and `Enter` to inspect it; printable navigation keys are never intercepted before activation.
278
290
 
279
291
  `/subagents-fleet` opens the live, inspection-only fleet inspector with current-session foreground work, recent async children, structured Markdown/tool transcripts, and completed output/session paths. Use `↑`/`↓` or `j`/`k` to select a child, `Shift+K`/`Shift+J` to scroll one line, `PgUp`/`PgDn` to scroll one page, `x`/`Ctrl+O` to toggle tool details, `r` to refresh, and `Esc` to close. `Ctrl+Alt+F` opens the same inspector even while a foreground turn is active and slash input is queued. Without a TUI, `/subagents-fleet` retains the textual `subagent({ action: "status", view: "fleet" })` fallback. Mutations stay in explicit commands: run `/subagents-stop` and pick from the selector, or use `/subagents-stop <run-id>` / `subagent({ action: "stop", id: "..." })` when you already know the id. To inspect one background child in text, use `subagent({ action: "status", id: "...", view: "transcript" })`; add `index` for a specific child in a parallel or chain run.
280
292
 
@@ -310,7 +322,7 @@ pi.events.emit("subagents:rpc:v1:request", {
310
322
  });
311
323
  ```
312
324
 
313
- The v1 methods are `ping`, `status`, `spawn`, `steer`, `interrupt`, `stop`, and `resume`. `status`, `steer`, `interrupt`, and `resume` reuse the normal package-owned actions. `steer` requires an async run `id` (plus optional child `index`) and a non-empty `message`; its reply preserves the normal acknowledged-delivery result. RPC steering disables the direct tool's pause-and-revive recovery so an extension keeps authority over the exact child it spawned; `ping.capabilities.nonRecoveringSteer` advertises this guarantee. `resume` requires a run target and non-empty `message`; it delegates to the existing revival path, which validates current-session ownership, persisted session/recovery metadata, stopped/live state, capability ceilings, and the exclusive session lease before returning the new async run details. Callers may request a `file-only` output path for the revived result without overriding its model, tools, or budgets. `ping.capabilities.resume` advertises this seam. `spawn` is async-only: omit `async` or set `async: true`, omit `clarify` or set `clarify: false`, and do not pass management `action` values. It goes through the same executor as the `subagent` tool, so agent discovery, validation, session attribution, configured spawn caps, child-safety depth, artifacts, and async status all behave the same. `stop` targets current-session top-level async runs through the stop control channel and records a `stopped` lifecycle instead of reporting a timeout.
325
+ The v1 methods are `ping`, `status`, `spawn`, `steer`, `interrupt`, `stop`, and `resume`. `status`, `steer`, `interrupt`, and `resume` reuse the normal package-owned actions. When `ping.capabilities.fleetStatus` is `{ version: 1 }`, successful `status` replies additionally include `data.fleet`: `{ version: 1, entries, totalActive, omitted }`. Entries are bounded, current-session public display records with an opaque reconciliation `key`, resolved `agent`, optional `role`, `model`, `effort`, caller-facing `goal`, safe `startedAt`, and `{ input, output, total }` tokens. `totalActive` and `omitted` preserve overflow information beyond the bounded entry window. The DTO intentionally never exposes run, async, or tool IDs; clients must ignore unknown fields and fall back to status text when the capability is absent. `steer` requires an async run `id` (plus optional child `index`) and a non-empty `message`; its reply preserves the normal acknowledged-delivery result. RPC steering disables the direct tool's pause-and-revive recovery so an extension keeps authority over the exact child it spawned; `ping.capabilities.nonRecoveringSteer` advertises this guarantee. `resume` requires a run target and non-empty `message`; it delegates to the existing revival path, which validates current-session ownership, persisted session/recovery metadata, stopped/live state, capability ceilings, and the exclusive session lease before returning the new async run details. Callers may request a `file-only` output path for the revived result without overriding its model, tools, or budgets. `ping.capabilities.resume` advertises this seam. `spawn` is async-only: omit `async` or set `async: true`, omit `clarify` or set `clarify: false`, and do not pass management `action` values. It goes through the same executor as the `subagent` tool, so agent discovery, validation, session attribution, configured spawn caps, child-safety depth, artifacts, and async status all behave the same. `stop` targets current-session top-level async runs through the stop control channel and records a `stopped` lifecycle instead of reporting a timeout.
314
326
 
315
327
  `pi.events` is in-process only. It does not reach separate Pi processes or child subagents; use the file lifecycle artifacts or `pi-intercom` for cross-process coordination.
316
328
 
@@ -644,7 +656,7 @@ Common clarify keys:
644
656
 
645
657
  - `Enter` runs in the foreground, or in the background if background is toggled on
646
658
  - `Esc` cancels or backs out
647
- - `↑↓` moves between steps or tasks
659
+ - `↑↓` or `j`/`k` moves between steps or tasks
648
660
  - `e` edits the task/template
649
661
  - `m` selects a model
650
662
  - `t` selects thinking level
@@ -1429,7 +1441,7 @@ subagent({ action: "doctor" })
1429
1441
 
1430
1442
  `resume` revives a paused, completed, or failed async/foreground child by starting a new child from its stored session file; stopped runs remain non-resumable, and it does not interrupt a live top-level async child. Use `steer` for acknowledged live async guidance. Multi-child async runs and remembered foreground single, parallel, or chain runs can be revived by passing `index` to choose the child. Nested runs can be resumed by nested id when their live route or persisted nested session metadata is available. Revive starts a new child process from the old session context; it does not restart the same OS process, and it requires the chosen child to have a persisted `.jsonl` session file. Direct revival takes an exclusive cross-process lease on the canonical session file until the new child finishes. A concurrent attempt fails before Pi is spawned and identifies the owning revived run; dead-owner leases are reclaimed only when staleness can be proved.
1431
1443
 
1432
- `stop` ends a current-session top-level async run. It is deliberately stronger than `interrupt`: it is not a resumable pause, stopped runs should be restarted as new runs, foreground and nested targets are rejected, direct id calls execute immediately, and `/subagents-stop` without an id opens a selector with confirmation when a TUI is available. In non-TUI contexts the slash command prints exact `subagent({ action: "stop", id })` and `/subagents-stop <id>` commands. Scheduled jobs can appear in the selector, but they are labeled as scheduled cancellations and route through `schedule-cancel`, not `stop`.
1444
+ `stop` ends a current-session top-level async run. It is deliberately stronger than `interrupt`: it is not a resumable pause, stopped runs should be restarted as new runs, foreground and nested targets are rejected, direct id calls execute immediately, and `/subagents-stop` without an id opens a selector with confirmation when a TUI is available. Use `↑`/`↓` or `j`/`k` to move through that selector. In non-TUI contexts the slash command prints exact `subagent({ action: "stop", id })` and `/subagents-stop <id>` commands. Scheduled jobs can appear in the selector, but they are labeled as scheduled cancellations and route through `schedule-cancel`, not `stop`.
1433
1445
 
1434
1446
  `steer` waits up to three seconds for a correlated child-Pi input acceptance and returns a request id with `delivered`, `scheduled`, `pending`, `partial`, `recovered`, or `failed` plus per-child states. Delivery means Pi accepted the user message, not model compliance. A pending indexed child returns `scheduled`. Only a top-level single run may interrupt after the acknowledgment deadline and recover after a further 15-second pause/revival bound; chain, parallel, and nested runs never auto-interrupt. Recovery launches a replacement only after the source is confirmed paused, a valid persisted session exists, and deadline, turn, and tool budgets remain. It preserves the original child contract and remaining limits; otherwise the source stays paused with an explicit failure. Late acceptance is recorded but cannot cancel committed recovery. The persisted `steering` ledger retains 20 requests and replaces the old `steerCount`/`lastSteerAt` fields.
1435
1447
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -152,6 +152,8 @@ export interface AgentConfig {
152
152
  modelSource?: AgentModelSourceInfo;
153
153
  }
154
154
 
155
+ type ProjectRootResolution = "nearest" | "git-root";
156
+
155
157
  interface SubagentSettings {
156
158
  overrides: Record<string, BuiltinAgentOverrideConfig>;
157
159
  defaultModel?: string;
@@ -422,7 +424,7 @@ function collectSettingsPackageRoots(settingsFile: string, baseDir: string): str
422
424
 
423
425
  function collectPackageSubagentPaths(cwd: string, options: { includeUser: boolean; includeProject: boolean } = { includeUser: true, includeProject: true }): PackageSubagentPaths {
424
426
  const agentDir = getAgentDir();
425
- const projectRoot = findNearestProjectRoot(cwd) ?? cwd;
427
+ const projectRoot = findConfiguredProjectRoot(cwd) ?? cwd;
426
428
  const packageRoots = [
427
429
  projectRoot,
428
430
  ];
@@ -551,12 +553,26 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
551
553
  };
552
554
  }
553
555
 
554
- export function findNearestProjectRoot(cwd: string): string | null {
556
+ function isProjectRootCandidate(dir: string): boolean {
557
+ return isDirectory(getProjectConfigDir(dir)) || isDirectory(path.join(dir, ".agents"));
558
+ }
559
+
560
+ function findProjectRootCandidates(cwd: string): string[] {
561
+ const roots: string[] = [];
555
562
  let currentDir = cwd;
556
563
  while (true) {
557
- if (isDirectory(getProjectConfigDir(currentDir)) || isDirectory(path.join(currentDir, ".agents"))) {
558
- return currentDir;
559
- }
564
+ if (isProjectRootCandidate(currentDir)) roots.push(currentDir);
565
+
566
+ const parentDir = path.dirname(currentDir);
567
+ if (parentDir === currentDir) return roots;
568
+ currentDir = parentDir;
569
+ }
570
+ }
571
+
572
+ function findNearestGitRoot(cwd: string): string | null {
573
+ let currentDir = cwd;
574
+ while (true) {
575
+ if (fs.existsSync(path.join(currentDir, ".git"))) return currentDir;
560
576
 
561
577
  const parentDir = path.dirname(currentDir);
562
578
  if (parentDir === currentDir) return null;
@@ -564,12 +580,46 @@ export function findNearestProjectRoot(cwd: string): string | null {
564
580
  }
565
581
  }
566
582
 
583
+ function readProjectRootResolution(projectRoot: string): ProjectRootResolution | undefined {
584
+ const settingsPath = path.join(getProjectConfigDir(projectRoot), "settings.json");
585
+ if (!fs.existsSync(settingsPath)) return undefined;
586
+ const settings = readSettingsFileStrict(settingsPath);
587
+ const subagents = settings.subagents;
588
+ if (!subagents || typeof subagents !== "object" || Array.isArray(subagents)) return undefined;
589
+
590
+ const value = (subagents as Record<string, unknown>).projectRootResolution;
591
+ if (value === undefined) return undefined;
592
+ if (value === "nearest" || value === "git-root") return value;
593
+ throw new Error(`Subagent settings in '${settingsPath}' have invalid 'projectRootResolution'; expected 'nearest' or 'git-root'.`);
594
+ }
595
+
596
+ export function findNearestProjectRoot(cwd: string): string | null {
597
+ return findProjectRootCandidates(cwd)[0] ?? null;
598
+ }
599
+
600
+ function findConfiguredProjectRoot(cwd: string): string | null {
601
+ const candidates = findProjectRootCandidates(cwd);
602
+ const nearestRoot = candidates[0];
603
+ if (!nearestRoot) return null;
604
+
605
+ const nearestMode = readProjectRootResolution(nearestRoot);
606
+ if (nearestMode === "nearest") return nearestRoot;
607
+
608
+ const gitRoot = findNearestGitRoot(cwd);
609
+ const gitProjectRoot = gitRoot ? candidates.find((candidate) => path.resolve(candidate) === path.resolve(gitRoot)) : undefined;
610
+ if (gitProjectRoot && (nearestMode === "git-root" || readProjectRootResolution(gitProjectRoot) === "git-root")) {
611
+ return gitProjectRoot;
612
+ }
613
+
614
+ return nearestRoot;
615
+ }
616
+
567
617
  function getUserAgentSettingsPath(): string {
568
618
  return path.join(getAgentDir(), "settings.json");
569
619
  }
570
620
 
571
621
  function getProjectAgentSettingsPath(cwd: string): string | null {
572
- const projectRoot = findNearestProjectRoot(cwd);
622
+ const projectRoot = findConfiguredProjectRoot(cwd);
573
623
  return projectRoot ? path.join(getProjectConfigDir(projectRoot), "settings.json") : null;
574
624
  }
575
625
 
@@ -1491,7 +1541,7 @@ function isDirectory(p: string): boolean {
1491
1541
  }
1492
1542
 
1493
1543
  function resolveNearestProjectAgentDirs(cwd: string): { readDirs: string[]; preferredDir: string | null } {
1494
- const projectRoot = findNearestProjectRoot(cwd);
1544
+ const projectRoot = findConfiguredProjectRoot(cwd);
1495
1545
  if (!projectRoot) return { readDirs: [], preferredDir: null };
1496
1546
 
1497
1547
  const legacyDir = path.join(projectRoot, ".agents");
@@ -1507,7 +1557,7 @@ function resolveNearestProjectAgentDirs(cwd: string): { readDirs: string[]; pref
1507
1557
  }
1508
1558
 
1509
1559
  function resolveNearestProjectChainDirs(cwd: string): { readDirs: string[]; preferredDir: string | null } {
1510
- const projectRoot = findNearestProjectRoot(cwd);
1560
+ const projectRoot = findConfiguredProjectRoot(cwd);
1511
1561
  if (!projectRoot) return { readDirs: [], preferredDir: null };
1512
1562
 
1513
1563
  const preferredDir = path.join(getProjectConfigDir(projectRoot), "chains");
@@ -379,19 +379,21 @@ function chooseHigherPrioritySkill(existing: CachedSkillEntry | undefined, candi
379
379
  return candidate.order < existing.order ? candidate : existing;
380
380
  }
381
381
 
382
- function maybeReadSkillDescription(filePath: string): string | undefined {
383
- try {
384
- const content = fs.readFileSync(filePath, "utf-8");
385
- const normalized = content.replace(/\r\n/g, "\n");
386
- if (!normalized.startsWith("---")) return undefined;
382
+ function parseSkillDescription(content: string): string | undefined {
383
+ const normalized = content.replace(/\r\n/g, "\n");
384
+ if (!normalized.startsWith("---")) return undefined;
385
+
386
+ const endIndex = normalized.indexOf("\n---", 3);
387
+ if (endIndex === -1) return undefined;
387
388
 
388
- const endIndex = normalized.indexOf("\n---", 3);
389
- if (endIndex === -1) return undefined;
389
+ const frontmatter = normalized.slice(3, endIndex).trim();
390
+ const match = frontmatter.match(/^description:\s*(.+)$/m);
391
+ return match?.[1]?.trim().replace(/^['\"]|['\"]$/g, "");
392
+ }
390
393
 
391
- const frontmatter = normalized.slice(3, endIndex).trim();
392
- const match = frontmatter.match(/^description:\s*(.+)$/m);
393
- if (!match) return undefined;
394
- return match[1]?.trim().replace(/^['\"]|['\"]$/g, "");
394
+ function maybeReadSkillDescription(filePath: string): string | undefined {
395
+ try {
396
+ return parseSkillDescription(fs.readFileSync(filePath, "utf-8"));
395
397
  } catch {
396
398
  // Description parsing is best-effort metadata extraction.
397
399
  return undefined;
@@ -585,7 +587,7 @@ function readSkill(
585
587
 
586
588
  const raw = fs.readFileSync(skillPath, "utf-8");
587
589
  const content = stripSkillFrontmatter(raw);
588
- const description = maybeReadSkillDescription(skillPath);
590
+ const description = parseSkillDescription(raw);
589
591
  const skill: ResolvedSkill = {
590
592
  name: skillName,
591
593
  path: skillPath,
@@ -389,6 +389,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
389
389
  events: pi.events,
390
390
  getContext: () => state.lastUiContext,
391
391
  execute: (id, params, signal, onUpdate, ctx) => executor.execute(id, params, signal, onUpdate, ctx),
392
+ state,
392
393
  });
393
394
 
394
395
  function effectiveParallelTaskCount(tasks: Array<{ count?: unknown }> | undefined): number {
@@ -557,6 +558,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
557
558
  state.lastUiContext = ctx;
558
559
  cleanupSessionArtifacts(ctx);
559
560
  clearPendingForegroundControlNotices(state);
561
+ state.foregroundControls.clear();
562
+ state.lastForegroundControlId = null;
560
563
  resetJobs(ctx);
561
564
  restoreActiveJobs(ctx);
562
565
  scheduledRunManager.bindSession(ctx);
@@ -8,7 +8,9 @@ import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.ts";
8
8
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
9
9
  import { resolveCurrentSessionId } from "../shared/session-identity.ts";
10
10
  import {
11
+ type AsyncJobStep,
11
12
  type Details,
13
+ type SubagentState,
12
14
  ASYNC_DIR,
13
15
  RESULTS_DIR,
14
16
  SUBAGENT_ASYNC_COMPLETE_EVENT,
@@ -70,6 +72,200 @@ interface EventBus {
70
72
  emit(event: string, data: unknown): void;
71
73
  }
72
74
 
75
+ export interface SubagentRpcFleetEntry {
76
+ /** Opaque key for client-side reconciliation; never a run or async identifier. */
77
+ key: string;
78
+ /** Resolved child agent/role name. */
79
+ agent: string;
80
+ role?: string;
81
+ model?: string;
82
+ effort?: string;
83
+ startedAt: number;
84
+ tokens: { input: number; output: number; total: number };
85
+ goal?: string;
86
+ }
87
+
88
+ export interface SubagentRpcFleetStatus {
89
+ version: 1;
90
+ entries: SubagentRpcFleetEntry[];
91
+ /** Total active children before the bounded entries window. */
92
+ totalActive: number;
93
+ omitted: number;
94
+ }
95
+
96
+ const MAX_FLEET_ENTRIES = 16;
97
+ const MAX_FLEET_CANDIDATES = 256;
98
+ const MAX_AGENT_LENGTH = 96;
99
+ const MAX_GOAL_LENGTH = 512;
100
+ const MAX_METADATA_LENGTH = 128;
101
+
102
+ function displayText(value: unknown, maxLength: number): string | undefined {
103
+ if (typeof value !== "string") return undefined;
104
+ // Strip complete CSI/OSC/DCS/APC/PM strings and C1 controls before collapsing
105
+ // whitespace; never leave CSI parameters behind after removing ESC.
106
+ const normalized = value.slice(0, 4_096)
107
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]|\x1b][\s\S]*?(?:\x07|\x1b\\)|\x1b[PX^_][\s\S]*?\x1b\\|[\u0000-\u001f\u007f-\u009f]/g, " ")
108
+ .replace(/\s+/g, " ").trim();
109
+ return normalized ? normalized.slice(0, maxLength) : undefined;
110
+ }
111
+
112
+ function publicTokens(value: unknown): { input: number; output: number; total: number } {
113
+ const record = isRecord(value) ? value : {};
114
+ const count = (field: "input" | "output" | "total") => {
115
+ const raw = record[field];
116
+ return typeof raw === "number" && Number.isFinite(raw) && raw >= 0
117
+ ? Math.min(Number.MAX_SAFE_INTEGER, Math.floor(raw))
118
+ : 0;
119
+ };
120
+ const input = count("input");
121
+ const output = count("output");
122
+ const sum = Math.min(Number.MAX_SAFE_INTEGER, input + output);
123
+ return { input, output, total: Math.max(sum, count("total")) };
124
+ }
125
+
126
+ function activeState(value: unknown): boolean {
127
+ return value === "running" || value === "queued" || value === "pending";
128
+ }
129
+
130
+ interface FleetKeyState {
131
+ sessionId: string | null;
132
+ next: number;
133
+ keys: Map<string, string>;
134
+ }
135
+
136
+ interface FleetCandidate {
137
+ internalKey: string;
138
+ agent: unknown;
139
+ role?: unknown;
140
+ model?: unknown;
141
+ effort?: unknown;
142
+ startedAt: unknown;
143
+ tokens?: unknown;
144
+ goal?: unknown;
145
+ }
146
+
147
+ function buildFleetStatus(
148
+ state: SubagentState | undefined,
149
+ keyState: FleetKeyState,
150
+ sessionId: string | null | undefined,
151
+ ): SubagentRpcFleetStatus {
152
+ const authoritativeSessionId = sessionId ?? null;
153
+ if (keyState.sessionId !== authoritativeSessionId) {
154
+ keyState.sessionId = authoritativeSessionId;
155
+ keyState.next = 0;
156
+ keyState.keys.clear();
157
+ }
158
+ if (!state || !authoritativeSessionId || state.currentSessionId !== authoritativeSessionId) {
159
+ keyState.keys.clear();
160
+ return { version: 1, entries: [], totalActive: 0, omitted: 0 };
161
+ }
162
+
163
+ let totalActive = 0;
164
+ const candidates: FleetCandidate[] = [];
165
+ const addCandidate = (candidate: FleetCandidate) => {
166
+ totalActive += 1;
167
+ if (candidates.length < MAX_FLEET_CANDIDATES) candidates.push(candidate);
168
+ };
169
+ for (const control of state.foregroundControls.values()) {
170
+ if (control.sessionId !== authoritativeSessionId) continue;
171
+ if (control.activeChildren?.size) {
172
+ for (const child of control.activeChildren.values()) addCandidate({
173
+ internalKey: `foreground:${control.runId}:${child.index}`,
174
+ agent: child.agent,
175
+ model: child.model,
176
+ effort: child.thinking,
177
+ startedAt: child.startedAt,
178
+ tokens: { input: child.inputTokens ?? 0, output: child.outputTokens ?? 0, total: child.tokens ?? 0 },
179
+ goal: child.description ?? control.description,
180
+ });
181
+ } else {
182
+ addCandidate({
183
+ internalKey: `foreground:${control.runId}:${control.currentIndex ?? 0}`,
184
+ agent: control.currentAgent ?? control.mode,
185
+ model: control.model,
186
+ effort: control.thinking,
187
+ startedAt: control.startedAt,
188
+ tokens: { input: control.inputTokens ?? 0, output: control.outputTokens ?? 0, total: control.tokens ?? 0 },
189
+ goal: control.description,
190
+ });
191
+ }
192
+ }
193
+ for (const job of state.asyncJobs.values()) {
194
+ if (job.sessionId !== authoritativeSessionId || !activeState(job.status)) continue;
195
+ const startedAt = job.startedAt ?? job.updatedAt;
196
+ const steps: AsyncJobStep[] | undefined = job.steps?.length
197
+ ? job.steps
198
+ : job.agents?.map((agent, index) => ({
199
+ agent,
200
+ index,
201
+ status: job.status === "queued" ? "pending" : "running",
202
+ }));
203
+ if (!steps?.length) {
204
+ addCandidate({
205
+ internalKey: `async:${job.asyncId}`,
206
+ agent: job.mode ?? "subagent",
207
+ startedAt,
208
+ tokens: job.totalTokens,
209
+ goal: job.description,
210
+ });
211
+ continue;
212
+ }
213
+ for (const [offset, step] of steps.entries()) {
214
+ if (!activeState(step.status)) continue;
215
+ const index = step.index ?? offset;
216
+ if (step.status === "pending" && job.mode === "chain" && !job.activeParallelGroup && index !== (job.currentStep ?? 0)) continue;
217
+ addCandidate({
218
+ internalKey: `async:${job.asyncId}:${index}`,
219
+ agent: step.agent,
220
+ role: step.label,
221
+ model: step.model,
222
+ effort: step.thinking,
223
+ startedAt: step.startedAt ?? startedAt,
224
+ tokens: step.tokens ?? (steps.length === 1 ? job.totalTokens : undefined),
225
+ goal: job.description,
226
+ });
227
+ }
228
+ }
229
+
230
+ candidates.sort((left, right) => {
231
+ const leftStarted = typeof left.startedAt === "number" ? left.startedAt : Number.MAX_SAFE_INTEGER;
232
+ const rightStarted = typeof right.startedAt === "number" ? right.startedAt : Number.MAX_SAFE_INTEGER;
233
+ return leftStarted - rightStarted || left.internalKey.localeCompare(right.internalKey);
234
+ });
235
+ const activeKeys = new Set(candidates.map((candidate) => candidate.internalKey));
236
+ const entries: SubagentRpcFleetEntry[] = [];
237
+ for (const candidate of candidates) {
238
+ if (entries.length >= MAX_FLEET_ENTRIES) break;
239
+ const agent = displayText(candidate.agent, MAX_AGENT_LENGTH);
240
+ const startedAt = candidate.startedAt;
241
+ if (!agent || typeof startedAt !== "number" || !Number.isSafeInteger(startedAt) || startedAt < 0) continue;
242
+ let key = keyState.keys.get(candidate.internalKey);
243
+ if (!key) {
244
+ key = `fleet-${++keyState.next}`;
245
+ keyState.keys.set(candidate.internalKey, key);
246
+ }
247
+ const role = displayText(candidate.role, MAX_AGENT_LENGTH);
248
+ const model = displayText(candidate.model, MAX_METADATA_LENGTH);
249
+ const effort = displayText(candidate.effort, MAX_METADATA_LENGTH);
250
+ const goal = displayText(candidate.goal, MAX_GOAL_LENGTH);
251
+ entries.push({
252
+ key,
253
+ agent,
254
+ ...(role ? { role } : {}),
255
+ ...(model ? { model } : {}),
256
+ ...(effort ? { effort } : {}),
257
+ startedAt,
258
+ tokens: publicTokens(candidate.tokens),
259
+ ...(goal ? { goal } : {}),
260
+ });
261
+ }
262
+ for (const internalKey of keyState.keys.keys()) {
263
+ if (!activeKeys.has(internalKey)) keyState.keys.delete(internalKey);
264
+ }
265
+ const omitted = Math.max(0, totalActive - entries.length);
266
+ return { version: 1, entries, totalActive, omitted };
267
+ }
268
+
73
269
  interface RegisterSubagentRpcBridgeOptions {
74
270
  events: EventBus;
75
271
  getContext: () => ExtensionContext | null;
@@ -84,6 +280,8 @@ interface RegisterSubagentRpcBridgeOptions {
84
280
  resultsDir?: string;
85
281
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
86
282
  now?: () => number;
283
+ /** Native live state, projected into the optional public fleet-status capability. */
284
+ state?: SubagentState;
87
285
  }
88
286
 
89
287
  class SubagentRpcError extends Error {
@@ -141,7 +339,9 @@ function textFromToolResult(result: AgentToolResult<Details>): string {
141
339
  .join("\n");
142
340
  }
143
341
 
144
- function dataFromToolResult(result: AgentToolResult<Details>): { text: string; details?: Details; isError?: boolean } {
342
+ type ToolResultWithError = AgentToolResult<Details> & { isError?: boolean };
343
+
344
+ function dataFromToolResult(result: ToolResultWithError): { text: string; details?: Details; isError?: boolean } {
145
345
  return {
146
346
  text: textFromToolResult(result),
147
347
  ...(result.details ? { details: result.details } : {}),
@@ -149,7 +349,7 @@ function dataFromToolResult(result: AgentToolResult<Details>): { text: string; d
149
349
  };
150
350
  }
151
351
 
152
- function failIfToolError(result: AgentToolResult<Details>): void {
352
+ function failIfToolError(result: ToolResultWithError): void {
153
353
  if (!result.isError) return;
154
354
  throw new SubagentRpcError("execution_failed", textFromToolResult(result) || "Subagent RPC execution failed.");
155
355
  }
@@ -179,6 +379,7 @@ function pingData(ctx: ExtensionContext | null) {
179
379
  methods: [...SUBAGENT_RPC_METHODS],
180
380
  capabilities: {
181
381
  status: true,
382
+ fleetStatus: { version: 1 },
182
383
  asyncSpawn: true,
183
384
  steer: true,
184
385
  nonRecoveringSteer: true,
@@ -324,6 +525,7 @@ function stopAsyncRun(
324
525
  async function handleRequest(
325
526
  request: SubagentRpcRequestEnvelope,
326
527
  options: RegisterSubagentRpcBridgeOptions,
528
+ fleetKeys: FleetKeyState,
327
529
  ): Promise<unknown> {
328
530
  const ctx = options.getContext();
329
531
  if (request.method === "ping") return pingData(ctx);
@@ -333,7 +535,21 @@ async function handleRequest(
333
535
  return executeChecked(options, ctx, request.requestId, request.method, spawnParams(request.params));
334
536
  }
335
537
  if (request.method === "status") {
336
- return executeChecked(options, ctx, request.requestId, request.method, { action: "status", ...normalizeTargetParams(request.params, "status") });
538
+ const status = await executeChecked(
539
+ options,
540
+ ctx,
541
+ request.requestId,
542
+ request.method,
543
+ { action: "status", ...normalizeTargetParams(request.params, "status") },
544
+ );
545
+ return {
546
+ ...status,
547
+ fleet: buildFleetStatus(
548
+ options.state,
549
+ fleetKeys,
550
+ resolveCurrentSessionId(ctx.sessionManager),
551
+ ),
552
+ };
337
553
  }
338
554
  if (request.method === "steer") {
339
555
  return executeChecked(options, ctx, request.requestId, request.method, steerParams(request.params));
@@ -400,11 +616,12 @@ export function registerSubagentRpcBridge(options: RegisterSubagentRpcBridgeOpti
400
616
  emitReady: (ctx?: ExtensionContext | null) => void;
401
617
  dispose: () => void;
402
618
  } {
619
+ const fleetKeys: FleetKeyState = { sessionId: null, next: 0, keys: new Map() };
403
620
  const unsubscribe = options.events.on(SUBAGENT_RPC_REQUEST_EVENT, async (raw) => {
404
621
  let request: SubagentRpcRequestEnvelope | undefined;
405
622
  try {
406
623
  request = parseRequest(raw);
407
- const data = await handleRequest(request, options);
624
+ const data = await handleRequest(request, options, fleetKeys);
408
625
  options.events.emit(subagentRpcReplyEvent(request.requestId), {
409
626
  version: SUBAGENT_RPC_PROTOCOL_VERSION,
410
627
  requestId: request.requestId,
@@ -65,6 +65,18 @@ const JsonSchemaObject = Type.Unsafe({
65
65
  description: "JSON Schema object for strict structured output. Non-object roots are rejected.",
66
66
  });
67
67
 
68
+ const AcceptanceEvidenceKinds = [
69
+ "changed-files",
70
+ "tests-added",
71
+ "commands-run",
72
+ "validation-output",
73
+ "residual-risks",
74
+ "no-staged-files",
75
+ "diff-summary",
76
+ "review-findings",
77
+ "manual-notes",
78
+ ];
79
+
68
80
  const AcceptanceOverride = Type.Unsafe({
69
81
  anyOf: [
70
82
  { type: "string", enum: ["auto", "attested", "checked", "verified"] },
@@ -77,7 +89,7 @@ const AcceptanceOverride = Type.Unsafe({
77
89
  { type: "boolean", enum: [false] },
78
90
  { type: "object", additionalProperties: true },
79
91
  ],
80
- description: "Optional acceptance policy. For reviewer/read-only calls, omit acceptance. Evidence levels end at verified; require independent review with acceptance.review.required. The value reviewed is an achieved status and is accepted by the schema only for actionable preflight recovery. In the current/default contract, omitted means auto-inferred. With agentContract.version=1, omitted means not requested and acceptance failures are reported separately from execution.",
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.`,
81
93
  });
82
94
 
83
95
  const AgentContractOverride = Type.Object({
@@ -16,6 +16,7 @@ function defaultSubagentConfigDir(agentDir = defaultAgentDir()): string {
16
16
  }
17
17
 
18
18
  const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
19
+ export const PI_INTERCOM_SESSION_ID_ENV = "PI_INTERCOM_SESSION_ID";
19
20
  export const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
20
21
  const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `The inherited thread is reference-only. Do not continue that conversation or send questions, status updates, or completion handoffs to the supervisor in normal assistant text.
21
22
 
@@ -56,10 +57,11 @@ interface ResolveIntercomBridgeInput {
56
57
  agentDir?: string;
57
58
  }
58
59
 
59
- export function resolveIntercomSessionTarget(sessionName: string | undefined, sessionId: string): string {
60
+ export function resolveIntercomSessionTarget(sessionName: string | undefined, sessionId: string, intercomSessionId = process.env[PI_INTERCOM_SESSION_ID_ENV]): string {
60
61
  const trimmedName = sessionName?.trim();
61
62
  if (trimmedName) return trimmedName;
62
- const normalizedSessionId = sessionId.startsWith("session-") ? sessionId.slice("session-".length) : sessionId;
63
+ const fallbackSessionId = intercomSessionId?.trim() || sessionId;
64
+ const normalizedSessionId = fallbackSessionId.startsWith("session-") ? fallbackSessionId.slice("session-".length) : fallbackSessionId;
63
65
  return `${DEFAULT_INTERCOM_TARGET_PREFIX}-${normalizedSessionId.slice(0, 8)}`;
64
66
  }
65
67