pi-subagents 0.31.1 → 0.32.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 (39) hide show
  1. package/CHANGELOG.md +40 -4
  2. package/README.md +97 -7
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +6 -1
  5. package/src/agents/agent-management.ts +6 -1
  6. package/src/agents/agents.ts +55 -11
  7. package/src/extension/companion-suggestions.ts +359 -0
  8. package/src/extension/config.ts +27 -4
  9. package/src/extension/doctor.ts +2 -0
  10. package/src/extension/fanout-child.ts +1 -0
  11. package/src/extension/index.ts +58 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/runs/background/async-execution.ts +138 -33
  14. package/src/runs/background/async-job-tracker.ts +77 -1
  15. package/src/runs/background/async-status.ts +41 -9
  16. package/src/runs/background/chain-root-attachment.ts +34 -4
  17. package/src/runs/background/control-channel.ts +50 -0
  18. package/src/runs/background/run-status.ts +1 -0
  19. package/src/runs/background/stale-run-reconciler.ts +28 -1
  20. package/src/runs/background/subagent-runner.ts +454 -115
  21. package/src/runs/foreground/chain-execution.ts +29 -7
  22. package/src/runs/foreground/execution.ts +24 -6
  23. package/src/runs/foreground/subagent-executor.ts +209 -35
  24. package/src/runs/shared/acceptance.ts +45 -22
  25. package/src/runs/shared/dynamic-fanout.ts +1 -1
  26. package/src/runs/shared/model-fallback.ts +4 -0
  27. package/src/runs/shared/nested-events.ts +58 -0
  28. package/src/runs/shared/parallel-utils.ts +49 -1
  29. package/src/runs/shared/pi-args.ts +5 -3
  30. package/src/runs/shared/pi-spawn.ts +52 -20
  31. package/src/runs/shared/single-output.ts +2 -0
  32. package/src/runs/shared/subagent-prompt-runtime.ts +2 -2
  33. package/src/runs/shared/worktree.ts +28 -5
  34. package/src/shared/artifacts.ts +15 -1
  35. package/src/shared/fork-context.ts +133 -22
  36. package/src/shared/types.ts +81 -3
  37. package/src/shared/utils.ts +99 -14
  38. package/src/slash/slash-commands.ts +117 -0
  39. package/src/tui/render.ts +16 -4
