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.
- package/CHANGELOG.md +48 -0
- package/README.md +170 -8
- 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 +69 -4
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +25 -1
- package/src/profiles/profiles.ts +637 -0
- package/src/runs/background/async-execution.ts +138 -33
- package/src/runs/background/async-job-tracker.ts +77 -1
- package/src/runs/background/async-resume.ts +11 -13
- 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 +227 -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 +459 -113
- package/src/runs/foreground/chain-execution.ts +29 -7
- package/src/runs/foreground/execution.ts +24 -6
- package/src/runs/foreground/subagent-executor.ts +240 -44
- 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 +3 -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 +82 -3
- package/src/shared/utils.ts +99 -14
- package/src/slash/slash-commands.ts +726 -40
- 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,
|
|
@@ -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
|
-
|
|
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
|
|
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", () => {
|
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)" })),
|
|
@@ -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
|
-
|
|
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 {
|