@signalridge/pi-subagents 1.11.1 → 1.13.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.
@@ -17,6 +17,7 @@ import {
17
17
  DefaultResourceLoader,
18
18
  type ExtensionAPI,
19
19
  getAgentDir,
20
+ type ResolvedResource,
20
21
  SessionManager,
21
22
  SettingsManager,
22
23
  } from "@earendil-works/pi-coding-agent";
@@ -28,6 +29,7 @@ import { extractText } from "./context.js";
28
29
  import { assertNoRawInheritance, assertSafeChildSession } from "./context-boundary.js";
29
30
  import { DEFAULT_AGENTS } from "./default-agents.js";
30
31
  import { detectEnv } from "./env.js";
32
+ import { extensionResourceIdentity, resolveExtensionResourceNames } from "./extension-resource-names.js";
31
33
  import { formatGateVerdict, type GateExec, runGate, workspaceFingerprint } from "./gate.js";
32
34
  import {
33
35
  INTERNAL_AGENT_CONFIG_OVERRIDE,
@@ -40,6 +42,7 @@ import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resol
40
42
  import { parentModelSessionOptions } from "./model-runtime-bridge.js";
41
43
  import { checkModelScope } from "./model-scope.js";
42
44
  import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
45
+ import { ANSWER_SUBAGENT_QUESTION, type ParentQuestionHandler } from "./parent-questions.js";
43
46
  import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
44
47
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
45
48
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
@@ -57,6 +60,7 @@ export const SUBAGENT_TOOL_NAMES = {
57
60
  AGENT: "Agent",
58
61
  GET_RESULT: "get_subagent_result",
59
62
  STEER: "steer_subagent",
63
+ ANSWER_QUESTION: ANSWER_SUBAGENT_QUESTION,
60
64
  } as const;
61
65
 
62
66
  /** Names of tools registered by this extension that subagents must NOT inherit. */
@@ -241,8 +245,9 @@ function extensionToolNamesInScope(session: AgentSession, ctx: {
241
245
  extNames: Set<string>;
242
246
  narrowing: Map<string, Set<string>>;
243
247
  nestedToolNames: Set<string>;
248
+ resolvedNames: Map<string, string[]>;
244
249
  }): () => Set<string> {
245
- const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames } = ctx;
250
+ const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames } = ctx;
246
251
  const builtins = new Set(toolNames);