@@ -0,0 +1,359 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { diagnoseIntercomBridge, type IntercomBridgeDiagnostic, resolveIntercomSessionTarget } from "../intercom/intercom-bridge.ts";
5
+ import type { CompanionSuggestionPackage, CompanionSuggestionSurface, ExtensionConfig, SubagentState } from "../shared/types.ts";
6
+ import { getAgentDir } from "../shared/utils.ts";
7
+ import { updateConfig } from "./config.ts";
8
+
9
+ const PROMPT_TEMPLATE_MODEL: CompanionSuggestionPackage = "pi-prompt-template-model";
10
+ const PI_INTERCOM: CompanionSuggestionPackage = "pi-intercom";
11
+ const COMPANION_PACKAGES = [PI_INTERCOM, PROMPT_TEMPLATE_MODEL] as const;
12
+ const DEFAULT_SURFACES: CompanionSuggestionSurface[] = ["session_start", "list", "doctor"];
13
+
14
+ interface SourceInfoLike {
15
+ path?: unknown;
16
+ source?: unknown;
17
+ baseDir?: unknown;
18
+ }
19
+
20
+ interface NamedRuntimeResource {
21
+ name?: unknown;
22
+ sourceInfo?: unknown;
23
+ }
24
+
25
+ interface IntercomConfigStatus {
26
+ enabled: boolean;
27
+ error?: string;
28
+ }
29
+
30
+ export interface CompanionPackageStatus {
31
+ packageName: CompanionSuggestionPackage;
32
+ active: boolean;
33
+ disabled: boolean;
34
+ dismissed: boolean;
35
+ surfaces: Set<CompanionSuggestionSurface>;
36
+ installCommand: string;
37
+ benefit: string;
38
+ statusSource: string;
39
+ reason: string;
40
+ details?: string[];
41
+ intercomBridge?: IntercomBridgeDiagnostic;
42
+ }
43
+
44
+ interface CollectCompanionStatusesInput {
45
+ pi: Pick<ExtensionAPI, "getAllTools" | "getCommands">;
46
+ config: ExtensionConfig;
47
+ cwd: string;
48
+ context?: "fresh" | "fork";
49
+ orchestratorTarget?: string;
50
+ workspaceKey?: string;
51
+ fast?: boolean;
52
+ }
53
+
54
+ interface CompanionMessageInput {
55
+ pi: Pick<ExtensionAPI, "sendMessage">;
56
+ ctx: ExtensionContext;
57
+ state: SubagentState;
58
+ statuses: CompanionPackageStatus[];
59
+ }
60
+
61
+ function commandBaseName(name: string): string {
62
+ return name.replace(/:\d+$/, "");
63
+ }
64
+
65
+ function sourceValueMatchesPackage(value: string, packageName: CompanionSuggestionPackage): boolean {
66
+ const normalized = value.replaceAll("\\", "/").toLowerCase();
67
+ const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
+ return new RegExp(`(^|[/:@])${escaped}($|[/:@])`).test(normalized);
69
+ }
70
+
71
+ function sourceInfoMatchesPackage(sourceInfo: unknown, packageName: CompanionSuggestionPackage): boolean {
72
+ if (!sourceInfo || typeof sourceInfo !== "object" || Array.isArray(sourceInfo)) return false;
73
+ const info = sourceInfo as SourceInfoLike;
74
+ return [info.path, info.source, info.baseDir]
75
+ .some((value) => typeof value === "string" && sourceValueMatchesPackage(value, packageName));
76
+ }
77
+
78
+ function namedResourcesFrom(value: unknown): NamedRuntimeResource[] {
79
+ return Array.isArray(value) ? value.filter((entry): entry is NamedRuntimeResource => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
80
+ }
81
+
82
+ function hasPackageCommand(pi: Pick<ExtensionAPI, "getCommands">, packageName: CompanionSuggestionPackage, commandName: string): boolean {
83
+ return namedResourcesFrom(pi.getCommands()).some((command) =>
84
+ typeof command.name === "string"
85
+ && commandBaseName(command.name) === commandName
86
+ && sourceInfoMatchesPackage(command.sourceInfo, packageName)
87
+ );
88
+ }
89
+
90
+ function hasPackageTool(pi: Pick<ExtensionAPI, "getAllTools">, packageName: CompanionSuggestionPackage, toolName: string): boolean {
91
+ return namedResourcesFrom(pi.getAllTools()).some((tool) =>
92
+ typeof tool.name === "string"
93
+ && commandBaseName(tool.name) === toolName
94
+ && sourceInfoMatchesPackage(tool.sourceInfo, packageName)
95
+ );
96
+ }
97
+
98
+ function nearestGitRoot(cwd: string): string | undefined {
99
+ let current = path.resolve(cwd);
100
+ while (true) {
101
+ if (fs.existsSync(path.join(current, ".git"))) return current;
102
+ const parent = path.dirname(current);
103
+ if (parent === current) return undefined;
104
+ current = parent;
105
+ }
106
+ }
107
+
108
+ export function companionWorkspaceKey(cwd: string): string {
109
+ return nearestGitRoot(cwd) ?? path.resolve(cwd);
110
+ }
111
+
112
+ function normalizeSurface(value: unknown): CompanionSuggestionSurface | undefined {
113
+ return value === "session_start" || value === "list" || value === "doctor" ? value : undefined;
114
+ }
115
+
116
+ function packageConfig(config: ExtensionConfig, packageName: CompanionSuggestionPackage) {
117
+ const companionConfig = config.companionSuggestions;
118
+ if (companionConfig === false) return { enabled: false, surfaces: new Set<CompanionSuggestionSurface>(), dismissed: false };
119
+ const packageSpecific = companionConfig?.packages?.[packageName];
120
+ const surfaces = Array.isArray(packageSpecific?.surfaces)
121
+ ? packageSpecific.surfaces.map(normalizeSurface).filter((surface): surface is CompanionSuggestionSurface => Boolean(surface))
122
+ : DEFAULT_SURFACES;
123
+ return {
124
+ enabled: companionConfig?.enabled !== false && packageSpecific?.enabled !== false,
125
+ surfaces: new Set(surfaces),
126
+ dismissedConfig: packageSpecific?.dismissed,
127
+ };
128
+ }
129
+
130
+ function isDismissed(config: ExtensionConfig, packageName: CompanionSuggestionPackage, workspaceKey: string): boolean {
131
+ const dismissed = packageConfig(config, packageName).dismissedConfig;
132
+ return dismissed?.user === true || dismissed?.workspaces?.includes(workspaceKey) === true;
133
+ }
134
+
135
+ function readPiIntercomConfigStatus(agentDir = getAgentDir()): IntercomConfigStatus {
136
+ const configPath = path.join(agentDir, "intercom", "config.json");
137
+ if (!fs.existsSync(configPath)) return { enabled: true };
138
+ try {
139
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as unknown;
140
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { enabled: true };
141
+ return { enabled: (parsed as { enabled?: unknown }).enabled !== false };
142
+ } catch (error) {
143
+ return { enabled: true, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
144
+ }
145
+ }
146
+
147
+ function promptTemplateModelStatus(input: CollectCompanionStatusesInput, workspaceKey: string): CompanionPackageStatus {
148
+ const config = packageConfig(input.config, PROMPT_TEMPLATE_MODEL);
149
+ const active = hasPackageCommand(input.pi, PROMPT_TEMPLATE_MODEL, "prompt-tool");
150
+ return {
151
+ packageName: PROMPT_TEMPLATE_MODEL,
152
+ active,
153
+ disabled: !config.enabled,
154
+ dismissed: isDismissed(input.config, PROMPT_TEMPLATE_MODEL, workspaceKey),
155
+ surfaces: config.surfaces,
156
+ installCommand: "pi install npm:pi-prompt-template-model",
157
+ benefit: "reusable prompt-template workflows with model/thinking/skill/subagent frontmatter",
158
+ statusSource: "active runtime command: prompt-tool",
159
+ reason: active ? "active" : "prompt-tool command from pi-prompt-template-model is not active in this session",
160
+ };
161
+ }
162
+
163
+ function piIntercomStatus(input: CollectCompanionStatusesInput, workspaceKey: string): CompanionPackageStatus {
164
+ const config = packageConfig(input.config, PI_INTERCOM);
165
+ const parentToolActive = hasPackageTool(input.pi, PI_INTERCOM, "intercom");
166
+ const intercomConfig = readPiIntercomConfigStatus();
167
+ const bridge = diagnoseIntercomBridge({
168
+ config: input.config.intercomBridge,
169
+ context: input.context,
170
+ orchestratorTarget: input.orchestratorTarget,
171
+ cwd: input.cwd,
172
+ globalNpmRoot: input.fast ? null : undefined,
173
+ });
174
+ const active = parentToolActive && intercomConfig.enabled && (!bridge.wantsIntercom || bridge.piIntercomAvailable);
175
+ const details = [
176
+ `parent runtime tool: ${parentToolActive ? "active" : "inactive"}`,
177
+ `bridge: ${bridge.active ? "active" : "inactive"}${bridge.reason ? ` (${bridge.reason})` : ""}`,
178
+ ...(intercomConfig.error ? [`intercom config warning: ${intercomConfig.error}; runtime assumes enabled`] : []),
179
+ ];
180
+ return {
181
+ packageName: PI_INTERCOM,
182
+ active,
183
+ disabled: !config.enabled,
184
+ dismissed: isDismissed(input.config, PI_INTERCOM, workspaceKey),
185
+ surfaces: config.surfaces,
186
+ installCommand: "pi install npm:pi-intercom",
187
+ benefit: "live supervisor decisions, progress updates, and grouped result delivery",
188
+ statusSource: "active runtime intercom tool plus intercom bridge diagnostics",
189
+ reason: active
190
+ ? "active"
191
+ : !intercomConfig.enabled
192
+ ? "pi-intercom config is disabled"
193
+ : parentToolActive
194
+ ? "pi-intercom is active in the parent runtime, but child bridge discovery is not ready"
195
+ : "intercom tool from pi-intercom is not active in this session",
196
+ details,
197
+ intercomBridge: bridge,
198
+ };
199
+ }
200
+
201
+ export function collectCompanionStatuses(input: CollectCompanionStatusesInput): CompanionPackageStatus[] {
202
+ const workspaceKey = input.workspaceKey ?? companionWorkspaceKey(input.cwd);
203
+ return [
204
+ piIntercomStatus(input, workspaceKey),
205
+ promptTemplateModelStatus(input, workspaceKey),
206
+ ];
207
+ }
208
+
209
+ function shouldRecommend(status: CompanionPackageStatus, surface: CompanionSuggestionSurface): boolean {
210
+ if (status.disabled || status.dismissed || status.active || !status.surfaces.has(surface)) return false;
211
+ if (status.packageName === PI_INTERCOM && status.reason === "pi-intercom config is disabled" && surface !== "doctor") return false;
212
+ if (status.packageName === PI_INTERCOM && status.intercomBridge?.wantsIntercom === false && surface !== "doctor") return false;
213
+ return true;
214
+ }
215
+
216
+ export function buildCompanionListLines(statuses: CompanionPackageStatus[]): string[] {
217
+ const recommended = statuses.filter((status) => shouldRecommend(status, "list"));
218
+ if (recommended.length === 0) return [];
219
+ const lines = ["Recommended companions:"];
220
+ for (const status of recommended) {
221
+ lines.push(`- ${status.packageName} is not active in this session.`);
222
+ lines.push(` Benefit: ${status.benefit}.`);
223
+ lines.push(` Run: ${status.installCommand}, then restart Pi or /reload.`);
224
+ lines.push(` Hide: /subagents-companions hide ${status.packageName} workspace`);
225
+ }
226
+ return lines;
227
+ }
228
+
229
+ export function buildCompanionDoctorLines(statuses: CompanionPackageStatus[]): string[] {
230
+ const lines = ["Companion packages"];
231
+ for (const status of statuses) {
232
+ const hidden = status.dismissed ? " recommendation hidden by config" : "";
233
+ const disabled = status.disabled ? " disabled by config" : "";
234
+ lines.push(`- ${status.packageName}: ${status.active ? "active" : "inactive"}${hidden}${disabled}`);
235
+ lines.push(` install: ${status.installCommand}`);
236
+ lines.push(` benefit: ${status.benefit}`);
237
+ lines.push(` status source: ${status.statusSource}`);
238
+ lines.push(` reason: ${status.reason}`);
239
+ for (const detail of status.details ?? []) lines.push(` ${detail}`);
240
+ }
241
+ return lines;
242
+ }
243
+
244
+ export function buildCompanionStartupMessage(statuses: CompanionPackageStatus[]): string | null {
245
+ const recommended = statuses.filter((status) => shouldRecommend(status, "session_start"));
246
+ if (recommended.length === 0) return null;
247
+ const lines = [
248
+ recommended.length === 1
249
+ ? `Recommended: install ${recommended[0]!.packageName} for pi-subagents.`
250
+ : "Recommended: install companion packages for pi-subagents.",
251
+ "",
252
+ ];
253
+ for (const status of recommended) {
254
+ lines.push(`- ${status.packageName}: ${status.benefit}.`);
255
+ lines.push(` Run: ${status.installCommand}`);
256
+ }
257
+ lines.push(
258
+ "",
259
+ recommended.length === 1 ? "I can help you run that install command." : "I can help you run those install commands.",
260
+ "",
261
+ "Or hide a recommendation:",
262
+ );
263
+ for (const status of recommended) {
264
+ lines.push(` /subagents-companions hide ${status.packageName} workspace`);
265
+ }
266
+ return lines.join("\n");
267
+ }
268
+
269
+ export function maybeSendCompanionStartupMessage(input: CompanionMessageInput): void {
270
+ if (!input.ctx.hasUI || input.state.companionSuggestionStartupShown) return;
271
+ const message = buildCompanionStartupMessage(input.statuses);
272
+ if (!message) return;
273
+ input.state.companionSuggestionStartupShown = true;
274
+ input.pi.sendMessage({
275
+ customType: "subagent_companion_suggestions",
276
+ content: message,
277
+ display: true,
278
+ details: { packages: input.statuses.filter((status) => shouldRecommend(status, "session_start")).map((status) => status.packageName) },
279
+ });
280
+ }
281
+
282
+ function parseCompanionPackage(value: string | undefined): CompanionSuggestionPackage | undefined {
283
+ return COMPANION_PACKAGES.find((packageName) => packageName === value);
284
+ }
285
+
286
+ function packageDismissedWorkspaces(value: unknown): string[] {
287
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
288
+ }
289
+
290
+ export function updateCompanionDismissal(packageName: CompanionSuggestionPackage, scope: "workspace" | "user" | "show", cwd: string): ExtensionConfig {
291
+ const workspaceKey = companionWorkspaceKey(cwd);
292
+ return updateConfig((current) => {
293
+ const companionSuggestions = current.companionSuggestions === false
294
+ ? { enabled: false }
295
+ : current.companionSuggestions ?? {};
296
+ const packages = companionSuggestions.packages ?? {};
297
+ const packageConfig = packages[packageName] ?? {};
298
+ const dismissed = { ...(packageConfig.dismissed ?? {}) };
299
+ if (scope === "user") {
300
+ dismissed.user = true;
301
+ } else if (scope === "workspace") {
302
+ dismissed.workspaces = [...new Set([...packageDismissedWorkspaces(dismissed.workspaces), workspaceKey])];
303
+ } else {
304
+ delete dismissed.user;
305
+ dismissed.workspaces = packageDismissedWorkspaces(dismissed.workspaces).filter((entry) => entry !== workspaceKey);
306
+ if (dismissed.workspaces.length === 0) delete dismissed.workspaces;
307
+ }
308
+ return {
309
+ ...current,
310
+ companionSuggestions: {
311
+ ...companionSuggestions,
312
+ packages: {
313
+ ...packages,
314
+ [packageName]: {
315
+ ...packageConfig,
316
+ ...(Object.keys(dismissed).length > 0 ? { dismissed } : { dismissed: undefined }),
317
+ },
318
+ },
319
+ },
320
+ };
321
+ });
322
+ }
323
+
324
+ export function buildCompanionCommandStatus(statuses: CompanionPackageStatus[]): string {
325
+ return buildCompanionDoctorLines(statuses).join("\n");
326
+ }
327
+
328
+ export function handleCompanionCommand(args: string, ctx: ExtensionContext, statuses: CompanionPackageStatus[]): { text: string; updatedConfig?: ExtensionConfig; error?: boolean } {
329
+ const parts = args.trim().split(/\s+/).filter(Boolean);
330
+ if (parts.length === 0 || parts[0] === "status") {
331
+ return { text: buildCompanionCommandStatus(statuses) };
332
+ }
333
+ if (parts[0] !== "hide" && parts[0] !== "show") {
334
+ return { text: "Usage: /subagents-companions status | hide <pi-intercom|pi-prompt-template-model> <workspace|user> | show <pi-intercom|pi-prompt-template-model>", error: true };
335
+ }
336
+ const packageName = parseCompanionPackage(parts[1]);
337
+ if (!packageName) {
338
+ return { text: "Unknown companion package. Use pi-intercom or pi-prompt-template-model.", error: true };
339
+ }
340
+ if (parts[0] === "show") {
341
+ return { text: `Showing ${packageName} recommendations for this workspace again.`, updatedConfig: updateCompanionDismissal(packageName, "show", ctx.cwd) };
342
+ }
343
+ const scope = parts[2];
344
+ if (scope !== "workspace" && scope !== "user") {
345
+ return { text: "Usage: /subagents-companions hide <pi-intercom|pi-prompt-template-model> <workspace|user>", error: true };
346
+ }
347
+ return {
348
+ text: scope === "user" ? `Hid ${packageName} recommendations for this user.` : `Hid ${packageName} recommendations for this workspace.`,
349
+ updatedConfig: updateCompanionDismissal(packageName, scope, ctx.cwd),
350
+ };
351
+ }
352
+
353
+ export function resolveCompanionOrchestratorTarget(pi: Pick<ExtensionAPI, "getSessionName">, ctx: ExtensionContext): string | undefined {
354
+ try {
355
+ return resolveIntercomSessionTarget(pi.getSessionName(), ctx.sessionManager.getSessionId());
356
+ } catch {
357
+ return undefined;
358
+ }
359
+ }
@@ -3,12 +3,35 @@ import * as path from "node:path";
3
3
  import type { ExtensionConfig } from "../shared/types.ts";
4
4
  import { getAgentDir } from "../shared/utils.ts";
5
5
 
6
+ export function getConfigPath(): string {
7
+ return path.join(getAgentDir(), "extensions", "subagent", "config.json");
8
+ }
9
+
10
+ function readConfigForUpdate(configPath = getConfigPath()): ExtensionConfig {
11
+ if (!fs.existsSync(configPath)) return {};
12
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as unknown;
13
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
14
+ throw new Error(`Subagent config at '${configPath}' must be a JSON object`);
15
+ }
16
+ return parsed as ExtensionConfig;
17
+ }
18
+
19
+ export function saveConfig(config: ExtensionConfig, configPath = getConfigPath()): void {
20
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
21
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf-8");
22
+ }
23
+
24
+ export function updateConfig(updater: (config: ExtensionConfig) => ExtensionConfig): ExtensionConfig {
25
+ const configPath = getConfigPath();
26
+ const next = updater(readConfigForUpdate(configPath));
27
+ saveConfig(next, configPath);
28
+ return next;
29
+ }
30
+
6
31
  export function loadConfig(): ExtensionConfig {
7
- const configPath = path.join(getAgentDir(), "extensions", "subagent", "config.json");
32
+ const configPath = getConfigPath();
8
33
  try {
9
- if (fs.existsSync(configPath)) {
10
- return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ExtensionConfig;
11
- }
34
+ return readConfigForUpdate(configPath);
12
35
  } catch (error) {
13
36
  console.error(`Failed to load subagent config from '${configPath}':`, error);
14
37
  }
@@ -39,6 +39,7 @@ interface DoctorReportInput {
39
39
  sessionError?: string;
40
40
  expandTilde?: (value: string) => string;
41
41
  paths?: DoctorPaths;
42
+ companionPackageLines?: string[];
42
43
  deps?: Partial<DoctorDeps>;
43
44
  }
44
45
 
@@ -215,6 +216,7 @@ export function buildDoctorReport(input: DoctorReportInput): string {
215
216
  orchestratorTarget: input.orchestratorTarget,
216
217
  cwd: input.cwd,
217
218
  }), input.context).join("\n")).split("\n"),
