pi-subagents 0.53.0 → 0.55.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 (76) hide show
  1. package/CHANGELOG.md +66 -5
  2. package/README.md +1 -1
  3. package/agents/reviewer.md +12 -2
  4. package/docs/agents.md +4 -4
  5. package/docs/configuration.md +6 -6
  6. package/docs/extension-api.md +14 -4
  7. package/docs/models.md +41 -6
  8. package/docs/observability.md +14 -3
  9. package/docs/tool-reference.md +21 -6
  10. package/docs/workflows.md +7 -1
  11. package/index.ts +10 -1
  12. package/package.json +1 -1
  13. package/prompts/council.md +19 -7
  14. package/prompts/parallel-review.md +5 -1
  15. package/prompts/review-loop.md +9 -5
  16. package/skills/council-mode/SKILL.md +50 -26
  17. package/skills/pi-subagents/SKILL.md +5 -1
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
  19. package/skills/pi-subagents/references/execution-controls.md +13 -4
  20. package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
  21. package/skills/pi-subagents/references/prompting-and-roles.md +14 -7
  22. package/src/agents/agent-management.ts +115 -14
  23. package/src/agents/agent-serializer.ts +2 -2
  24. package/src/agents/agents.ts +195 -47
  25. package/src/api/external-job-provider.ts +10 -1
  26. package/src/api/preflight.ts +33 -19
  27. package/src/api/project-panes.ts +2 -0
  28. package/src/extension/doctor.ts +10 -0
  29. package/src/extension/fanout-child.ts +3 -2
  30. package/src/extension/index.ts +24 -4
  31. package/src/extension/public-execution.ts +12 -9
  32. package/src/extension/rpc.ts +77 -3
  33. package/src/extension/schemas.ts +2 -1
  34. package/src/extension/tool-description.ts +9 -6
  35. package/src/extension/tool-result.ts +19 -0
  36. package/src/inspectors/herdr/client.ts +3 -3
  37. package/src/inspectors/herdr/focus.ts +55 -0
  38. package/src/inspectors/herdr/project-panes.ts +228 -44
  39. package/src/integrations/herdr-status.ts +26 -4
  40. package/src/runs/background/async-execution.ts +112 -13
  41. package/src/runs/background/async-job-tracker.ts +33 -14
  42. package/src/runs/background/async-resume.ts +20 -2
  43. package/src/runs/background/async-retention.ts +1 -1
  44. package/src/runs/background/async-status.ts +10 -0
  45. package/src/runs/background/chain-root-attachment.ts +5 -0
  46. package/src/runs/background/control-channel.ts +98 -10
  47. package/src/runs/background/notify.ts +62 -1
  48. package/src/runs/background/result-watcher.ts +4 -3
  49. package/src/runs/background/run-status.ts +15 -1
  50. package/src/runs/background/stale-run-reconciler.ts +3 -0
  51. package/src/runs/background/subagent-runner.ts +251 -38
  52. package/src/runs/background/wait-completions.ts +2 -0
  53. package/src/runs/background/wait-tool.ts +4 -3
  54. package/src/runs/foreground/async-stop-action.ts +23 -2
  55. package/src/runs/foreground/execution.ts +110 -8
  56. package/src/runs/foreground/subagent-executor.ts +361 -45
  57. package/src/runs/foreground/workflow-detach-reconcile.ts +3 -0
  58. package/src/runs/shared/acceptance.ts +1 -0
  59. package/src/runs/shared/child-identity.ts +36 -0
  60. package/src/runs/shared/completion-guard.ts +50 -1
  61. package/src/runs/shared/external-job-bridge.ts +53 -37
  62. package/src/runs/shared/external-job-runner.ts +126 -23
  63. package/src/runs/shared/model-fallback.ts +46 -15
  64. package/src/runs/shared/model-scope.ts +106 -39
  65. package/src/runs/shared/orca-progress-tabs.ts +71 -9
  66. package/src/runs/shared/parallel-utils.ts +12 -0
  67. package/src/runs/shared/pi-args.ts +31 -1
  68. package/src/runs/shared/subagent-prompt-runtime.ts +39 -10
  69. package/src/runs/shared/tool-availability.ts +1 -3
  70. package/src/shared/launch-contract.ts +6 -5
  71. package/src/shared/thinking-ceiling.ts +52 -0
  72. package/src/shared/types.ts +72 -5
  73. package/src/tui/fleet-status.ts +66 -13
  74. package/src/tui/render.ts +5 -4
  75. package/src/watchdog/permission-arbiter.ts +59 -51
  76. package/src/workflows/scripted-workflow.ts +107 -10
