pi-subagents 0.31.0 → 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 (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +170 -8
  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 +69 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +25 -1
  14. package/src/profiles/profiles.ts +637 -0
  15. package/src/runs/background/async-execution.ts +138 -33
  16. package/src/runs/background/async-job-tracker.ts +77 -1
  17. package/src/runs/background/async-resume.ts +11 -13
  18. package/src/runs/background/async-status.ts +41 -9
  19. package/src/runs/background/chain-root-attachment.ts +34 -4
  20. package/src/runs/background/control-channel.ts +227 -0
  21. package/src/runs/background/run-status.ts +1 -0
  22. package/src/runs/background/stale-run-reconciler.ts +28 -1
  23. package/src/runs/background/subagent-runner.ts +459 -113
  24. package/src/runs/foreground/chain-execution.ts +29 -7
  25. package/src/runs/foreground/execution.ts +24 -6
  26. package/src/runs/foreground/subagent-executor.ts +240 -44
  27. package/src/runs/shared/acceptance.ts +45 -22
  28. package/src/runs/shared/dynamic-fanout.ts +1 -1
  29. package/src/runs/shared/model-fallback.ts +4 -0
  30. package/src/runs/shared/nested-events.ts +58 -0
  31. package/src/runs/shared/parallel-utils.ts +49 -1
  32. package/src/runs/shared/pi-args.ts +5 -3
  33. package/src/runs/shared/pi-spawn.ts +52 -20
  34. package/src/runs/shared/single-output.ts +2 -0
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/worktree.ts +28 -5
  37. package/src/shared/artifacts.ts +15 -1
  38. package/src/shared/fork-context.ts +133 -22
  39. package/src/shared/types.ts +82 -3
  40. package/src/shared/utils.ts +99 -14
  41. package/src/slash/slash-commands.ts +726 -40
  42. 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,
@@ -43,6 +51,7 @@ import {
43
51
  DEFAULT_ARTIFACT_CONFIG,
44
52
  RESULTS_DIR,
45
53
  SLASH_RESULT_TYPE,
54
+ SLASH_TEXT_RESULT_TYPE,
46
55
  SUBAGENT_ASYNC_COMPLETE_EVENT,
47
56
  SUBAGENT_ASYNC_STARTED_EVENT,
48
57
  SUBAGENT_CONTROL_EVENT,
@@ -249,7 +258,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
249
258
  ensureAccessibleDir(ASYNC_DIR);
250
259
  cleanupOldChainDirs();
251
260
 
252
- const config = loadConfig();
261
+ let config = loadConfig();
253
262
  const asyncByDefault = config.asyncByDefault === true;
254
263
  const tempArtifactsDir = getArtifactsDir(null);
255
264
  cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
@@ -258,6 +267,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
258
267
  baseCwd: "",
259
268
  currentSessionId: null,
260
269
  subagentInProgress: false,
270
+ subagentSpawns: { sessionId: null, count: 0 },
261
271
  asyncJobs: new Map(),
262
272
  foregroundRuns: new Map(),
263
273
  foregroundControls: new Map(),
@@ -269,6 +279,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
269
279
  completionSeen: new Map(),
270
280
  watcher: null,
271
281
  watcherRestartTimer: null,
282
+ companionSuggestionStartupShown: false,
283
+ companionSuggestionListShown: false,
272
284
  resultFileCoalescer: {
273
285
  schedule: () => false,
274
286
  clear: () => {},
@@ -294,12 +306,16 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
294
306
  };
295
307
  globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
296
308
 
297
- const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
309
+ const { ensurePoller, handleStarted, handleComplete, resetJobs, restoreActiveJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
298
310
  const executor = createSubagentExecutor({
299
311
  pi,
300
312
  state,
301
313
  config,
302
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
+ },
303
319
  tempArtifactsDir,
304
320
  getSubagentSessionRoot,
305
321
  expandTilde,
@@ -312,6 +328,16 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
312
328
  return createSlashResultComponent(details, options, theme);
313
329
  });
314
330
 
331
+ pi.registerMessageRenderer<undefined>(SLASH_TEXT_RESULT_TYPE, (message, _options, _theme) => {
332
+ const content = typeof message.content === "string"
333
+ ? message.content
334
+ : message.content
335
+ .filter((entry) => entry.type === "text")
336
+ .map((entry) => entry.text)
337
+ .join("\n");
338
+ return new Text(content, 0, 0);
339
+ });
340
+
315
341
  pi.registerMessageRenderer<SubagentNotifyDetails>("subagent-notify", (message, options, theme) => {
316
342
  const content = typeof message.content === "string" ? message.content : "";
317
343
  const details = (message.details as SubagentNotifyDetails | undefined) ?? parseSubagentNotifyContent(content);
@@ -407,6 +433,28 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
407
433
  }, 0);
408
434
  }
409
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
+
410
458
  const tool: ToolDefinition<typeof SubagentParams, Details> = {
411
459
  name: "subagent",
412
460
  label: "Subagent",
@@ -418,6 +466,7 @@ EXECUTION (use exactly ONE mode):
418
466
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
419
467
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
420
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
421
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
422
471
 
423
472
  CHAIN TEMPLATE VARIABLES (use in task strings):
@@ -460,7 +509,7 @@ DIAGNOSTICS:
460
509
  }
461
510
  const isParallel = (args.tasks?.length ?? 0) > 0;
462
511
  const parallelCount = effectiveParallelTaskCount(args.tasks as Array<{ count?: unknown }> | undefined);
463
- 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]") : "";
464
513
  if (args.chain?.length)