219
+ ...(input.companionPackageLines?.length ? ["", ...input.companionPackageLines] : []),
218
220
  ];
219
221
  return lines.join("\n");
220
222
  }
@@ -31,6 +31,7 @@ function createChildSafeState(): SubagentState {
31
31
  baseCwd: "",
32
32
  currentSessionId: null,
33
33
  subagentInProgress: false,
34
+ subagentSpawns: { sessionId: null, count: 0 },
34
35
  asyncJobs: new Map(),
35
36
  foregroundRuns: new Map(),
36
37
  foregroundControls: new Map(),
@@ -36,6 +36,14 @@ import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/back
36
36
  import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "../runs/shared/pi-args.ts";
37
37
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
38
38
  import { loadConfig } from "./config.ts";
39
+ import {
40
+ buildCompanionDoctorLines,
41
+ buildCompanionListLines,
42
+ collectCompanionStatuses,
43
+ handleCompanionCommand,
44
+ maybeSendCompanionStartupMessage,
45
+ resolveCompanionOrchestratorTarget,
46
+ } from "./companion-suggestions.ts";
39
47
  import {
40
48
  type Details,
41
49
  type SubagentState,
@@ -250,7 +258,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
250
258
  ensureAccessibleDir(ASYNC_DIR);
251
259
  cleanupOldChainDirs();
252
260
 
253
- const config = loadConfig();
261
+ let config = loadConfig();
254
262
  const asyncByDefault = config.asyncByDefault === true;
255
263
  const tempArtifactsDir = getArtifactsDir(null);
256
264
  cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
@@ -259,6 +267,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
259
267
  baseCwd: "",
260
268
  currentSessionId: null,
261
269
  subagentInProgress: false,
270
+ subagentSpawns: { sessionId: null, count: 0 },
262
271
  asyncJobs: new Map(),
263
272
  foregroundRuns: new Map(),
264
273
  foregroundControls: new Map(),
@@ -270,6 +279,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
270
279
  completionSeen: new Map(),
271
280
  watcher: null,
272
281
  watcherRestartTimer: null,
282
+ companionSuggestionStartupShown: false,
283
+ companionSuggestionListShown: false,
273
284
  resultFileCoalescer: {
274
285
  schedule: () => false,
275
286
  clear: () => {},
@@ -295,12 +306,16 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
295
306
  };
296
307
  globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
297
308
 
298
- const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
309
+ const { ensurePoller, handleStarted, handleComplete, resetJobs, restoreActiveJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
299
310
  const executor = createSubagentExecutor({
300
311
  pi,
301
312
  state,
302
313
  config,
303
314
  asyncByDefault,
315
+ companionSuggestionLines: ({ surface, cwd, context, orchestratorTarget }) => {
316
+ const statuses = collectCompanionStatuses({ pi, config, cwd, context, orchestratorTarget });
317
+ return surface === "doctor" ? buildCompanionDoctorLines(statuses) : buildCompanionListLines(statuses);
318
+ },
304
319
  tempArtifactsDir,
305
320
  getSubagentSessionRoot,
306
321
  expandTilde,
@@ -418,6 +433,28 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
418
433
  }, 0);
419
434
  }
420
435
 
436
+ pi.registerCommand("subagents-companions", {
437
+ description: "Show or hide pi-subagents companion package recommendations",
438
+ handler: async (args, ctx) => {
439
+ try {
440
+ const statuses = collectCompanionStatuses({
441
+ pi,
442
+ config,
443
+ cwd: ctx.cwd,
444
+ orchestratorTarget: resolveCompanionOrchestratorTarget(pi, ctx),
445
+ });
446
+ const result = handleCompanionCommand(args, ctx, statuses);
447
+ if (result.updatedConfig) config = result.updatedConfig;
448
+ pi.sendMessage({ content: result.text, display: true });
449
+ if (result.error && ctx.hasUI) ctx.ui.notify(result.text, "error");
450
+ } catch (error) {
451
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
452
+ pi.sendMessage({ content: `Failed to update companion suggestions: ${message}`, display: true });
453
+ if (ctx.hasUI) ctx.ui.notify(`Failed to update companion suggestions: ${message}`, "error");
454
+ }
455
+ },
456
+ });
457
+
421
458
  const tool: ToolDefinition<typeof SubagentParams, Details> = {
422
459
  name: "subagent",
423
460
  label: "Subagent",
@@ -429,6 +466,7 @@ EXECUTION (use exactly ONE mode):
429
466
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
430
467
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
431
468
  • Optional context: { context: "fresh" | "fork" } (explicit value overrides every child; when omitted, each requested agent uses its own defaultContext, otherwise "fresh"; inspect agent defaults via { action: "list" })
469
+ • Optional timeout: { timeoutMs } or { maxRuntimeMs } sets a run-level max runtime for foreground and async/background runs
432
470
  • If { action: "list" } shows proactive skill subagent suggestions, consider a small fresh-context fanout for broad tasks where one of those skills would materially help
433
471
 
434
472
  CHAIN TEMPLATE VARIABLES (use in task strings):
@@ -471,7 +509,7 @@ DIAGNOSTICS:
471
509
  }
472
510
  const isParallel = (args.tasks?.length ?? 0) > 0;
473
511
  const parallelCount = effectiveParallelTaskCount(args.tasks as Array<{ count?: unknown }> | undefined);
474
- const asyncLabel = args.async === true && args.clarify !== true && !isParallel ? theme.fg("warning", " [async]") : "";
512
+ const asyncLabel = args.async === true && args.clarify !== true ? theme.fg("warning", " [async]") : "";
475
513
  if (args.chain?.length)
476
514
  return new Text(
477
515
  `${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}`,
@@ -480,7 +518,7 @@ DIAGNOSTICS:
480
518
  );
481
519
  if (isParallel)
482
520
  return new Text(
483
- `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${parallelCount})`,
521
+ `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${parallelCount})${asyncLabel}`,
484
522
  0,
485
523
  0,
486
524
  );
@@ -564,6 +602,7 @@ DIAGNOSTICS:
564
602
  const resetSessionState = (ctx: ExtensionContext) => {
565
603
  state.baseCwd = ctx.cwd;
566
604
  state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
605
+ state.subagentSpawns = { sessionId: state.currentSessionId, count: 0 };
567
606
  // Set PI_SUBAGENT_PARENT_SESSION for permission-system forwarding.
568
607
  // Only set in the root session (the interactive UI session), not in
569
608
  // child subagent processes — children inherit the parent's value
@@ -576,15 +615,30 @@ DIAGNOSTICS:
576
615
  }
577
616
  }
578
617
  state.lastUiContext = ctx;
618
+ state.companionSuggestionStartupShown = false;
619
+ state.companionSuggestionListShown = false;
579
620
  cleanupSessionArtifacts(ctx);
580
621
  clearPendingForegroundControlNotices(state);
581
622
  resetJobs(ctx);
623
+ restoreActiveJobs(ctx);
582
624
  restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
583
625
  primeExistingResults();
584
626
  };