@@ -0,0 +1,55 @@
1
+ import type { HerdrClient, HerdrErrorCode } from "./client.ts";
2
+
3
+ export type HerdrFocusErrorCode = HerdrErrorCode | "PANE_FOCUS_UNSUPPORTED" | "INVALID_PANE_RESPONSE";
4
+
5
+ export type HerdrPaneFocusResult =
6
+ | { ok: true; data: { paneId: string; tabId?: string; workspaceId?: string } }
7
+ | { ok: false; error: { code: HerdrFocusErrorCode; message: string; details?: unknown } };
8
+
9
+ export function herdrPaneRecord(value: unknown): Record<string, unknown> | undefined {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
11
+ const record = value as Record<string, unknown>;
12
+ return record.pane && typeof record.pane === "object" && !Array.isArray(record.pane)
13
+ ? record.pane as Record<string, unknown>
14
+ : record;
15
+ }
16
+
17
+ function text(record: Record<string, unknown>, ...keys: string[]): string | undefined {
18
+ for (const key of keys) if (typeof record[key] === "string" && record[key]) return record[key] as string;
19
+ return undefined;
20
+ }
21
+
22
+ export function herdrPaneFocusTarget(value: unknown): { paneId?: string; tabId?: string; workspaceId?: string } {
23
+ const pane = herdrPaneRecord(value);
24
+ if (!pane) return {};
25
+ return {
26
+ paneId: text(pane, "pane_id", "paneId", "id"),
27
+ tabId: text(pane, "tab_id", "tabId"),
28
+ workspaceId: text(pane, "workspace_id", "workspaceId"),
29
+ };
30
+ }
31
+
32
+ export async function focusHerdrPane(client: HerdrClient, paneId: string, signal?: AbortSignal): Promise<HerdrPaneFocusResult> {
33
+ const live = await client.run(["pane", "get", paneId], { timeoutMs: 5_000, signal });
34
+ if (live.ok === false) return live;
35
+ const target = herdrPaneFocusTarget(live.data);
36
+ if (!target.paneId) {
37
+ return { ok: false, error: { code: "INVALID_PANE_RESPONSE", message: `Herdr pane get returned no pane id for '${paneId}'.`, details: live.data } };
38
+ }
39
+ if (target.tabId) {
40
+ const focused = await client.run(["tab", "focus", target.tabId], { timeoutMs: 5_000, signal });
41
+ return focused.ok ? { ok: true, data: { paneId: target.paneId, tabId: target.tabId, ...(target.workspaceId ? { workspaceId: target.workspaceId } : {}) } } : focused;
42
+ }
43
+ if (target.workspaceId) {
44
+ const focused = await client.run(["workspace", "focus", target.workspaceId], { timeoutMs: 5_000, signal });
45
+ return focused.ok ? { ok: true, data: { paneId: target.paneId, workspaceId: target.workspaceId } } : focused;
46
+ }
47
+ return {
48
+ ok: false,
49
+ error: {
50
+ code: "PANE_FOCUS_UNSUPPORTED",
51
+ message: `Herdr pane '${paneId}' has no tab_id or workspace_id. Select it in Herdr manually, or upgrade Herdr focus support.`,
52
+ details: live.data,
53
+ },
54
+ };
55
+ }
@@ -4,8 +4,9 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { getPiSpawnCommand } from "../../runs/shared/pi-spawn.ts";
5
5
  import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
6
6
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
- import type { Details } from "../../shared/types.ts";
7
+ import type { Details, HerdrProjectPaneSnapshot, SubagentState } from "../../shared/types.ts";
8
8
  import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode } from "./client.ts";
9
+ import { focusHerdrPane, herdrPaneFocusTarget, herdrPaneRecord } from "./focus.ts";
9
10
  import { formatShellCommand } from "./shell-command.ts";
10
11
 
11
12
  export const HERDR_PROJECT_PANE_ACTIONS = ["project.open", "project.status", "project.close"] as const;
@@ -34,6 +35,9 @@ export interface ProjectPaneRuntime {
34
35
  cwd?: string;
35
36
  foregroundCwd?: string;
36
37
  focused?: boolean;
38
+ tabId?: string;
39
+ workspaceId?: string;
40
+ summary?: string;
37
41
  terminalTitle?: string;
38
42
  }
39
43
 
@@ -44,6 +48,7 @@ export type ProjectPaneErrorCode = HerdrErrorCode
44
48
  | "BINDING_READ_FAILED"
45
49
  | "BINDING_WRITE_FAILED"
46
50
  | "BINDING_REMOVE_FAILED"
51
+ | "PANE_FOCUS_UNSUPPORTED"
47
52
  | "PANE_NOT_IDLE"
48
53
  | "PANE_OWNERSHIP_UNVERIFIED";
49
54
 
@@ -87,6 +92,13 @@ export interface CloseProjectPaneData extends ProjectPaneCommonData {
87
92
  runtime?: ProjectPaneRuntime;
88
93
  }
