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.
Files changed (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +116 -17
  3. package/agents/context-builder.md +3 -3
  4. package/agents/planner.md +1 -1
  5. package/agents/researcher.md +1 -1
  6. package/agents/scout.md +1 -1
  7. package/package.json +7 -7
  8. package/skills/pi-subagents/SKILL.md +5 -0
  9. package/src/agents/agent-management.ts +170 -6
  10. package/src/agents/agent-serializer.ts +31 -13
  11. package/src/agents/agents.ts +207 -23
  12. package/src/agents/frontmatter.ts +66 -2
  13. package/src/agents/skills.ts +117 -20
  14. package/src/extension/doctor.ts +20 -0
  15. package/src/extension/fanout-child.ts +1 -0
  16. package/src/extension/index.ts +47 -4
  17. package/src/extension/schemas.ts +10 -76
  18. package/src/intercom/intercom-bridge.ts +2 -3
  19. package/src/runs/background/async-execution.ts +14 -4
  20. package/src/runs/background/async-job-tracker.ts +56 -11
  21. package/src/runs/background/result-watcher.ts +11 -2
  22. package/src/runs/background/stale-run-reconciler.ts +9 -4
  23. package/src/runs/background/subagent-runner.ts +79 -3
  24. package/src/runs/foreground/chain-execution.ts +26 -2
  25. package/src/runs/foreground/execution.ts +113 -8
  26. package/src/runs/foreground/subagent-executor.ts +325 -77
  27. package/src/runs/shared/acceptance.ts +285 -34
  28. package/src/runs/shared/completion-guard.ts +1 -1
  29. package/src/runs/shared/dynamic-fanout.ts +4 -2
  30. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
  31. package/src/runs/shared/parallel-utils.ts +6 -1
  32. package/src/runs/shared/pi-args.ts +9 -1
  33. package/src/runs/shared/single-output.ts +15 -1
  34. package/src/shared/settings.ts +1 -0
  35. package/src/shared/types.ts +8 -2
  36. package/src/shared/utils.ts +19 -1
  37. package/src/slash/prompt-template-bridge.ts +26 -3
  38. package/src/slash/slash-commands.ts +33 -3
  39. package/src/tui/render.ts +265 -13
@@ -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(cwd, CONFIG_DIR, "npm", "node_modules"), source: "project-package" },
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(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR), source: "project-settings" as const },
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(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR), source: "project-package" as const },
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(cwd, CONFIG_DIR, "skills"), source: "project" },
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
- if (!deduped.has(resolvedPath)) {
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, CONFIG_DIR);
344
- const projectSkillsRoot = path.resolve(cwd, CONFIG_DIR, "skills");
345
- const projectPackagesRoot = path.resolve(cwd, CONFIG_DIR, "npm", "node_modules");
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 Set<string>();
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
- seen.add(resolvedFile);
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: inferSkillSource(resolvedFile, cwd, agentDir, sourceHint),
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
- const nestedSkillPath = path.join(childPath, "SKILL.md");
452
- if (fs.existsSync(nestedSkillPath)) {
453
- pushEntry(child.name, nestedSkillPath, skillPath.source);
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
- return skills
583
- .map((s) => `<skill name="${s.name}">\n${s.content}\n</skill>`)
584
- .join("\n\n");
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, "&amp;")
680
+ .replace(/</g, "&lt;")
681
+ .replace(/>/g, "&gt;");
585
682
  }
586
683
 
587
684
  export function normalizeSkillInput(
@@ -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,
@@ -30,6 +30,7 @@ function createChildSafeState(): SubagentState {
30
30
  return {
31
31
  baseCwd: "",
32
32
  currentSessionId: null,
33
+ subagentInProgress: false,
33
34
  asyncJobs: new Map(),
34
35
  foregroundRuns: new Map(),
35
36
  foregroundControls: new Map(),
@@ -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" } (default: if any requested agent has defaultContext: "fork", the whole invocation uses fork; otherwise "fresh"; inspect agent defaults via { action: "list" })
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
- clearLegacyResultAnimationTimer(context);
459
- return renderSubagentResult(result, options, theme);
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();
@@ -36,7 +36,7 @@ const SkillOverride = Type.Unsafe({
36
36
  { type: "boolean" },
37
37
  { type: "string" },
38
38
  ],
39
- description: "Skill name(s) to inject (comma-separated), array of strings, or boolean (false disables, true uses default)",
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
- { const: false },
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 or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), subagentOnlyExtensions (comma-separated child-only extension paths), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
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 each parallel task. " +
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 pipeline where each step's response becomes {previous} for the next. With action='append-step', provide exactly one step to append to an active async chain; it can use {previous}, {chain_dir}, and existing {outputs.name} references." })),
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, any requested agent with defaultContext: 'fork' makes the whole invocation forked; otherwise the default is 'fresh'.",
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 directory for chain artifacts. Default: a user-scoped temp directory under <tmpdir>/ (auto-cleaned after 24h)" })),
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 = path.join(current, CONFIG_DIR);
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 }, runnerCwd, isFirstProgressAgent);
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(runnerCwd);
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(runnerCwd);
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,