585
627
 
586
628
  pi.on("session_start", (_event, ctx) => {
587
629
  resetSessionState(ctx);
630
+ maybeSendCompanionStartupMessage({
631
+ pi,
632
+ ctx,
633
+ state,
634
+ statuses: collectCompanionStatuses({
635
+ pi,
636
+ config,
637
+ cwd: ctx.cwd,
638
+ orchestratorTarget: resolveCompanionOrchestratorTarget(pi, ctx),
639
+ fast: true,
640
+ }),
641
+ });
588
642
  });
589
643
 
590
644
  pi.on("session_shutdown", () => {
@@ -235,8 +235,8 @@ const SubagentParamsSchema = Type.Object({
235
235
  })),
236
236
  chainDir: Type.Optional(Type.String({ description: "Persistent chain artifact directory; defaults to user-scoped temp storage." })),
237
237
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
238
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Foreground timeout ms; alias of maxRuntimeMs." })),
239
- maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias of timeoutMs for foreground timeout." })),
238
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Optional run-level timeout in ms for foreground and async/background runs. Alias of maxRuntimeMs." })),
239
+ maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias of timeoutMs for optional run-level timeout in foreground and async/background runs." })),
240
240
  agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
241
241
  cwd: Type.Optional(Type.String()),
242
242
  artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),