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.
- package/CHANGELOG.md +40 -4
- package/README.md +97 -7
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -1
- package/src/agents/agent-management.ts +6 -1
- package/src/agents/agents.ts +55 -11
- package/src/extension/companion-suggestions.ts +359 -0
- package/src/extension/config.ts +27 -4
- package/src/extension/doctor.ts +2 -0
- package/src/extension/fanout-child.ts +1 -0
- package/src/extension/index.ts +58 -4
- package/src/extension/schemas.ts +2 -2
- package/src/runs/background/async-execution.ts +138 -33
- package/src/runs/background/async-job-tracker.ts +77 -1
- package/src/runs/background/async-status.ts +41 -9
- package/src/runs/background/chain-root-attachment.ts +34 -4
- package/src/runs/background/control-channel.ts +50 -0
- package/src/runs/background/run-status.ts +1 -0
- package/src/runs/background/stale-run-reconciler.ts +28 -1
- package/src/runs/background/subagent-runner.ts +454 -115
- package/src/runs/foreground/chain-execution.ts +29 -7
- package/src/runs/foreground/execution.ts +24 -6
- package/src/runs/foreground/subagent-executor.ts +209 -35
- package/src/runs/shared/acceptance.ts +45 -22
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/model-fallback.ts +4 -0
- package/src/runs/shared/nested-events.ts +58 -0
- package/src/runs/shared/parallel-utils.ts +49 -1
- package/src/runs/shared/pi-args.ts +5 -3
- package/src/runs/shared/pi-spawn.ts +52 -20
- package/src/runs/shared/single-output.ts +2 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +2 -2
- package/src/runs/shared/worktree.ts +28 -5
- package/src/shared/artifacts.ts +15 -1
- package/src/shared/fork-context.ts +133 -22
- package/src/shared/types.ts +81 -3
- package/src/shared/utils.ts +99 -14
- package/src/slash/slash-commands.ts +117 -0
- 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
|
+
}
|
package/src/extension/config.ts
CHANGED
|
@@ -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 =
|
|
32
|
+
const configPath = getConfigPath();
|
|
8
33
|
try {
|
|
9
|
-
|
|
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
|
}
|
package/src/extension/doctor.ts
CHANGED
|
@@ -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
|
}
|
package/src/extension/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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", () => {
|
package/src/extension/schemas.ts
CHANGED
|
@@ -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: "
|
|
239
|
-
maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias of timeoutMs for foreground
|
|
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)" })),
|