89
94
 
95
+ export interface FocusProjectPaneData extends ProjectPaneCommonData {
96
+ binding: HerdrProjectPaneBinding;
97
+ runtime: ProjectPaneRuntime;
98
+ ownership: "verified" | "unknown" | "mismatch";
99
+ focused: { paneId: string; tabId?: string; workspaceId?: string };
100
+ }
101
+
90
102
  export interface OpenProjectPaneOptions {
91
103
  cwd: string;
92
104
  message?: string;
@@ -106,12 +118,7 @@ export interface CloseProjectPaneOptions {
106
118
  signal?: AbortSignal;
107
119
  }
108
120
 
109
- export interface ProjectPaneCommandClient {
110
- run<T = unknown>(
111
- args: string[],
112
- options?: { timeoutMs?: number; signal?: AbortSignal; textOk?: boolean },
113
- ): Promise<{ ok: true; data: T } | { ok: false; error: { code: HerdrErrorCode; message: string; details?: unknown } }>;
114
- }
121
+ export type ProjectPaneCommandClient = Pick<HerdrClient, "run">;
115
122
 
116
123
  export interface ProjectPaneManagerOptions {
117
124
  client?: ProjectPaneCommandClient;
@@ -121,6 +128,7 @@ export interface ProjectPaneManagerOptions {
121
128
  export interface ProjectPaneManager {
122
129
  open(options: OpenProjectPaneOptions): Promise<ProjectPaneResult<OpenProjectPaneData>>;
123
130
  status(options: GetProjectPaneStatusOptions): Promise<ProjectPaneResult<ProjectPaneStatusData>>;
131
+ focus(options: GetProjectPaneStatusOptions): Promise<ProjectPaneResult<FocusProjectPaneData>>;
124
132
  close(options: CloseProjectPaneOptions): Promise<ProjectPaneResult<CloseProjectPaneData>>;
125
133
  }
126
134
 
@@ -129,6 +137,12 @@ interface InternalProjectPaneManagerOptions extends ProjectPaneManagerOptions {
129
137
  legacyToolCompatibility?: boolean;
130
138
  }
131
139
 
140
+ interface HerdrProjectPaneRootIndex {
141
+ schemaVersion: 1;
142
+ kind: "herdr-project-pane-roots";
143
+ projectRoots: string[];
144
+ }
145
+
132
146
  interface ProjectPaneParams {
133
147
  cwd?: string;
134
148
  message?: string;
@@ -137,6 +151,7 @@ interface ProjectPaneParams {
137
151
 
138
152
  interface ProjectPaneDeps {
139
153
  cwd: string;
154
+ state?: SubagentState;
140
155
  client?: HerdrClient;
141
156
  signal?: AbortSignal;
142
157
  now?: () => Date;
@@ -162,6 +177,51 @@ export function projectPaneBindingPath(projectRoot: string): string {
162
177
  return path.join(projectPaneDir(projectRoot), "herdr.json");
163
178
  }
164
179
 
180
+ function projectPaneRootIndexPath(ownerRoot: string): string {
181
+ return path.join(projectPaneDir(ownerRoot), "herdr-roots.json");
182
+ }
183
+
184
+ function parseRootIndex(value: unknown): string[] {
185
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Herdr project pane root index.");
186
+ const input = value as Partial<HerdrProjectPaneRootIndex>;
187
+ if (input.schemaVersion !== 1 || input.kind !== "herdr-project-pane-roots" || !Array.isArray(input.projectRoots)) throw new Error("Invalid Herdr project pane root index.");
188
+ if (!input.projectRoots.every((root) => typeof root === "string" && root.length > 0)) throw new Error("Invalid Herdr project pane root index.");
189
+ return input.projectRoots;
190
+ }
191
+
192
+ export function listHerdrProjectPaneRoots(ownerRoot: string): string[] {
193
+ try {
194
+ return parseRootIndex(JSON.parse(fs.readFileSync(projectPaneRootIndexPath(ownerRoot), "utf-8")));
195
+ } catch (error) {
196
+ const code = (error as NodeJS.ErrnoException).code;
197
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ function writeHerdrProjectPaneRoot(ownerRoot: string, projectRoot: string): void {
203
+ const projectRoots = [...new Set([...listHerdrProjectPaneRoots(ownerRoot), projectRoot])].sort();
204
+ writeAtomicJson(projectPaneRootIndexPath(ownerRoot), {
205
+ schemaVersion: 1,
206
+ kind: "herdr-project-pane-roots",
207
+ projectRoots,
208
+ } satisfies HerdrProjectPaneRootIndex);
209
+ }
210
+
211
+ function removeHerdrProjectPaneRoot(ownerRoot: string, projectRoot: string): void {
212
+ const projectRoots = listHerdrProjectPaneRoots(ownerRoot).filter((root) => root !== projectRoot);
213
+ const file = projectPaneRootIndexPath(ownerRoot);
214
+ if (projectRoots.length === 0) {
215
+ fs.rmSync(file, { force: true });
216
+ return;
217
+ }
218
+ writeAtomicJson(file, {
219
+ schemaVersion: 1,
220
+ kind: "herdr-project-pane-roots",
221
+ projectRoots,
222
+ } satisfies HerdrProjectPaneRootIndex);
223
+ }
224
+
165
225
  function parseBinding(value: unknown, strict = false): HerdrProjectPaneBinding | undefined {
166
226
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
167
227
  const input = value as Partial<HerdrProjectPaneBinding>;
@@ -224,27 +284,60 @@ export function readProjectPaneBinding(projectRoot: string): ProjectPaneResult<H
224
284
  return { ok: true, data: read.binding };
225
285
  }
226
286
 
227
- function paneRecord(value: unknown): Record<string, unknown> | undefined {
287
+ function herdrProjectPaneSnapshotFromBinding(binding: HerdrProjectPaneBinding, now = Date.now()): HerdrProjectPaneSnapshot {
288
+ return {
289
+ projectRoot: binding.projectRoot,
290
+ bindingPath: projectPaneBindingPath(binding.projectRoot),
291
+ paneId: binding.paneId,
292
+ openedAt: binding.openedAt,
293
+ ...(binding.lastFocusedAt ? { lastFocusedAt: binding.lastFocusedAt } : {}),
294
+ state: "open",
295
+ agentStatus: "unknown",
296
+ ownership: "unknown",
297
+ safeToClose: false,
298
+ refreshedAt: now,
299
+ };
300
+ }
301
+
302
+ export function restoreHerdrProjectPaneSnapshots(state: SubagentState, projectRoots: Iterable<string>, now = Date.now()): void {
303
+ const restored = new Map(state.herdrProjectPanes ?? []);
304
+ for (const projectRoot of projectRoots) {
305
+ const binding = readHerdrProjectPaneBinding(projectRoot);
306
+ if (binding) restored.set(binding.projectRoot, herdrProjectPaneSnapshotFromBinding(binding, now));
307
+ }
308
+ state.herdrProjectPanes = restored;
309
+ }
310
+
311
+ function sanitizedSummary(value: unknown): string | undefined {
312
+ if (typeof value !== "string") return undefined;
313
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
314
+ return cleaned ? cleaned.slice(0, 120) : undefined;
315
+ }
316
+
317
+ function nestedRecord(value: unknown, key: string): Record<string, unknown> | undefined {
228
318
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
229
- const record = value as Record<string, unknown>;
230
- return record.pane && typeof record.pane === "object" && !Array.isArray(record.pane)
231
- ? record.pane as Record<string, unknown>
232
- : record;
319
+ const child = (value as Record<string, unknown>)[key];
320
+ return child && typeof child === "object" && !Array.isArray(child) ? child as Record<string, unknown> : undefined;
233
321
  }
234
322
 
235
- function extractPaneId(value: unknown): string | undefined {
236
- const pane = paneRecord(value);
237
- if (!pane) return undefined;
238
- for (const key of ["pane_id", "paneId", "id"]) if (typeof pane[key] === "string") return pane[key];
239
- return undefined;
323
+ function paneSummary(pane: Record<string, unknown>): string | undefined {
324
+ return sanitizedSummary(pane.summary)
325
+ ?? sanitizedSummary(pane.state_text)
326
+ ?? sanitizedSummary(pane.stateText)
327
+ ?? sanitizedSummary(pane.token_summary)
328
+ ?? sanitizedSummary(nestedRecord(pane, "tokens")?.summary)
329
+ ?? sanitizedSummary(nestedRecord(pane, "metadata")?.summary)
330
+ ?? sanitizedSummary(nestedRecord(nestedRecord(pane, "metadata"), "tokens")?.summary);
240
331
  }
241
332
 
242
333
  function projectPaneRuntime(value: unknown): ProjectPaneRuntime | undefined {
243
- const pane = paneRecord(value);
244
- const paneId = extractPaneId(value);
334
+ const pane = herdrPaneRecord(value);
335
+ const focusTarget = herdrPaneFocusTarget(value);
336
+ const paneId = focusTarget.paneId;
245
337
  if (!pane || !paneId) return undefined;
246
338
  const text = (key: string): string | undefined => typeof pane[key] === "string" ? pane[key] as string : undefined;
247
339
  const agentStatus = text("agent_status") ?? text("agentStatus") ?? "unknown";
340
+ const summary = paneSummary(pane);
248
341
  return {
249
342
  paneId,
250
343
  ...(text("agent") ? { agent: text("agent") } : {}),
@@ -252,12 +345,48 @@ function projectPaneRuntime(value: unknown): ProjectPaneRuntime | undefined {
252
345
  ...(text("cwd") ? { cwd: text("cwd") } : {}),
253
346
  ...(text("foreground_cwd") || text("foregroundCwd") ? { foregroundCwd: text("foreground_cwd") ?? text("foregroundCwd") } : {}),
254
347
  ...(typeof pane.focused === "boolean" ? { focused: pane.focused } : {}),
348
+ ...(focusTarget.tabId ? { tabId: focusTarget.tabId } : {}),
349
+ ...(focusTarget.workspaceId ? { workspaceId: focusTarget.workspaceId } : {}),
350
+ ...(summary ? { summary } : {}),
255
351
  ...(text("terminal_title_stripped") || text("terminal_title") || text("terminalTitle")
256
352
  ? { terminalTitle: text("terminal_title_stripped") ?? text("terminal_title") ?? text("terminalTitle") }
257
353
  : {}),
258
354
  };
259
355
  }
260
356
 
357
+ function herdrProjectPaneSnapshotFromStatus(data: ProjectPaneStatusData, now: number): HerdrProjectPaneSnapshot | undefined {
358
+ if (data.state === "absent" || !data.binding) return undefined;
359
+ return {
360
+ projectRoot: data.projectRoot,
361
+ bindingPath: data.bindingPath,
362
+ paneId: data.binding.paneId,
363
+ openedAt: data.binding.openedAt,
364
+ ...(data.binding.lastFocusedAt ? { lastFocusedAt: data.binding.lastFocusedAt } : {}),
365
+ state: data.state,
366
+ agentStatus: data.runtime?.agentStatus ?? "unknown",
367
+ ownership: data.ownership,
368
+ safeToClose: data.safeToClose,
369
+ refreshedAt: now,
370
+ ...(data.runtime?.summary ? { summary: data.runtime.summary } : {}),
371
+ ...(data.runtime?.tabId ? { tabId: data.runtime.tabId } : {}),
372
+ ...(data.runtime?.workspaceId ? { workspaceId: data.runtime.workspaceId } : {}),
373
+ ...(data.runtime?.terminalTitle ? { terminalTitle: data.runtime.terminalTitle } : {}),
374
+ ...(data.staleReason ? { staleReason: data.staleReason.message } : {}),
375
+ };
376
+ }
377
+
378
+ function rememberProjectPane(state: SubagentState | undefined, data: ProjectPaneStatusData, now: number): void {
379
+ if (!state) return;
380
+ state.herdrProjectPanes ??= new Map();
381
+ const snapshot = herdrProjectPaneSnapshotFromStatus(data, now);
382
+ if (snapshot) state.herdrProjectPanes.set(data.projectRoot, snapshot);
383
+ else state.herdrProjectPanes.delete(data.projectRoot);
384
+ }
385
+
386
+ function forgetProjectPane(state: SubagentState | undefined, projectRoot: string): void {
387
+ state?.herdrProjectPanes?.delete(projectRoot);
388
+ }
389
+
261
390
  function resolveProjectRoot(requested: string): ProjectPaneResult<string> {
262
391
  const resolved = path.resolve(requested);
263
392
  try {
@@ -379,6 +508,41 @@ function createProjectPaneManagerInternal(options: InternalProjectPaneManagerOpt
379
508
  } };
380
509
  },
381
510
 
511
+ async focus(input) {
512
+ const status = await this.status(input);
513
+ if (!status.ok) return projectPaneError(status.error.code, status.error.message, status.error);
514
+ if (status.data.state !== "open" || !status.data.binding || !status.data.runtime) {
515
+ return projectPaneError("PANE_GONE", `No open Herdr project pane binding exists for '${status.data.projectRoot}'.`, {
516
+ projectRoot: status.data.projectRoot, bindingPath: status.data.bindingPath,
517
+ });
518
+ }
519
+ if (status.data.ownership !== "verified") {
520
+ return projectPaneError("PANE_OWNERSHIP_UNVERIFIED", `Project pane '${status.data.binding.paneId}' ownership is '${status.data.ownership}' for '${status.data.projectRoot}'.`, {
521
+ projectRoot: status.data.projectRoot, bindingPath: status.data.bindingPath, details: status.data.runtime,
522
+ });
523
+ }
524
+ const focused = await focusHerdrPane(client, status.data.binding.paneId, input.signal);
525
+ if (!focused.ok) return projectPaneError(focused.error.code, focused.error.message, {
526
+ projectRoot: status.data.projectRoot, bindingPath: status.data.bindingPath, details: focused.error.details,
527
+ });
528
+ const now = (options.now?.() ?? new Date()).toISOString();
529
+ const binding = { ...status.data.binding, lastFocusedAt: now };
530
+ try {
531
+ writeAtomicJson(status.data.bindingPath, binding);
532
+ } catch (cause) {
533
+ return projectPaneError("BINDING_WRITE_FAILED", `Failed to update project pane binding '${status.data.bindingPath}': ${cause instanceof Error ? cause.message : String(cause)}`, {
534
+ projectRoot: status.data.projectRoot, bindingPath: status.data.bindingPath, details: fileSystemErrorDetails(cause),
535
+ });
536
+ }
537
+ return { ok: true, data: {
538
+ ...common(status.data.projectRoot),
539
+ binding,
540
+ runtime: status.data.runtime,
541
+ ownership: status.data.ownership,
542
+ focused: focused.data,
543
+ } };
544
+ },
545
+
382
546
  async open(input) {
383
547
  const root = resolveProjectRoot(input.cwd);
384
548
  if (!root.ok) return root;
@@ -414,7 +578,7 @@ function createProjectPaneManagerInternal(options: InternalProjectPaneManagerOpt
414
578
  splitArgs.push(input.focus === true ? "--focus" : "--no-focus");
415
579
  const split = await client.run(splitArgs, { timeoutMs: 15_000, signal: input.signal });
416
580
  if (!split.ok) return projectPaneError(split.error.code, split.error.message, { projectRoot, details: split.error.details });
417
- const paneId = extractPaneId(split.data);
581
+ const paneId = herdrPaneFocusTarget(split.data).paneId;
418
582
  if (!paneId) return projectPaneError(options.legacyToolCompatibility ? "PANE_GONE" : "INVALID_PANE_RESPONSE", "Herdr pane split returned no pane id.", { projectRoot, details: split.data });
419
583
  const startupMessage = input.message?.trim();
420
584
  const command = projectPaneCommand(startupMessage);
@@ -466,29 +630,26 @@ function createProjectPaneManagerInternal(options: InternalProjectPaneManagerOpt
466
630
  if (!bindingResult.ok) return bindingResult;
467
631
  const existing = bindingResult.data;
468
632
  if (!existing) return { ok: true, data: { ...common(projectRoot), disposition: "absent" } };
469
- let runtime: ProjectPaneRuntime | undefined;
470
- if (input.requireIdle) {
471
- const live = await inspectPane(client, existing.paneId, input.signal);
472
- if (!live.ok) {
473
- if (live.error.code === "NOT_FOUND" || live.error.code === "PANE_GONE") {
474
- const removed = removeProjectPaneBinding(projectRoot);
475
- if (!removed.ok) return removed;
476
- return { ok: true, data: { ...common(projectRoot), disposition: "stale-binding-removed", binding: existing } };
477
- }
478
- return { ok: false, error: { ...live.error, projectRoot, bindingPath: projectPaneBindingPath(projectRoot) } };
479
- }
480
- runtime = live.data;
481
- const ownership = projectPaneOwnership(runtime, existing, projectRoot);
482
- if (ownership !== "verified") {
483
- return projectPaneError("PANE_OWNERSHIP_UNVERIFIED", `Project pane '${existing.paneId}' ownership is '${ownership}' for '${projectRoot}'.`, {
484
- projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: runtime,
485
- });
486
- }
487
- if (runtime.agentStatus !== "idle") {
488
- return projectPaneError("PANE_NOT_IDLE", `Project pane '${existing.paneId}' is '${runtime.agentStatus}', not explicitly idle.`, {
489
- projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: runtime,
490
- });
633
+ const live = await inspectPane(client, existing.paneId, input.signal);
634
+ if (!live.ok) {
635
+ if (live.error.code === "NOT_FOUND" || live.error.code === "PANE_GONE") {
636
+ const removed = removeProjectPaneBinding(projectRoot);
637
+ if (!removed.ok) return removed;
638
+ return { ok: true, data: { ...common(projectRoot), disposition: "stale-binding-removed", binding: existing } };
491
639
  }
640
+ return { ok: false, error: { ...live.error, projectRoot, bindingPath: projectPaneBindingPath(projectRoot) } };
641
+ }
642
+ const runtime = live.data;
643
+ const ownership = projectPaneOwnership(runtime, existing, projectRoot);
644
+ if (ownership !== "verified") {
645
+ return projectPaneError("PANE_OWNERSHIP_UNVERIFIED", `Project pane '${existing.paneId}' ownership is '${ownership}' for '${projectRoot}'.`, {
646
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: runtime,
647
+ });
648
+ }
649
+ if (runtime.agentStatus !== "idle") {
650
+ return projectPaneError("PANE_NOT_IDLE", `Project pane '${existing.paneId}' is '${runtime.agentStatus}', not explicitly idle.`, {
651
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: runtime,
652
+ });
492
653
  }
493
654
  const closed = await client.run(["pane", "close", existing.paneId], { timeoutMs: 10_000, signal: input.signal });
494
655
  if (!closed.ok && closed.error.code !== "NOT_FOUND" && closed.error.code !== "PANE_GONE") {
@@ -518,12 +679,19 @@ export async function closeProjectPane(options: CloseProjectPaneOptions): Promis
518
679
  return createProjectPaneManager().close(options);
519
680
  }
520
681
 
682
+ export async function focusProjectPane(options: GetProjectPaneStatusOptions): Promise<ProjectPaneResult<FocusProjectPaneData>> {
683
+ return createProjectPaneManager().focus(options);
684
+ }
685
+
521
686
  export async function handleHerdrProjectPaneAction(action: HerdrProjectPaneAction, params: ProjectPaneParams, deps: ProjectPaneDeps): Promise<AgentToolResult<Details>> {
522
687
  const requested = params.cwd?.trim() || deps.cwd;
688
+ const ownerRoot = path.resolve(deps.cwd);
523
689
  const manager = createProjectPaneManagerInternal({ client: deps.client, now: deps.now, legacyToolCompatibility: true });
690
+ const remember = (data: ProjectPaneStatusData) => rememberProjectPane(deps.state, data, deps.now?.().getTime() ?? Date.now());
524
691
  if (action === "project.status") {
525
692
  const status = await manager.status({ cwd: requested, signal: deps.signal });
526
693
  if (!status.ok) return toolResult(formatProjectPaneError(status.error), true);
694
+ remember(status.data);
527
695
  if (status.data.state === "absent") return toolResult(`No Herdr project pane binding exists for ${status.data.projectRoot}.`);
528
696
  if (status.data.state === "stale") {
529
697
  const reason = status.data.staleReason!;
@@ -532,15 +700,31 @@ export async function handleHerdrProjectPaneAction(action: HerdrProjectPaneActio
532
700
  return toolResult(`Herdr project pane ${status.data.binding!.paneId} is open for ${status.data.projectRoot}.\nBinding: ${status.data.bindingPath}`);
533
701
  }
534
702
  if (action === "project.close") {
535
- const closed = await manager.close({ cwd: requested, signal: deps.signal });
703
+ const closed = await manager.close({ cwd: requested, requireIdle: true, signal: deps.signal });
536
704
  if (!closed.ok) return toolResult(formatProjectPaneError(closed.error), true);
705
+ forgetProjectPane(deps.state, closed.data.projectRoot);
706
+ try { removeHerdrProjectPaneRoot(ownerRoot, closed.data.projectRoot); } catch (error) {
707
+ return toolResult(`Herdr project pane error (BINDING_REMOVE_FAILED): Failed to remove project pane root index '${projectPaneRootIndexPath(ownerRoot)}': ${error instanceof Error ? error.message : String(error)}`, true);
708
+ }
537
709
  if (closed.data.disposition === "absent") return toolResult(`No Herdr project pane binding exists for ${closed.data.projectRoot}.`);
538
710
  return toolResult(`Closed Herdr project pane ${closed.data.binding!.paneId} for ${closed.data.projectRoot}.`);
539
711
  }
540
712
  const opened = await manager.open({ cwd: requested, message: params.message, focus: params.focus, signal: deps.signal });
541
713
  if (!opened.ok) return toolResult(formatProjectPaneError(opened.error), true);
714
+ try { writeHerdrProjectPaneRoot(ownerRoot, opened.data.projectRoot); } catch (error) {
715
+ return toolResult(`Herdr project pane error (BINDING_WRITE_FAILED): Failed to persist project pane root index '${projectPaneRootIndexPath(ownerRoot)}': ${error instanceof Error ? error.message : String(error)}`, true);
716
+ }
717
+ const status = await manager.status({ cwd: opened.data.projectRoot, signal: deps.signal });
718
+ if (status.ok) remember(status.data);
542
719
  if (opened.data.disposition === "already-open") {
543
- return toolResult(`Herdr project pane ${opened.data.binding.paneId} is already open for ${opened.data.projectRoot}.${params.focus ? " Herdr cannot refocus an arbitrary raw pane id; select it in the Herdr UI." : ""}`);
720
+ if (params.focus) {
721
+ const focused = await manager.focus({ cwd: opened.data.projectRoot, signal: deps.signal });
722
+ if (!focused.ok) return toolResult(`Herdr project pane ${opened.data.binding.paneId} is already open for ${opened.data.projectRoot}. ${formatProjectPaneError(focused.error)}`, true);
723
+ const focusedStatus = await manager.status({ cwd: opened.data.projectRoot, signal: deps.signal });
724
+ if (focusedStatus.ok) remember(focusedStatus.data);
725
+ return toolResult(`Herdr project pane ${opened.data.binding.paneId} is already open for ${opened.data.projectRoot}. Focused ${focused.data.focused.tabId ? `tab ${focused.data.focused.tabId}` : `workspace ${focused.data.focused.workspaceId}`}.`);
726
+ }
727
+ return toolResult(`Herdr project pane ${opened.data.binding.paneId} is already open for ${opened.data.projectRoot}.`);
544
728
  }
545
729
  return toolResult(`Opened Herdr project pane ${opened.data.binding.paneId} for ${opened.data.projectRoot}. The pane runs its own Pi session; subagents launched there belong to that project.`);
546
730
  }
@@ -33,6 +33,8 @@ export interface HerdrStatusBridgeOptions {
33
33
  env?: Record<string, string | undefined>;
34
34
  /** Current authoritative active-run projection, used before TTL refresh. */
35
35
  getRuns?: () => Iterable<HerdrStatusRun>;
36
+ /** Current project panes opened by this Pi session. Views are excluded. */
37
+ getProjectPaneCount?: () => number;
36
38
  runHerdr: (args: readonly string[]) => void | Promise<void>;
37
39
  ttlMs?: number;
38
40
  refreshMs?: number;
@@ -109,12 +111,27 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
109
111
  let drainPromise = Promise.resolve();
110
112
  let refreshTimer: ReturnType<typeof setInterval> | undefined;
111
113
 
112
- const label = (): string => {
113
- const agents = [...new Set([...runs.values()].flatMap((run) => run.agents?.length ? run.agents : run.agent ? [run.agent] : []))];
114
+ const activeAgentNames = (): string[] => [...new Set([...runs.values()].flatMap((run) => run.agents?.length ? run.agents : run.agent ? [run.agent] : []))];
115
+ const activeSubagentCount = (): number => [...runs.values()].reduce((total, run) => total + Math.max(1, run.agents?.length ?? (run.agent ? 1 : 0)), 0);
116
+
117
+ const label = (includeAttention = false): string => {
118
+ const agents = activeAgentNames();
119
+ const activeCount = activeSubagentCount();
114
120
  const who = agents.length > 0
115
121
  ? ` (${agents.slice(0, 3).join(", ")}${agents.length > 3 ? ", …" : ""})`
116
122
  : "";
117
- return `⏳ ${runs.size} subagent${runs.size === 1 ? "" : "s"}${who}`;
123
+ const panes = Math.max(0, options.getProjectPaneCount?.() ?? 0);
124
+ const paneText = panes > 0 ? ` · ${panes} pane${panes === 1 ? "" : "s"}` : "";
125
+ const attention = includeAttention && attentionLabels.size > 0 ? " ⚠" : "";
126
+ return `⏳ ${activeCount} subagent${activeCount === 1 ? "" : "s"}${who}${paneText}${attention}`;
127
+ };
128
+
129
+ const titleSuffix = (): string | undefined => {
130
+ if (runs.size === 0) return undefined;
131
+ const agentNames = activeAgentNames();
132
+ const activeCount = activeSubagentCount();
133
+ const target = activeCount === 1 && agentNames.length === 1 ? agentNames[0]! : String(activeCount);
134
+ return `⏳${target}${attentionLabels.size > 0 ? "⚠" : ""}`;
118
135
  };
119
136
 
120
137
  const enqueue = (args: readonly string[]): void => {
@@ -150,11 +167,13 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
150
167
  "--applies-to-source", "herdr:pi",
151
168
  "--clear-state-labels",
152
169
  "--clear-token", "summary",
170
+ "--clear-token", "title-suffix",
153
171
  "--seq", seq,
154
172
  ]);
155
173
  return;
156
174
  }
157
- const text = label();
175
+ const text = label(true);
176
+ const suffix = titleSuffix();
158
177
  published = true;
159
178
  enqueue([
160
179
  "pane", "report-metadata", paneId,
@@ -165,6 +184,7 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
165
184
  "--state-label", `done=${text}`,
166
185
  "--state-label", `working=${text}`,
167
186
  "--token", `summary=${text}`,
187
+ ...(suffix ? ["--token", `title-suffix=${suffix}`] : []),
168
188
  "--ttl-ms", String(ttlMs),
169
189
  "--seq", seq,
170
190
  ]);
@@ -221,6 +241,7 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
221
241
  const clearAttention = (): void => {
222
242
  attentionLabels.clear();
223
243
  syncBlocked();
244
+ publish();
224
245
  };
225
246
 
226
247
  const raiseAttention = (runId: string, labelText: string): void => {
@@ -228,6 +249,7 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
228
249
  acknowledgedAttention.delete(runId);
229
250
  attentionLabels.set(runId, labelText);
230
251
  syncBlocked();
252
+ publish();
231
253
  };
232
254
 
233
255
  const replaceRuns = (nextRuns: Iterable<HerdrStatusRun>): void => {