465
514
  return new Text(
466
515
  `${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}`,
@@ -469,7 +518,7 @@ DIAGNOSTICS:
469
518
  );
470
519
  if (isParallel)
471
520
  return new Text(
472
- `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${parallelCount})`,
521
+ `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${parallelCount})${asyncLabel}`,
473
522
  0,
474
523
  0,
475
524
  );
@@ -553,6 +602,7 @@ DIAGNOSTICS:
553
602
  const resetSessionState = (ctx: ExtensionContext) => {
554
603
  state.baseCwd = ctx.cwd;
555
604
  state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
605
+ state.subagentSpawns = { sessionId: state.currentSessionId, count: 0 };
556
606
  // Set PI_SUBAGENT_PARENT_SESSION for permission-system forwarding.
557
607
  // Only set in the root session (the interactive UI session), not in
558
608
  // child subagent processes — children inherit the parent's value
@@ -565,15 +615,30 @@ DIAGNOSTICS:
565
615
  }
566
616
  }
567
617
  state.lastUiContext = ctx;
618
+ state.companionSuggestionStartupShown = false;
619
+ state.companionSuggestionListShown = false;
568
620
  cleanupSessionArtifacts(ctx);
569
621
  clearPendingForegroundControlNotices(state);
570
622
  resetJobs(ctx);
623
+ restoreActiveJobs(ctx);
571
624
  restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
572
625
  primeExistingResults();
573
626
  };
574
627
 
575
628
  pi.on("session_start", (_event, ctx) => {
576
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
+ });
577
642
  });
578
643
 
579
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)" })),
@@ -199,6 +199,23 @@ function getGlobalNpmRoot(): string | null {
199
199
  }
200
200
  }
201
201
 
202
+ function tmpNpmIntercomPackageDir(agentDir: string): string | undefined {
203
+ const tmpNpmDir = path.join(agentDir, "tmp", "extensions", "npm");
204
+ try {
205
+ const entries = fs.readdirSync(tmpNpmDir, { withFileTypes: true });
206
+ for (const entry of entries) {
207
+ if (!entry.isDirectory()) continue;
208
+ const pkgDir = path.join(tmpNpmDir, entry.name, "node_modules", PI_INTERCOM_PACKAGE_NAME);
209
+ if (fs.existsSync(pkgDir) && packageHasPiExtension(pkgDir)) {
210
+ return path.resolve(pkgDir);
211
+ }
212
+ }
213
+ } catch {
214
+ // ignore ENOTDIR, ENOENT, permission errors
215
+ }
216
+ return undefined;
217
+ }
218
+
202
219
  function configuredPiIntercomPackageDir(input: ResolveIntercomBridgeInput, agentDir: string): string | undefined {
203
220
  const projectConfigDir = input.cwd ? findNearestProjectConfigDir(path.resolve(input.cwd)) : undefined;
204
221
  const settingsFiles = [
@@ -235,7 +252,14 @@ function configuredPiIntercomPackageDir(input: ResolveIntercomBridgeInput, agent
235
252
  function resolveIntercomExtensionDir(input: ResolveIntercomBridgeInput, agentDir: string): string {
236
253
  const legacyDir = path.resolve(input.extensionDir ?? envIntercomExtensionDir() ?? defaultIntercomExtensionDir(agentDir));
237
254
  if (fs.existsSync(legacyDir)) return legacyDir;
238
- return configuredPiIntercomPackageDir(input, agentDir) ?? legacyDir;
255
+
256
+ const configured = configuredPiIntercomPackageDir(input, agentDir);
257
+ if (configured) return configured;
258
+
259
+ const tmpDir = tmpNpmIntercomPackageDir(agentDir);
260
+ if (tmpDir) return tmpDir;
261
+
262
+ return legacyDir;
239
263
  }
240
264
 
241
265
  function extensionSandboxAllowsIntercom(extensions: string[] | undefined, extensionDir: string): boolean {