247
252
  return () => {
248
253
  const keep = new Set<string>();
@@ -258,7 +263,7 @@ function extensionToolNamesInScope(session: AgentSession, ctx: {
258
263
  const path = tool.sourceInfo.path;
259
264
  const extension = extensionsByPath.get(path);
260
265
  if (extension) {
261
- const canons = extensionCanonicalNames(extension.path);
266
+ const canons = resolvedNames.get(extension.path) ?? extensionCanonicalNames(extension.path);
262
267
  if (optInActive && !canons.some((c) => extNames.has(c))) continue;
263
268
  // First alias that carries a narrowing set — a user won't narrow one
264
269
  // extension under two different names, so first-match is correct.
@@ -684,7 +689,9 @@ export interface RunOptions {
684
689
  * Defaults to on wherever there is a UI to ask through; `false` withholds the
685
690
  * tool entirely rather than injecting one that always refuses.
686
691
  */
687
- supervisorQuestions?: boolean;
692
+ supervisorQuestions?: boolean | "parent";
693
+ /** Manager-issued root clarification channel; never supplied by child tool args. */
694
+ parentQuestion?: ParentQuestionHandler;
688
695
  /** Runtime bridge for opt-in child-safe nested delegation. */
689
696
  nestedRuntime?: {
690
697
  manager: NestedAgentManager;
@@ -901,6 +908,9 @@ export async function runAgent(
901
908
  // an already-cancelled caller. This matters for tool-call signals: the
902
909
  // caller may be cancelled before this async function gets its first turn.
903
910
  throwIfAborted(options.signal);
911
+ if (options.supervisorQuestions === "parent" && !options.parentQuestion) {
912
+ throw new Error('Parent clarification requires a manager-issued background channel. Use Agent with run_in_background: true and supervisor_questions: "parent".');
913
+ }
904
914
  const parentProjectTrust = captureProjectTrust(ctx);
905
915
  // Configuration provenance is established synchronously, before definition
906
916
  // admission, model setup, callbacks, or any await. Execution cwd is not trust.
@@ -1085,12 +1095,12 @@ export async function runAgent(
1085
1095
  const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
1086
1096
 
1087
1097
  // Extension loading:
1088
- // - true → all default-discovered extensions
1098
+ // - true → all child-safe default-discovered extensions
1089
1099
  // - false → none (noExtensions)
1090
1100
  // - string[] → allowlist. Bare names keep matching default-discovered
1091
1101
  // extensions; path entries load explicitly; "*" keeps all defaults.
1092
- // On Pi with replaceable built-ins, restrictive policies resolve paths
1093
- // before loading, so excluded factories cannot replace a selected built-in.
1102
+ // Resolve enabled resources before loading: excluded factories cannot
1103
+ // replace selected built-ins or schedule unmanaged asynchronous wakes.
1094
1104
  //
1095
1105
  // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
1096
1106
  // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
@@ -1111,12 +1121,11 @@ export async function runAgent(
1111
1121
  : undefined;
1112
1122
  const keepNames = extensionsSpec?.names ?? new Set<string>();
1113
1123
  // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
1114
- // Plain canonical names only (case-insensitive). Without preselection,
1115
- // filtering is post-factory, not a sandbox for extensions that already loaded.
1124
+ // Plain canonical names only (case-insensitive). Preselection prevents
1125
+ // factory execution, but resolving packages is not an untrusted-code sandbox.
1116
1126
  const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
1117
1127
  const hasExcludes = excludeNames.size > 0;
1118
- // The override filters without preselection, validates preselection when used,
1119
- // and ALWAYS puts our tool policy first. Pi passes one mutable
1128
+ // The override validates preselection and ALWAYS puts our tool policy first. Pi passes one mutable
1120
1129
  // tool_call event through handlers in extension order; if an earlier extension
1121
1130
  // changes event.toolName, a later gate would inspect a different name from the
1122
1131
  // one Pi actually executes. Ordering before untrusted handlers is essential.
@@ -1130,6 +1139,7 @@ export async function runAgent(
1130
1139
  let discoveredNames: Set<string> | undefined;
1131
1140
  let prefilteredExtensionPaths: Set<string> | undefined;
1132
1141
  const resolvedExplicitNames = new Set<string>();
1142
+ let resolvedNames = new Map<string, string[]>();
1133
1143
  const interactiveAskNames = new Set(ctx.hasUI ? (agentConfig?.askTools ?? []).map((name) => name.toLowerCase()) : []);
1134
1144
  const approvalTimer: ApprovalTimer = {
1135
1145
  isInteractiveAsk: (name) => interactiveAskNames.has(name.toLowerCase()),
@@ -1224,11 +1234,12 @@ export async function runAgent(
1224
1234
  // discovered extension can register /mcp, replace builtin:mcp, and only then
1225
1235
  // get filtered out by our allowlist. Resolve configured resources through the
1226
1236
  // public package API first and feed only the selected paths into a loader with
1227
- // discovery off. Without the built-in factory capability, retain the
1228
- // post-load filtering fallback rather than assuming replacement behavior.
1237
+ // discovery off. Always preselect, even under default inheritance: a
1238
+ // pi-background factory can schedule model wakes beyond session.prompt(),
1239
+ // after the manager has completed the record and cleaned its worktree.
1229
1240
  const builtinNames = extensionFactories.filter((factory) => typeof factory !== "function" && factory.builtin)
1230
1241
  .map((factory) => factory.name);
1231
- const prefilter = !noExtensions && (!loadAll || hasExcludes || !projectTrusted) && builtinNames.length > 0;
1242
+ const prefilter = !noExtensions;
1232
1243
  if (prefilter) {
1233
1244
  await settingsManager.reload();
1234
1245
  throwIfAborted(options.signal);
@@ -1238,31 +1249,54 @@ export async function runAgent(
1238
1249
  // state or replace its source-resolution rules with path heuristics.
1239
1250
  const configured = await packages.resolve();
1240
1251
  throwIfAborted(options.signal);
1241
- const explicit = await packages.resolveExtensionSources(extensionsSpec?.paths ?? [], { temporary: true });
1242
- throwIfAborted(options.signal);
1252
+ // Validate each source before cross-source dedup discards valid alias metadata.
1253
+ const explicitSources = new Map<string, ResolvedResource[]>();
1243
1254
  for (const source of extensionsSpec?.paths ?? []) {
1244
- if (!existsSync(source) || !explicit.extensions.some((resource) => resource.enabled && resource.metadata.source === source)) {
1255
+ const resolved = await packages.resolveExtensionSources([source], { temporary: true });
1256
+ throwIfAborted(options.signal);
1257
+ const enabled = resolved.extensions.filter(resource => resource.enabled);
1258
+ if (!existsSync(source) || enabled.length === 0) {
1245
1259
  throw new Error(`Extension source "${source}" requested by agent "${type}" was not found or has no enabled extensions.`);
1246
1260
  }
1261
+ explicitSources.set(source, enabled);
1247
1262
  }
1263
+ const explicitResources = [...explicitSources.values()].flat();
1248
1264
  settingsBeforeLoad = JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()]);
1249
- const enabledConfigured = configured.extensions.filter((resource) => resource.enabled && !deniedProjectResource(resource.path)).map((resource) => resource.path);
1250
- const enabledExplicit = explicit.extensions.filter((resource) => resource.enabled);
1265
+ const enabledConfigured = configured.extensions.filter((resource) => resource.enabled && !deniedProjectResource(resource.path));
1266
+ const enabledExplicit = explicitResources;
1251
1267
  for (const resource of enabledExplicit) {
1252
1268
  if (deniedProjectResource(resource.path)) {
1253
1269
  throw new Error(`Project extension "${resource.path}" requested by agent "${type}" is not trusted.`);
1254
1270
  }
1255
1271
  }
1256
- discoveredNames = new Set([...enabledConfigured, ...enabledExplicit.map((resource) => resource.path)].flatMap(extensionCanonicalNames));
1272
+ const resolved = await resolveExtensionResourceNames(
1273
+ [...configured.extensions.filter(resource => !deniedProjectResource(resource.path)), ...enabledExplicit],
1274
+ packages, extensionCanonicalNames,
1275
+ );
1276
+ throwIfAborted(options.signal);
1277
+ resolvedNames = resolved.names;
1278
+ discoveredNames = new Set([...enabledConfigured, ...enabledExplicit]
1279
+ .flatMap(resource => resolvedNames.get(resource.path) ?? []));
1280
+ let warnedBackground = false;
1281
+ const admitChildExtension = (resource: ResolvedResource, intentional: boolean): boolean => {
1282
+ if (!resolvedNames.get(resource.path)?.includes("pi-background")) return true;
1283
+ const reason = `pi-background cannot load in agent "${type}": managed child sessions do not support asynchronous model wakes after completion/worktree cleanup`;
1284
+ if (intentional) throw new Error(`${reason} (requested extension "${resource.path}").`);
1285
+ if (!warnedBackground) {
1286
+ warnedBackground = true;
1287
+ options.onToolActivity?.({ type: "end", toolName: `extension-error:${reason}; excluded from inherited resources` });
1288
+ }
1289
+ return false;
1290
+ };
1257
1291
  const selectedExplicit = enabledExplicit.filter((resource) =>
1258
- !extensionCanonicalNames(resource.path).some((name) => excludeNames.has(name)));
1259
- const explicitPaths = new Set(selectedExplicit.map((resource) => resource.path));
1292
+ !resolvedNames.get(resource.path)?.some((name) => excludeNames.has(name)) && admitChildExtension(resource, true));
1293
+ const explicitPaths = new Set(selectedExplicit.map((resource) => extensionResourceIdentity(resource.path)));
1260
1294
  // For priority, use the package's declared name rather than a generic entry
1261
1295
  // alias like "src"; unrelated packages often have src/index.ts too.
1262
1296
  const explicitNames = new Set(selectedExplicit.map((resource) =>
1263
- extensionPackageName(resource.path) ?? extensionCanonicalName(resource.path)));
1264
- for (const source of extensionsSpec?.paths ?? []) {
1265
- if (selectedExplicit.some((resource) => resource.metadata.source === source)) {
1297
+ resolved.packageName(resource) ?? extensionCanonicalName(resource.path)));
1298
+ for (const [source, resources] of explicitSources) {
1299
+ if (resources.some(resource => explicitPaths.has(extensionResourceIdentity(resource.path)))) {
1266
1300
  resolvedExplicitNames.add(extensionCanonicalName(source));
1267
1301
  }
1268
1302
  }
@@ -1270,15 +1304,23 @@ export async function runAgent(
1270
1304
  // The loader normally loads temporary paths before discovered paths. Keep
1271
1305
  // that priority (including for directory/package paths with many entries).
1272
1306
  ...selectedExplicit.map((resource) => resource.path),
1273
- ...enabledConfigured.filter((path) => {
1274
- const names = extensionCanonicalNames(path);
1307
+ ...enabledConfigured.filter((resource) => {
1308
+ const names = resolvedNames.get(resource.path) ?? extensionCanonicalNames(resource.path);
1275
1309
  if (names.some((name) => excludeNames.has(name))) return false;
1276
- if (explicitPaths.has(path) || explicitNames.has(extensionPackageName(path) ?? extensionCanonicalName(path))) return false;
1277
- return loadAll || names.some((name) => bareNames.has(name));
1278
- }),
1310
+ if (explicitPaths.has(extensionResourceIdentity(resource.path)) ||
1311
+ explicitNames.has(resolved.packageName(resource) ?? extensionCanonicalName(resource.path))) return false;
1312
+ const intentional = names.some((name) => bareNames.has(name));
1313
+ return (loadAll || intentional) && admitChildExtension(resource, intentional);
1314
+ }).map((resource) => resource.path),
1279
1315
  ];
1280
- prefilteredExtensionPaths = new Set(selectedPaths);
1281
- additionalExtensionPaths = [...prefilteredExtensionPaths];
1316
+ const seen = new Set<string>();
1317
+ additionalExtensionPaths = selectedPaths.filter(path => {
1318
+ const identity = extensionResourceIdentity(path);
1319
+ if (seen.has(identity)) return false;
1320
+ seen.add(identity);
1321
+ return true;
1322
+ });
1323
+ prefilteredExtensionPaths = new Set(additionalExtensionPaths);
1282
1324
  }
1283
1325
 
1284
1326
  const loader = new DefaultResourceLoader({
@@ -1308,7 +1350,12 @@ export async function runAgent(
1308
1350
  throw new Error(`Pi extension settings changed while loading agent "${type}"; retry with the current settings.`);
1309
1351
  }
1310
1352
  const loadedPaths = new Set(loader.getExtensions().extensions.map((extension) => extension.path));
1311
- for (const path of prefilteredExtensionPaths) {
1353
+ // Preserve ordinary unrestricted inheritance: Pi may drop a faulty factory
1354
+ // or let a replaceable built-in yield to another discovered extension. The
1355
+ // original restrictive preselection still requires every selected path.
1356
+ const validateSelected = !loadAll || hasExcludes || !projectTrusted ||
1357
+ bareNames.size > 0 || (extensionsSpec?.paths.length ?? 0) > 0;
1358
+ for (const path of validateSelected ? prefilteredExtensionPaths : []) {
1312
1359
  if (!loadedPaths.has(path)) {
1313
1360
  const diagnostic = loader.getExtensions().errors.find((error) => error.path === path);
1314
1361
  throw new Error(`Selected extension "${path}" was not loaded for agent "${type}"${diagnostic ? `: ${diagnostic.error}` : " (possibly replaced by another extension)"}.`);
@@ -1369,7 +1416,7 @@ export async function runAgent(
1369
1416
  }
1370
1417
  if (keepNames.size > 0 || extNames.size > 0) {
1371
1418
  const survivingNames = new Set(
1372
- loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
1419
+ loader.getExtensions().extensions.flatMap((e) => resolvedNames.get(e.path) ?? extensionCanonicalNames(e.path)),
1373
1420
  );
1374
1421
  for (const name of keepNames) {
1375
1422
  // A directory/package source can resolve to entries with different
@@ -1458,8 +1505,9 @@ export async function runAgent(
1458
1505
  // only a human to answer, which `hasUI` decides. Isolation still suppresses
1459
1506
  // it: an `isolated: true` agent is defined as built-in tools only.
1460
1507
  const supervisorTools =
1461
- ctx.hasUI && !isolated && options.supervisorQuestions !== false
1508
+ (ctx.hasUI || options.supervisorQuestions === "parent") && !isolated && options.supervisorQuestions !== false
1462
1509
  ? createSupervisorTool({
1510
+ ...(options.supervisorQuestions === "parent" ? { parentQuestion: options.parentQuestion } : {}),
1463
1511
  // Use the canonical agent type, not display_name: the question must
1464
1512
  // identify the actual subagent that requested the decision.
1465
1513
  agentLabel: agentConfig?.name ?? type,
@@ -1600,7 +1648,7 @@ export async function runAgent(
1600
1648
  throwIfAborted(options.signal);
1601
1649
 
1602
1650
  const inScope = noExtensions ? undefined : extensionToolNamesInScope(session, {
1603
- loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames,
1651
+ loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames,
1604
1652
  });
1605
1653
  checkToolCall = async (name, input, deadline) => {
1606
1654
  const run = async (): Promise<ToolCallEventResult | undefined> => {
@@ -0,0 +1,105 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import type { DefaultPackageManager, ResolvedResource } from "@earendil-works/pi-coding-agent";
5
+
6
+ /** Built-in identifiers and missing resources retain their original identity. */
7
+ export function extensionResourceIdentity(path: string): string {
8
+ if (path.startsWith("builtin:") || path.startsWith("<")) return path;
9
+ try { return realpathSync(path); } catch { return resolve(path); }
10
+ }
11
+
12
+ function packageShortName(root: string): string | undefined {
13
+ try {
14
+ const name: unknown = JSON.parse(readFileSync(join(root, "package.json"), "utf-8")).name;
15
+ return typeof name === "string" ? name.split("/").pop()?.toLowerCase() : undefined;
16
+ } catch { return undefined; }
17
+ }
18
+
19
+ function isDirectory(path: string): boolean {
20
+ try { return statSync(path).isDirectory(); } catch { return false; }
21
+ }
22
+
23
+ /** Find candidate roots only; the public resolver decides which own the entry. */
24
+ function candidatePackageRoots(entry: string): string[] {
25
+ if (entry.startsWith("builtin:") || entry.startsWith("<")) return [];
26
+ const roots: string[] = [];
27
+ let dir = isDirectory(entry) ? entry : dirname(entry);
28
+ for (;;) {
29
+ if (basename(dir) === "node_modules") break;
30
+ if (existsSync(join(dir, "package.json"))) roots.push(dir);
31
+ const parent = dirname(dir);
32
+ if (parent === dir) break;
33
+ dir = parent;
34
+ }
35
+ return roots;
36
+ }
37
+
38
+ /** Only Node's supported module entry, never factory evaluation or sibling scanning. */
39
+ function resourceIdentities(path: string): string[] {
40
+ const identity = extensionResourceIdentity(path);
41
+ if (!isDirectory(identity)) return [identity];
42
+ try {
43
+ return [identity, extensionResourceIdentity(createRequire(join(identity, "package.json")).resolve(identity))];
44
+ } catch { return [identity]; }
45
+ }
46
+
47
+ /** Union aliases for admission/exclusion; keep declared ownership separate for priority. */
48
+ export async function resolveExtensionResourceNames(
49
+ resources: ResolvedResource[],
50
+ packages: DefaultPackageManager,
51
+ pathNames: (path: string) => string[],
52
+ ): Promise<{ names: Map<string, string[]>; packageName: (resource: ResolvedResource) => string | undefined }> {
53
+ const entries = new Map<string, { paths: Set<string>; names: Set<string>; owners: Set<string> }>();
54
+ for (const resource of resources) {
55
+ const identity = extensionResourceIdentity(resource.path);
56
+ let entry = entries.get(identity);
57
+ if (!entry) {
58
+ entry = { paths: new Set([identity]), names: new Set(), owners: new Set() };
59
+ entries.set(identity, entry);
60
+ }
61
+ entry.paths.add(resource.path);
62
+ if (resource.metadata.packageRoot) {
63
+ const name = packageShortName(resource.metadata.packageRoot);
64
+ if (name) entry.owners.add(name);
65
+ }
66
+ }
67
+ const probes = new Map<string, Set<string>>();
68
+ const canonicalOwners = new Map<string, string>();
69
+ for (const [identity, entry] of entries) {
70
+ for (const path of entry.paths) {
71
+ for (const name of pathNames(path)) entry.names.add(name);
72
+ }
73
+ // A lexical file alias must not conceal the canonical declaring package.
74
+ // A nearer non-owning manifest must not stop ancestor discovery either.
75
+ for (const root of candidatePackageRoots(identity)) {
76
+ let declared = probes.get(root);
77
+ if (!declared) {
78
+ const resolved = await packages.resolveExtensionSources([root], { temporary: true });
79
+ // Enabled is a selection preference, not evidence of file ownership.
80
+ declared = new Set(resolved.extensions.flatMap(resource => resourceIdentities(resource.path)));
81
+ probes.set(root, declared);
82
+ }
83
+ if (declared.has(identity)) {
84
+ const name = packageShortName(root);
85
+ if (name) {
86
+ entry.owners.add(name);
87
+ if (!canonicalOwners.has(identity)) canonicalOwners.set(identity, name);
88
+ }
89
+ }
90
+ }
91
+ for (const name of entry.owners) entry.names.add(name);
92
+ }
93
+ const names = new Map<string, string[]>();
94
+ for (const entry of entries.values()) {
95
+ for (const path of entry.paths) names.set(path, [...entry.names]);
96
+ }
97
+ return {
98
+ names,
99
+ // Identical paths can have different declaring metadata. Never key this by
100
+ // path alone. Direct entries use nearest canonical declaring ownership.
101
+ packageName: resource => resource.metadata.packageRoot
102
+ ? packageShortName(resource.metadata.packageRoot)
103
+ : canonicalOwners.get(extensionResourceIdentity(resource.path)),
104
+ };
105
+ }