pi-subagents 0.30.0 → 0.31.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 +26 -0
- package/README.md +116 -17
- package/agents/context-builder.md +3 -3
- package/agents/planner.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/scout.md +1 -1
- package/package.json +7 -7
- package/skills/pi-subagents/SKILL.md +5 -0
- package/src/agents/agent-management.ts +170 -6
- package/src/agents/agent-serializer.ts +31 -13
- package/src/agents/agents.ts +207 -23
- package/src/agents/frontmatter.ts +66 -2
- package/src/agents/skills.ts +117 -20
- package/src/extension/doctor.ts +20 -0
- package/src/extension/fanout-child.ts +1 -0
- package/src/extension/index.ts +47 -4
- package/src/extension/schemas.ts +10 -76
- package/src/intercom/intercom-bridge.ts +2 -3
- package/src/runs/background/async-execution.ts +14 -4
- package/src/runs/background/async-job-tracker.ts +56 -11
- package/src/runs/background/result-watcher.ts +11 -2
- package/src/runs/background/stale-run-reconciler.ts +9 -4
- package/src/runs/background/subagent-runner.ts +79 -3
- package/src/runs/foreground/chain-execution.ts +26 -2
- package/src/runs/foreground/execution.ts +113 -8
- package/src/runs/foreground/subagent-executor.ts +325 -77
- package/src/runs/shared/acceptance.ts +285 -34
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/dynamic-fanout.ts +4 -2
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
- package/src/runs/shared/parallel-utils.ts +6 -1
- package/src/runs/shared/pi-args.ts +9 -1
- package/src/runs/shared/single-output.ts +15 -1
- package/src/shared/settings.ts +1 -0
- package/src/shared/types.ts +8 -2
- package/src/shared/utils.ts +19 -1
- package/src/slash/prompt-template-bridge.ts +26 -3
- package/src/slash/slash-commands.ts +33 -3
- package/src/tui/render.ts +265 -13
package/src/agents/skills.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { execSync } from "node:child_process";
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import { getAgentDir } from "../shared/utils.ts";
|
|
9
|
+
import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
|
|
10
10
|
|
|
11
11
|
export type SkillSource =
|
|
12
12
|
| "project"
|
|
@@ -23,6 +23,7 @@ interface ResolvedSkill {
|
|
|
23
23
|
name: string;
|
|
24
24
|
path: string;
|
|
25
25
|
content: string;
|
|
26
|
+
description?: string;
|
|
26
27
|
source: SkillSource;
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -50,7 +51,6 @@ const MAX_CACHE_SIZE = 50;
|
|
|
50
51
|
let loadSkillsCache: { cwd: string; agentDir: string; skills: CachedSkillEntry[]; timestamp: number } | null = null;
|
|
51
52
|
const LOAD_SKILLS_CACHE_TTL_MS = 5000;
|
|
52
53
|
|
|
53
|
-
const CONFIG_DIR = ".pi";
|
|
54
54
|
const SUBAGENT_ORCHESTRATION_SKILL = "pi-subagents";
|
|
55
55
|
|
|
56
56
|
const SOURCE_PRIORITY: Record<SkillSource, number> = {
|
|
@@ -134,8 +134,9 @@ function getGlobalNpmRoot(): string | null {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
function collectInstalledPackageSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
|
|
137
|
+
const projectConfigDir = getProjectConfigDir(cwd);
|
|
137
138
|
const dirs: SkillSearchPath[] = [
|
|
138
|
-
{ path: path.join(
|
|
139
|
+
{ path: path.join(projectConfigDir, "npm", "node_modules"), source: "project-package" },
|
|
139
140
|
{ path: path.join(agentDir, "npm", "node_modules"), source: "user-package" },
|
|
140
141
|
];
|
|
141
142
|
|
|
@@ -186,8 +187,9 @@ function collectInstalledPackageSkillPaths(cwd: string, agentDir: string): Skill
|
|
|
186
187
|
|
|
187
188
|
function collectSettingsSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
|
|
188
189
|
const results: SkillSearchPath[] = [];
|
|
190
|
+
const projectConfigDir = getProjectConfigDir(cwd);
|
|
189
191
|
const settingsFiles = [
|
|
190
|
-
{ file: path.join(
|
|
192
|
+
{ file: path.join(projectConfigDir, "settings.json"), base: projectConfigDir, source: "project-settings" as const },
|
|
191
193
|
{ file: path.join(agentDir, "settings.json"), base: agentDir, source: "user-settings" as const },
|
|
192
194
|
];
|
|
193
195
|
|
|
@@ -286,8 +288,9 @@ function resolveSettingsPackageRoot(source: string, baseDir: string): string | u
|
|
|
286
288
|
}
|
|
287
289
|
|
|
288
290
|
function collectSettingsPackageSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
|
|
291
|
+
const projectConfigDir = getProjectConfigDir(cwd);
|
|
289
292
|
const settingsFiles = [
|
|
290
|
-
{ file: path.join(
|
|
293
|
+
{ file: path.join(projectConfigDir, "settings.json"), base: projectConfigDir, source: "project-package" as const },
|
|
291
294
|
{ file: path.join(agentDir, "settings.json"), base: agentDir, source: "user-package" as const },
|
|
292
295
|
];
|
|
293
296
|
const results: SkillSearchPath[] = [];
|
|
@@ -316,8 +319,9 @@ function collectSettingsPackageSkillPaths(cwd: string, agentDir: string): SkillS
|
|
|
316
319
|
}
|
|
317
320
|
|
|
318
321
|
function buildSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
|
|
322
|
+
const projectConfigDir = getProjectConfigDir(cwd);
|
|
319
323
|
const skillPaths: SkillSearchPath[] = [
|
|
320
|
-
{ path: path.join(
|
|
324
|
+
{ path: path.join(projectConfigDir, "skills"), source: "project" },
|
|
321
325
|
{ path: path.join(cwd, ".agents", "skills"), source: "project" },
|
|
322
326
|
{ path: path.join(agentDir, "skills"), source: "user" },
|
|
323
327
|
{ path: path.join(os.homedir(), ".agents", "skills"), source: "user" },
|
|
@@ -330,7 +334,8 @@ function buildSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
|
|
|
330
334
|
const deduped = new Map<string, SkillSearchPath>();
|
|
331
335
|
for (const entry of skillPaths) {
|
|
332
336
|
const resolvedPath = path.resolve(entry.path);
|
|
333
|
-
|
|
337
|
+
const existing = deduped.get(resolvedPath);
|
|
338
|
+
if (!existing || (SOURCE_PRIORITY[entry.source] ?? 0) > (SOURCE_PRIORITY[existing.source] ?? 0)) {
|
|
334
339
|
deduped.set(resolvedPath, { path: resolvedPath, source: entry.source });
|
|
335
340
|
}
|
|
336
341
|
}
|
|
@@ -340,9 +345,9 @@ function buildSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
|
|
|
340
345
|
function inferSkillSource(filePath: string, cwd: string, agentDir: string, sourceHint?: SkillSource): SkillSource {
|
|
341
346
|
if (sourceHint) return sourceHint;
|
|
342
347
|
|
|
343
|
-
const projectConfigRoot = path.resolve(cwd
|
|
344
|
-
const projectSkillsRoot = path.resolve(
|
|
345
|
-
const projectPackagesRoot = path.resolve(
|
|
348
|
+
const projectConfigRoot = path.resolve(getProjectConfigDir(cwd));
|
|
349
|
+
const projectSkillsRoot = path.resolve(projectConfigRoot, "skills");
|
|
350
|
+
const projectPackagesRoot = path.resolve(projectConfigRoot, "npm", "node_modules");
|
|
346
351
|
const projectAgentsRoot = path.resolve(cwd, ".agents");
|
|
347
352
|
const userSkillsRoot = path.resolve(agentDir, "skills");
|
|
348
353
|
const userPackagesRoot = path.resolve(agentDir, "npm", "node_modules");
|
|
@@ -393,23 +398,86 @@ function maybeReadSkillDescription(filePath: string): string | undefined {
|
|
|
393
398
|
|
|
394
399
|
function collectFilesystemSkills(cwd: string, agentDir: string, skillPaths: SkillSearchPath[]): CachedSkillEntry[] {
|
|
395
400
|
const entries: CachedSkillEntry[] = [];
|
|
396
|
-
const seen = new
|
|
401
|
+
const seen = new Map<string, number>();
|
|
402
|
+
const visitedDirectories = new Map<string, number>();
|
|
397
403
|
let order = 0;
|
|
398
404
|
|
|
399
405
|
const pushEntry = (name: string, filePath: string, sourceHint?: SkillSource) => {
|
|
400
406
|
const resolvedFile = path.resolve(filePath);
|
|
401
|
-
if (seen.has(resolvedFile)) return;
|
|
402
407
|
if (!fs.existsSync(resolvedFile)) return;
|
|
403
|
-
|
|
408
|
+
const source = inferSkillSource(resolvedFile, cwd, agentDir, sourceHint);
|
|
409
|
+
const existingIndex = seen.get(resolvedFile);
|
|
410
|
+
if (existingIndex !== undefined) {
|
|
411
|
+
const existing = entries[existingIndex];
|
|
412
|
+
if (existing && (SOURCE_PRIORITY[source] ?? 0) > (SOURCE_PRIORITY[existing.source] ?? 0)) {
|
|
413
|
+
entries[existingIndex] = {
|
|
414
|
+
...existing,
|
|
415
|
+
name,
|
|
416
|
+
source,
|
|
417
|
+
description: maybeReadSkillDescription(resolvedFile),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
seen.set(resolvedFile, entries.length);
|
|
404
423
|
entries.push({
|
|
405
424
|
name,
|
|
406
425
|
filePath: resolvedFile,
|
|
407
|
-
source
|
|
426
|
+
source,
|
|
408
427
|
description: maybeReadSkillDescription(resolvedFile),
|
|
409
428
|
order: order++,
|
|
410
429
|
});
|
|
411
430
|
};
|
|
412
431
|
|
|
432
|
+
const shouldSkipDirectory = (name: string) => name.startsWith(".") || name === "node_modules";
|
|
433
|
+
|
|
434
|
+
const markDirectoryVisited = (dirPath: string, sourceHint?: SkillSource): boolean => {
|
|
435
|
+
let resolvedDir: string;
|
|
436
|
+
try {
|
|
437
|
+
resolvedDir = fs.realpathSync(dirPath);
|
|
438
|
+
} catch {
|
|
439
|
+
resolvedDir = path.resolve(dirPath);
|
|
440
|
+
}
|
|
441
|
+
const priority = sourceHint ? SOURCE_PRIORITY[sourceHint] ?? 0 : SOURCE_PRIORITY.unknown;
|
|
442
|
+
const previousPriority = visitedDirectories.get(resolvedDir);
|
|
443
|
+
if (previousPriority !== undefined && previousPriority >= priority) return false;
|
|
444
|
+
visitedDirectories.set(resolvedDir, priority);
|
|
445
|
+
return true;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const walkSkillDirectories = (dirPath: string, sourceHint?: SkillSource) => {
|
|
449
|
+
if (!markDirectoryVisited(dirPath, sourceHint)) return;
|
|
450
|
+
|
|
451
|
+
const skillFile = path.join(dirPath, "SKILL.md");
|
|
452
|
+
if (fs.existsSync(skillFile)) {
|
|
453
|
+
pushEntry(path.basename(dirPath), skillFile, sourceHint);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let entriesInDir: fs.Dirent[];
|
|
458
|
+
try {
|
|
459
|
+
entriesInDir = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
460
|
+
} catch {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const entry of entriesInDir) {
|
|
465
|
+
if (shouldSkipDirectory(entry.name)) continue;
|
|
466
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
467
|
+
|
|
468
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
469
|
+
let stat: fs.Stats;
|
|
470
|
+
try {
|
|
471
|
+
stat = fs.statSync(entryPath);
|
|
472
|
+
} catch {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (stat.isDirectory()) {
|
|
476
|
+
walkSkillDirectories(entryPath, sourceHint);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
413
481
|
for (const skillPath of skillPaths) {
|
|
414
482
|
if (!fs.existsSync(skillPath.path)) continue;
|
|
415
483
|
|
|
@@ -435,8 +503,11 @@ function collectFilesystemSkills(cwd: string, agentDir: string, skillPaths: Skil
|
|
|
435
503
|
const rootSkillFile = path.join(skillPath.path, "SKILL.md");
|
|
436
504
|
if (fs.existsSync(rootSkillFile)) {
|
|
437
505
|
pushEntry(path.basename(skillPath.path), rootSkillFile, skillPath.source);
|
|
506
|
+
continue;
|
|
438
507
|
}
|
|
439
508
|
|
|
509
|
+
markDirectoryVisited(skillPath.path, skillPath.source);
|
|
510
|
+
|
|
440
511
|
let childEntries: fs.Dirent[];
|
|
441
512
|
try {
|
|
442
513
|
childEntries = fs.readdirSync(skillPath.path, { withFileTypes: true });
|
|
@@ -448,10 +519,14 @@ function collectFilesystemSkills(cwd: string, agentDir: string, skillPaths: Skil
|
|
|
448
519
|
if (child.name.startsWith(".")) continue;
|
|
449
520
|
const childPath = path.join(skillPath.path, child.name);
|
|
450
521
|
if (child.isDirectory() || child.isSymbolicLink()) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
522
|
+
if (shouldSkipDirectory(child.name)) continue;
|
|
523
|
+
let childStat: fs.Stats;
|
|
524
|
+
try {
|
|
525
|
+
childStat = fs.statSync(childPath);
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
454
528
|
}
|
|
529
|
+
if (childStat.isDirectory()) walkSkillDirectories(childPath, skillPath.source);
|
|
455
530
|
continue;
|
|
456
531
|
}
|
|
457
532
|
if (child.isFile() && child.name.toLowerCase().endsWith(".md")) {
|
|
@@ -508,10 +583,12 @@ function readSkill(
|
|
|
508
583
|
|
|
509
584
|
const raw = fs.readFileSync(skillPath, "utf-8");
|
|
510
585
|
const content = stripSkillFrontmatter(raw);
|
|
586
|
+
const description = maybeReadSkillDescription(skillPath);
|
|
511
587
|
const skill: ResolvedSkill = {
|
|
512
588
|
name: skillName,
|
|
513
589
|
path: skillPath,
|
|
514
590
|
content,
|
|
591
|
+
description,
|
|
515
592
|
source,
|
|
516
593
|
};
|
|
517
594
|
|
|
@@ -579,9 +656,29 @@ export function resolveSkillsWithFallback(
|
|
|
579
656
|
export function buildSkillInjection(skills: ResolvedSkill[]): string {
|
|
580
657
|
if (skills.length === 0) return "";
|
|
581
658
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
.
|
|
659
|
+
const lines = [
|
|
660
|
+
"The following configured skills are available to this subagent.",
|
|
661
|
+
"Use the read tool to load a skill's file when the task matches its description.",
|
|
662
|
+
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
|
|
663
|
+
"",
|
|
664
|
+
"<available_skills>",
|
|
665
|
+
];
|
|
666
|
+
for (const skill of skills) {
|
|
667
|
+
lines.push(" <skill>");
|
|
668
|
+
lines.push(` <name>${escapeXmlText(skill.name)}</name>`);
|
|
669
|
+
lines.push(` <description>${escapeXmlText(skill.description ?? "")}</description>`);
|
|
670
|
+
lines.push(` <location>${escapeXmlText(skill.path)}</location>`);
|
|
671
|
+
lines.push(" </skill>");
|
|
672
|
+
}
|
|
673
|
+
lines.push("</available_skills>");
|
|
674
|
+
return lines.join("\n");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function escapeXmlText(value: string): string {
|
|
678
|
+
return value
|
|
679
|
+
.replace(/&/g, "&")
|
|
680
|
+
.replace(/</g, "<")
|
|
681
|
+
.replace(/>/g, ">");
|
|
585
682
|
}
|
|
586
683
|
|
|
587
684
|
export function normalizeSkillInput(
|
package/src/extension/doctor.ts
CHANGED
|
@@ -168,6 +168,23 @@ function formatIntercomDiagnostic(diagnostic: IntercomBridgeDiagnostic, context:
|
|
|
168
168
|
return lines;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function formatPermissionSystemSection(): string[] {
|
|
172
|
+
const lines: string[] = [];
|
|
173
|
+
const parentSession = process.env["PI_SUBAGENT_PARENT_SESSION"] ?? "";
|
|
174
|
+
const trimmed = parentSession.trim();
|
|
175
|
+
if (trimmed) {
|
|
176
|
+
lines.push(`- parent session: set (${trimmed})`);
|
|
177
|
+
} else {
|
|
178
|
+
lines.push("- parent session: not set — ask forwarding from subprocess children will not reach a parent UI");
|
|
179
|
+
}
|
|
180
|
+
const isChild = process.env["PI_SUBAGENT_CHILD"] === "1";
|
|
181
|
+
lines.push(`- subagent process: ${isChild ? "yes (PI_SUBAGENT_CHILD=1)" : "no"}`);
|
|
182
|
+
// Whether pi-permission-system is installed and where it stores config is
|
|
183
|
+
// outside pi-subagents' control, so we only report the forwarding signal we
|
|
184
|
+
// own. Run `pi list` to confirm the permission extension is installed.
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
|
|
171
188
|
export function buildDoctorReport(input: DoctorReportInput): string {
|
|
172
189
|
const paths = input.paths ?? DEFAULT_PATHS;
|
|
173
190
|
const deps = { ...DEFAULT_DEPS, ...input.deps };
|
|
@@ -188,6 +205,9 @@ export function buildDoctorReport(input: DoctorReportInput): string {
|
|
|
188
205
|
"Discovery",
|
|
189
206
|
...formatDiscovery(input, deps),
|
|
190
207
|
"",
|
|
208
|
+
"Permission system",
|
|
209
|
+
...formatPermissionSystemSection(),
|
|
210
|
+
"",
|
|
191
211
|
"Intercom bridge",
|
|
192
212
|
...lineFromCheck("intercom bridge", () => formatIntercomDiagnostic(deps.diagnoseIntercomBridge({
|
|
193
213
|
config: input.config.intercomBridge,
|
package/src/extension/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { registerSlashSubagentBridge } from "../slash/slash-bridge.ts";
|
|
|
33
33
|
import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
|
|
34
34
|
import { inspectSubagentStatus } from "../runs/background/run-status.ts";
|
|
35
35
|
import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
|
|
36
|
-
import { SUBAGENT_CHILD_ENV } from "../runs/shared/pi-args.ts";
|
|
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
39
|
import {
|
|
@@ -106,6 +106,30 @@ function isSlashResultRunning(result: { details?: Details }): boolean {
|
|
|
106
106
|
|| false;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Drives the inline running-indicator braille animation for foreground subagent
|
|
110
|
+
// results. Foreground runs receive progress only on child events, so the glyph
|
|
111
|
+
// (derived from progress fields) would freeze between events. While a result is
|
|
112
|
+
// running we tick a frame counter + invalidate() every 80ms so renderSubagentResult
|
|
113
|
+
// can blend the frame into runningGlyph and produce a smooth spinner.
|
|
114
|
+
function subagentResultIsRunning(result: { details?: Details }): boolean {
|
|
115
|
+
return result.details?.progress?.some((entry) => entry.status === "running")
|
|
116
|
+
|| result.details?.results.some((entry) => entry.progress?.status === "running")
|
|
117
|
+
|| false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ensureSubagentResultAnimation(context: { state: Record<string, unknown>; invalidate?: () => void }): void {
|
|
121
|
+
const state = context.state as { subagentResultAnimationTimer?: ReturnType<typeof setInterval>; frame?: number };
|
|
122
|
+
if (state.subagentResultAnimationTimer) return;
|
|
123
|
+
if (typeof context.invalidate !== "function") return;
|
|
124
|
+
if (state.frame === undefined) state.frame = 0;
|
|
125
|
+
state.subagentResultAnimationTimer = setInterval(() => {
|
|
126
|
+
state.frame = ((state.frame ?? 0) + 1) % 10;
|
|
127
|
+
try {
|
|
128
|
+
context.invalidate();
|
|
129
|
+
} catch {}
|
|
130
|
+
}, 80);
|
|
131
|
+
}
|
|
132
|
+
|
|
109
133
|
function isSlashResultError(result: { details?: Details }): boolean {
|
|
110
134
|
return result.details?.results.some((entry) => entry.exitCode !== 0 && entry.progress?.status !== "running") || false;
|
|
111
135
|
}
|
|
@@ -233,6 +257,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
233
257
|
const state: SubagentState = {
|
|
234
258
|
baseCwd: "",
|
|
235
259
|
currentSessionId: null,
|
|
260
|
+
subagentInProgress: false,
|
|
236
261
|
asyncJobs: new Map(),
|
|
237
262
|
foregroundRuns: new Map(),
|
|
238
263
|
foregroundControls: new Map(),
|
|
@@ -392,7 +417,7 @@ EXECUTION (use exactly ONE mode):
|
|
|
392
417
|
• SINGLE: { agent, task? } - one task; omit task for self-contained agents
|
|
393
418
|
• CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
|
|
394
419
|
• PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
|
|
395
|
-
• Optional context: { context: "fresh" | "fork" } (
|
|
420
|
+
• 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" })
|
|
396
421
|
• 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
|
|
397
422
|
|
|
398
423
|
CHAIN TEMPLATE VARIABLES (use in task strings):
|
|
@@ -405,6 +430,7 @@ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", t
|
|
|
405
430
|
MANAGEMENT (use action field, omit agent/task/chain/tasks):
|
|
406
431
|
• { action: "list" } - discover executable agents/chains
|
|
407
432
|
• { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
|
|
433
|
+
• { action: "models", agent?: "name" } - show the runtime-loaded builtin subagent model mapping, optionally filtered to one builtin
|
|
408
434
|
• { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
|
|
409
435
|
• { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
|
|
410
436
|
• { action: "delete", agent: "code-analysis.custom-agent" }
|
|
@@ -455,8 +481,13 @@ DIAGNOSTICS:
|
|
|
455
481
|
},
|
|
456
482
|
|
|
457
483
|
renderResult(result, options, theme, context) {
|
|
458
|
-
|
|
459
|
-
|
|
484
|
+
if (subagentResultIsRunning(result)) {
|
|
485
|
+
ensureSubagentResultAnimation(context);
|
|
486
|
+
} else {
|
|
487
|
+
clearLegacyResultAnimationTimer(context);
|
|
488
|
+
}
|
|
489
|
+
const frame = (context.state as { frame?: number } | undefined)?.frame ?? 0;
|
|
490
|
+
return renderSubagentResult(result, options, theme, frame);
|
|
460
491
|
},
|
|
461
492
|
|
|
462
493
|
};
|
|
@@ -522,6 +553,17 @@ DIAGNOSTICS:
|
|
|
522
553
|
const resetSessionState = (ctx: ExtensionContext) => {
|
|
523
554
|
state.baseCwd = ctx.cwd;
|
|
524
555
|
state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
|
|
556
|
+
// Set PI_SUBAGENT_PARENT_SESSION for permission-system forwarding.
|
|
557
|
+
// Only set in the root session (the interactive UI session), not in
|
|
558
|
+
// child subagent processes — children inherit the parent's value
|
|
559
|
+
// through the process environment at spawn time and must not overwrite
|
|
560
|
+
// it with their own session identity.
|
|
561
|
+
if (!process.env[SUBAGENT_CHILD_ENV]) {
|
|
562
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
563
|
+
if (sessionId) {
|
|
564
|
+
process.env[SUBAGENT_PARENT_SESSION_ENV] = sessionId;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
525
567
|
state.lastUiContext = ctx;
|
|
526
568
|
cleanupSessionArtifacts(ctx);
|
|
527
569
|
clearPendingForegroundControlNotices(state);
|
|
@@ -535,6 +577,7 @@ DIAGNOSTICS:
|
|
|
535
577
|
});
|
|
536
578
|
|
|
537
579
|
pi.on("session_shutdown", () => {
|
|
580
|
+
delete process.env[SUBAGENT_PARENT_SESSION_ENV];
|
|
538
581
|
for (const unsubscribe of eventUnsubscribes) {
|
|
539
582
|
try {
|
|
540
583
|
unsubscribe();
|
package/src/extension/schemas.ts
CHANGED
|
@@ -36,7 +36,7 @@ const SkillOverride = Type.Unsafe({
|
|
|
36
36
|
{ type: "boolean" },
|
|
37
37
|
{ type: "string" },
|
|
38
38
|
],
|
|
39
|
-
description: "Skill name(s) to
|
|
39
|
+
description: "Skill name(s) to make available (comma-separated), array of strings, or boolean (false disables, true uses default)",
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
const OutputOverride = Type.Unsafe({
|
|
@@ -66,72 +66,11 @@ const JsonSchemaObject = Type.Unsafe({
|
|
|
66
66
|
description: "JSON Schema object for strict structured output. Non-object roots are rejected.",
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
const AcceptanceEvidenceKind = Type.String({
|
|
70
|
-
enum: [
|
|
71
|
-
"changed-files",
|
|
72
|
-
"tests-added",
|
|
73
|
-
"commands-run",
|
|
74
|
-
"validation-output",
|
|
75
|
-
"residual-risks",
|
|
76
|
-
"no-staged-files",
|
|
77
|
-
"diff-summary",
|
|
78
|
-
"review-findings",
|
|
79
|
-
"manual-notes",
|
|
80
|
-
],
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const AcceptanceGateSchema = Type.Object({
|
|
84
|
-
id: Type.String(),
|
|
85
|
-
must: Type.String(),
|
|
86
|
-
evidence: Type.Optional(Type.Array(AcceptanceEvidenceKind)),
|
|
87
|
-
severity: Type.Optional(Type.String({ enum: ["required", "recommended"] })),
|
|
88
|
-
}, { additionalProperties: false });
|
|
89
|
-
|
|
90
|
-
const AcceptanceVerifyCommandSchema = Type.Object({
|
|
91
|
-
id: Type.String(),
|
|
92
|
-
command: Type.String(),
|
|
93
|
-
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
94
|
-
cwd: Type.Optional(Type.String()),
|
|
95
|
-
env: Type.Optional(Type.Unsafe({ type: "object", additionalProperties: { type: "string" } })),
|
|
96
|
-
allowFailure: Type.Optional(Type.Boolean()),
|
|
97
|
-
}, { additionalProperties: false });
|
|
98
|
-
|
|
99
|
-
const AcceptanceReviewGateSchema = Type.Object({
|
|
100
|
-
agent: Type.Optional(Type.String()),
|
|
101
|
-
focus: Type.Optional(Type.String()),
|
|
102
|
-
required: Type.Optional(Type.Boolean()),
|
|
103
|
-
}, { additionalProperties: false });
|
|
104
|
-
|
|
105
69
|
const AcceptanceOverride = Type.Unsafe({
|
|
106
70
|
anyOf: [
|
|
107
71
|
{ type: "string", enum: ["auto", "none", "attested", "checked", "verified", "reviewed"] },
|
|
108
|
-
{
|
|
109
|
-
{
|
|
110
|
-
type: "object",
|
|
111
|
-
properties: {
|
|
112
|
-
level: { type: "string", enum: ["auto", "none", "attested", "checked", "verified", "reviewed"] },
|
|
113
|
-
criteria: {
|
|
114
|
-
type: "array",
|
|
115
|
-
items: {
|
|
116
|
-
anyOf: [
|
|
117
|
-
{ type: "string" },
|
|
118
|
-
AcceptanceGateSchema,
|
|
119
|
-
],
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
evidence: { type: "array", items: AcceptanceEvidenceKind },
|
|
123
|
-
verify: { type: "array", items: AcceptanceVerifyCommandSchema },
|
|
124
|
-
review: {
|
|
125
|
-
anyOf: [
|
|
126
|
-
{ const: false },
|
|
127
|
-
AcceptanceReviewGateSchema,
|
|
128
|
-
],
|
|
129
|
-
},
|
|
130
|
-
stopRules: { type: "array", items: { type: "string" } },
|
|
131
|
-
reason: { type: "string" },
|
|
132
|
-
},
|
|
133
|
-
additionalProperties: false,
|
|
134
|
-
},
|
|
72
|
+
{ type: "boolean", enum: [false] },
|
|
73
|
+
{ type: "object", additionalProperties: true },
|
|
135
74
|
],
|
|
136
75
|
description: "Optional acceptance policy. Omitted means auto-inferred; verified requires configured runtime commands.",
|
|
137
76
|
});
|
|
@@ -236,11 +175,6 @@ const ChainItem = Type.Object({
|
|
|
236
175
|
}, {
|
|
237
176
|
description: "Chain step: use {agent, task?, ...} for sequential, {parallel: [...]} for static concurrent execution, or {expand, parallel: {...}, collect} for dynamic fanout.",
|
|
238
177
|
additionalProperties: false,
|
|
239
|
-
allOf: [
|
|
240
|
-
{ if: { required: ["expand"] }, then: { required: ["parallel", "collect"], properties: { parallel: { type: "object" } } } },
|
|
241
|
-
{ if: { required: ["collect"] }, then: { required: ["expand", "parallel"], properties: { parallel: { type: "object" } } } },
|
|
242
|
-
{ not: { required: ["expand"], properties: { parallel: { type: "array", items: {} } } } },
|
|
243
|
-
],
|
|
244
178
|
});
|
|
245
179
|
|
|
246
180
|
const ControlOverrides = Type.Object({
|
|
@@ -287,22 +221,22 @@ const SubagentParamsSchema = Type.Object({
|
|
|
287
221
|
{ type: "object", additionalProperties: true },
|
|
288
222
|
{ type: "string" },
|
|
289
223
|
],
|
|
290
|
-
description: "Agent
|
|
224
|
+
description: "Agent/chain config for create/update. Object or JSON string; presence of steps creates a chain."
|
|
291
225
|
})),
|
|
292
226
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, outputMode?, reads?, progress?}, ...]" })),
|
|
293
227
|
concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
|
|
294
228
|
worktree: Type.Optional(Type.Boolean({
|
|
295
|
-
description: "Create isolated git worktrees for
|
|
296
|
-
"Prevents filesystem conflicts. Requires clean git state. " +
|
|
297
|
-
"Per-worktree diffs included in output."
|
|
229
|
+
description: "Create isolated git worktrees for parallel tasks; requires clean git state."
|
|
298
230
|
})),
|
|
299
|
-
chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential
|
|
231
|
+
chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential steps; each result becomes {previous}. append-step takes one tail step and may use {chain_dir}/{outputs.name}." })),
|
|
300
232
|
context: Type.Optional(Type.String({
|
|
301
233
|
enum: ["fresh", "fork"],
|
|
302
|
-
description: "'fresh' or 'fork' to branch from parent session. If omitted,
|
|
234
|
+
description: "'fresh' or 'fork' to branch from parent session. Explicit context overrides every child in the invocation. If omitted, each requested agent uses its own defaultContext; agents without defaultContext: 'fork' run fresh.",
|
|
303
235
|
})),
|
|
304
|
-
chainDir: Type.Optional(Type.String({ description: "Persistent
|
|
236
|
+
chainDir: Type.Optional(Type.String({ description: "Persistent chain artifact directory; defaults to user-scoped temp storage." })),
|
|
305
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." })),
|
|
306
240
|
agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
|
|
307
241
|
cwd: Type.Optional(Type.String()),
|
|
308
242
|
artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
|
|
@@ -4,10 +4,9 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import type { AgentConfig } from "../agents/agents.ts";
|
|
6
6
|
import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "../shared/types.ts";
|
|
7
|
-
import { getAgentDir } from "../shared/utils.ts";
|
|
7
|
+
import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
|
|
8
8
|
|
|
9
9
|
const PI_INTERCOM_PACKAGE_NAME = "pi-intercom";
|
|
10
|
-
const CONFIG_DIR = ".pi";
|
|
11
10
|
|
|
12
11
|
function defaultAgentDir(): string {
|
|
13
12
|
return getAgentDir();
|
|
@@ -179,7 +178,7 @@ function packageEntryAllowsExtensions(entry: unknown): boolean {
|
|
|
179
178
|
function findNearestProjectConfigDir(cwd: string): string | undefined {
|
|
180
179
|
let current = path.resolve(cwd);
|
|
181
180
|
while (true) {
|
|
182
|
-
const configDir =
|
|
181
|
+
const configDir = getProjectConfigDir(current);
|
|
183
182
|
if (fs.existsSync(path.join(configDir, "settings.json"))) return configDir;
|
|
184
183
|
const parent = path.dirname(current);
|
|
185
184
|
if (parent === current) return undefined;
|
|
@@ -11,7 +11,7 @@ import { createRequire } from "node:module";
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { AgentConfig } from "../../agents/agents.ts";
|
|
13
13
|
import { applyThinkingSuffix } from "../shared/pi-args.ts";
|
|
14
|
-
import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
14
|
+
import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
15
15
|
import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
|
|
16
16
|
import type { RunnerStep } from "../shared/parallel-utils.ts";
|
|
17
17
|
import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
|
|
@@ -95,6 +95,8 @@ interface AsyncExecutionContext {
|
|
|
95
95
|
pi: ExtensionAPI;
|
|
96
96
|
cwd: string;
|
|
97
97
|
currentSessionId: string;
|
|
98
|
+
/** Parent session id used by permission-system ask forwarding. */
|
|
99
|
+
parentSessionId?: string;
|
|
98
100
|
currentModelProvider?: string;
|
|
99
101
|
currentModel?: ParentModel;
|
|
100
102
|
}
|
|
@@ -115,6 +117,7 @@ interface AsyncChainParams {
|
|
|
115
117
|
sessionRoot?: string;
|
|
116
118
|
chainSkills?: string[];
|
|
117
119
|
sessionFilesByFlatIndex?: (string | undefined)[];
|
|
120
|
+
progressDir?: string;
|
|
118
121
|
dynamicFanoutMaxItems?: number;
|
|
119
122
|
maxSubagentDepth: number;
|
|
120
123
|
worktreeSetupHook?: string;
|
|
@@ -170,6 +173,7 @@ export interface AsyncRunnerStepBuildParams {
|
|
|
170
173
|
cwd?: string;
|
|
171
174
|
chainSkills?: string[];
|
|
172
175
|
sessionFilesByFlatIndex?: (string | undefined)[];
|
|
176
|
+
progressDir?: string;
|
|
173
177
|
dynamicFanoutMaxItems?: number;
|
|
174
178
|
maxSubagentDepth: number;
|
|
175
179
|
asyncDir: string;
|
|
@@ -277,6 +281,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
277
281
|
const chainSkills = params.chainSkills ?? [];
|
|
278
282
|
const availableModels = params.availableModels;
|
|
279
283
|
const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
|
|
284
|
+
const progressDir = params.progressDir ?? runnerCwd;
|
|
280
285
|
const graphChain: ChainStep[] = params.attachRoot
|
|
281
286
|
? [{
|
|
282
287
|
agent: params.attachRoot.agent,
|
|
@@ -346,8 +351,9 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
346
351
|
const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
|
|
347
352
|
const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
|
|
348
353
|
if (behavior.progress) progressInstructionCreated = true;
|
|
349
|
-
const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false },
|
|
354
|
+
const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
|
|
350
355
|
const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
|
|
356
|
+
systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
|
|
351
357
|
const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
|
|
352
358
|
if (validationError) throw new AsyncStartValidationError(validationError);
|
|
353
359
|
let taskTemplate = s.task ?? "{previous}";
|
|
@@ -359,6 +365,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
359
365
|
const primaryModel = resolveSubagentModelOverride(requestedModel, ctx.currentModel, availableModels, ctx.currentModelProvider);
|
|
360
366
|
const model = applyThinkingSuffix(primaryModel, a.thinking);
|
|
361
367
|
return {
|
|
368
|
+
parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
|
|
362
369
|
agent: s.agent,
|
|
363
370
|
task,
|
|
364
371
|
phase: s.phase,
|
|
@@ -414,7 +421,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
414
421
|
});
|
|
415
422
|
const progressPrecreated = parallelBehaviors.some((behavior) => behavior.progress);
|
|
416
423
|
if (progressPrecreated) {
|
|
417
|
-
if (!s.worktree) writeInitialProgressFile(
|
|
424
|
+
if (!s.worktree || params.progressDir) writeInitialProgressFile(progressDir);
|
|
418
425
|
progressInstructionCreated = true;
|
|
419
426
|
}
|
|
420
427
|
return {
|
|
@@ -439,7 +446,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
439
446
|
const behavior = suppressProgressForReadOnlyTask(resolveStepBehavior(agent, buildStepOverrides(s.parallel), chainSkills), s.parallel.task, originalTask);
|
|
440
447
|
const progressPrecreated = behavior.progress;
|
|
441
448
|
if (progressPrecreated) {
|
|
442
|
-
writeInitialProgressFile(
|
|
449
|
+
writeInitialProgressFile(progressDir);
|
|
443
450
|
progressInstructionCreated = true;
|
|
444
451
|
}
|
|
445
452
|
return {
|
|
@@ -539,6 +546,7 @@ export function executeAsyncChain(
|
|
|
539
546
|
cwd,
|
|
540
547
|
chainSkills: params.chainSkills,
|
|
541
548
|
sessionFilesByFlatIndex,
|
|
549
|
+
progressDir: params.progressDir ?? (resultMode === "parallel" ? path.join(asyncDir, "progress") : undefined),
|
|
542
550
|
dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
|
|
543
551
|
maxSubagentDepth,
|
|
544
552
|
asyncDir,
|
|
@@ -765,6 +773,7 @@ export function executeAsyncSingle(
|
|
|
765
773
|
|
|
766
774
|
const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
|
|
767
775
|
const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd);
|
|
776
|
+
systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
|
|
768
777
|
const outputMode = params.outputMode ?? "inline";
|
|
769
778
|
const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
|
|
770
779
|
if (validationError) return formatAsyncStartError("single", validationError);
|
|
@@ -783,6 +792,7 @@ export function executeAsyncSingle(
|
|
|
783
792
|
id,
|
|
784
793
|
steps: [
|
|
785
794
|
{
|
|
795
|
+
parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
|
|
786
796
|
agent,
|
|
787
797
|
task: taskWithOutputInstruction,
|
|
788
798
|
cwd: runnerCwd,
|