@signalridge/pi-subagents 1.11.0 → 1.12.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,8 @@ 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";
46
+ import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
43
47
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
44
48
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
45
49
  import { preloadSkills } from "./skill-loader.js";
@@ -56,6 +60,7 @@ export const SUBAGENT_TOOL_NAMES = {
56
60
  AGENT: "Agent",
57
61
  GET_RESULT: "get_subagent_result",
58
62
  STEER: "steer_subagent",
63
+ ANSWER_QUESTION: ANSWER_SUBAGENT_QUESTION,
59
64
  } as const;
60
65
 
61
66
  /** Names of tools registered by this extension that subagents must NOT inherit. */
@@ -240,8 +245,9 @@ function extensionToolNamesInScope(session: AgentSession, ctx: {
240
245
  extNames: Set<string>;
241
246
  narrowing: Map<string, Set<string>>;
242
247
  nestedToolNames: Set<string>;
248
+ resolvedNames: Map<string, string[]>;
243
249
  }): () => Set<string> {
244
- const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames } = ctx;
250
+ const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames } = ctx;
245
251
  const builtins = new Set(toolNames);
246
252
  return () => {
247
253
  const keep = new Set<string>();
@@ -257,7 +263,7 @@ function extensionToolNamesInScope(session: AgentSession, ctx: {
257
263
  const path = tool.sourceInfo.path;
258
264
  const extension = extensionsByPath.get(path);
259
265
  if (extension) {
260
- const canons = extensionCanonicalNames(extension.path);
266
+ const canons = resolvedNames.get(extension.path) ?? extensionCanonicalNames(extension.path);
261
267
  if (optInActive && !canons.some((c) => extNames.has(c))) continue;
262
268
  // First alias that carries a narrowing set — a user won't narrow one
263
269
  // extension under two different names, so first-match is correct.
@@ -331,8 +337,10 @@ export function installExtensionToolScope(session: AgentSession, inScope: () =>
331
337
  * the agent's whole result down with it.
332
338
  */
333
339
  async function runConfiguredGate(command: string, cwd: string, pi: ExtensionAPI): Promise<string> {
334
- const exec: GateExec = (file, args, execOptions) =>
335
- pi.exec(file, args, execOptions as Parameters<ExtensionAPI["exec"]>[2]);
340
+ const exec: GateExec = async (file, args, execOptions) => {
341
+ const result = await pi.exec(file, args, execOptions);
342
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: result.killed ? 1 : result.code };
343
+ };
336
344
  try {
337
345
  const fingerprint = await workspaceFingerprint(cwd, exec);
338
346
  const verdict = await runGate({ command, cwd, exec, ...(fingerprint ? { fingerprint } : {}) });
@@ -622,9 +630,9 @@ export interface RunOptions {
622
630
  *
623
631
  * WARNING for future callers: if you pass `cwd` pointing at a directory the
624
632
  * user didn't open, you almost certainly must pass `configCwd` too —
625
- * omitting it makes the target's `.pi` extensions execute in this process.
626
- * (Worktree isolation is the one intentional exception: its copy IS the
627
- * parent's repo, so config resolving inside it is correct.)
633
+ * a different configuration root does not inherit the parent's trust.
634
+ * Worktree execution with configCwd pointing at the parent retains trust;
635
+ * configuration discovered inside a new worktree is not implicitly trusted.
628
636
  */
629
637
  configCwd?: string;
630
638
  /** Called on tool start/end with activity info. */
@@ -681,7 +689,9 @@ export interface RunOptions {
681
689
  * Defaults to on wherever there is a UI to ask through; `false` withholds the
682
690
  * tool entirely rather than injecting one that always refuses.
683
691
  */
684
- supervisorQuestions?: boolean;
692
+ supervisorQuestions?: boolean | "parent";
693
+ /** Manager-issued root clarification channel; never supplied by child tool args. */
694
+ parentQuestion?: ParentQuestionHandler;
685
695
  /** Runtime bridge for opt-in child-safe nested delegation. */
686
696
  nestedRuntime?: {
687
697
  manager: NestedAgentManager;
@@ -898,8 +908,36 @@ export async function runAgent(
898
908
  // an already-cancelled caller. This matters for tool-call signals: the
899
909
  // caller may be cancelled before this async function gets its first turn.
900
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
+ }
914
+ const parentProjectTrust = captureProjectTrust(ctx);
915
+ // Configuration provenance is established synchronously, before definition
916
+ // admission, model setup, callbacks, or any await. Execution cwd is not trust.
917
+ const effectiveCwd = options.cwd ?? ctx.cwd;
918
+ const configCwd = options.configCwd ?? effectiveCwd;
919
+ const projectTrust = childProjectTrust(parentProjectTrust, configCwd);
920
+ const projectTrusted = projectTrust.trusted;
921
+ const deniedProjectResource = (path: string): boolean =>
922
+ projectTrust.deniedRoots.some(root => isProjectResource(path, root));
923
+ const agentDir = getAgentDir();
924
+ // Native global configuration is resolved before resource-path filtering.
925
+ // Reject aliases into denied roots before the SDK parses their declarations;
926
+ // do not copy or reinterpret the host's settings/MCP schemas here.
927
+ for (const filename of ["settings.json", "mcp.json"]) {
928
+ if (deniedProjectResource(join(agentDir, filename))) {
929
+ throw new Error(`Project trust denied for global configuration source "${filename}".`);
930
+ }
931
+ }
901
932
 
902
933
  const loadedConfig = getConfig(type);
934
+ // The request's provenance matters, not just the requested extension path:
935
+ // a denied definition can request packages anywhere (including npm caches).
936
+ // getConfig carries the actual fallback definition's provenance as well.
937
+ if ((!parentProjectTrust.trusted && loadedConfig.source === "project") ||
938
+ (loadedConfig.sourcePath && deniedProjectResource(loadedConfig.sourcePath))) {
939
+ throw new Error(`Project trust denied for agent definition "${type}".`);
940
+ }
903
941
  const internalOverride = options[INTERNAL_AGENT_CONFIG_OVERRIDE];
904
942
  const loadedAgentConfig = getAgentConfig(type);
905
943
  const config = internalOverride
@@ -982,12 +1020,6 @@ export async function runAgent(
982
1020
  options.onAgentTierResolved?.(agentTierSnapshot, tierModelLabel);
983
1021
  }
984
1022
 
985
- // Resolve working directory: worktree override > parent cwd
986
- const effectiveCwd = options.cwd ?? ctx.cwd;
987
- // Filesystem work happens in effectiveCwd; config discovery in configCwd.
988
- // They differ only for SpawnOptions.cwd spawns (config stays with the parent).
989
- const configCwd = options.configCwd ?? effectiveCwd;
990
-
991
1023
  const env = await detectEnv(options.pi, effectiveCwd);
992
1024
  throwIfAborted(options.signal);
993
1025
 
@@ -1010,7 +1042,7 @@ export async function runAgent(
1010
1042
 
1011
1043
  // Skill preloading: when skills is string[], preload their content into prompt
1012
1044
  if (Array.isArray(skills)) {
1013
- const loaded = preloadSkills(skills, configCwd);
1045
+ const loaded = preloadSkills(skills, configCwd, { projectTrusted, deniedResource: deniedProjectResource });
1014
1046
  if (loaded.length > 0) {
1015
1047
  extras.skillBlocks = loaded;
1016
1048
  }
@@ -1060,15 +1092,15 @@ export async function runAgent(
1060
1092
  // skills later, so the loader override must enforce this policy on every update.
1061
1093
  const noSkills = skills === false || Array.isArray(skills);
1062
1094
 
1063
- const agentDir = getAgentDir();
1095
+ const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
1064
1096
 
1065
1097
  // Extension loading:
1066
- // - true → all default-discovered extensions
1098
+ // - true → all child-safe default-discovered extensions
1067
1099
  // - false → none (noExtensions)
1068
1100
  // - string[] → allowlist. Bare names keep matching default-discovered
1069
1101
  // extensions; path entries load explicitly; "*" keeps all defaults.
1070
- // On Pi with replaceable built-ins, restrictive policies resolve paths
1071
- // 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.
1072
1104
  //
1073
1105
  // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
1074
1106
  // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
@@ -1089,12 +1121,11 @@ export async function runAgent(
1089
1121
  : undefined;
1090
1122
  const keepNames = extensionsSpec?.names ?? new Set<string>();
1091
1123
  // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
1092
- // Plain canonical names only (case-insensitive). On older hosts filtering is
1093
- // post-factory; this is 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.
1094
1126
  const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
1095
1127
  const hasExcludes = excludeNames.size > 0;
1096
- // The override filters on older hosts, validates preselection on new hosts,
1097
- // 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
1098
1129
  // tool_call event through handlers in extension order; if an earlier extension
1099
1130
  // changes event.toolName, a later gate would inspect a different name from the
1100
1131
  // one Pi actually executes. Ordering before untrusted handlers is essential.
@@ -1108,6 +1139,7 @@ export async function runAgent(
1108
1139
  let discoveredNames: Set<string> | undefined;
1109
1140
  let prefilteredExtensionPaths: Set<string> | undefined;
1110
1141
  const resolvedExplicitNames = new Set<string>();
1142
+ let resolvedNames = new Map<string, string[]>();
1111
1143
  const interactiveAskNames = new Set(ctx.hasUI ? (agentConfig?.askTools ?? []).map((name) => name.toLowerCase()) : []);
1112
1144
  const approvalTimer: ApprovalTimer = {
1113
1145
  isInteractiveAsk: (name) => interactiveAskNames.has(name.toLowerCase()),
@@ -1177,9 +1209,9 @@ export async function runAgent(
1177
1209
  });
1178
1210
  },
1179
1211
  }];
1180
- // Pi 0.99 exposes these as public SDK factories. Earlier supported hosts do
1181
- // not export them, so capability-check the namespace instead of importing a
1182
- // missing named export (which would prevent the whole extension from loading).
1212
+ // Public SDK factories are verified on Pi 1.0.4. Capability-check the
1213
+ // namespace rather than importing a potentially missing named export; this
1214
+ // retained fallback does not establish support for older host versions.
1183
1215
  if ("createCodemodeExtension" in PiCodingAgent && typeof PiCodingAgent.createCodemodeExtension === "function") {
1184
1216
  extensionFactories.push({ name: "codemode", builtin: true, replaceable: true, factory: PiCodingAgent.createCodemodeExtension() });
1185
1217
  }
@@ -1187,21 +1219,27 @@ export async function runAgent(
1187
1219
  extensionFactories.push({ name: "tool-search", builtin: true, replaceable: true, factory: PiCodingAgent.createToolSearchExtension() });
1188
1220
  }
1189
1221
  if ("createMcpExtension" in PiCodingAgent && typeof PiCodingAgent.createMcpExtension === "function") {
1190
- extensionFactories.push({ name: "mcp", builtin: true, replaceable: true, factory: PiCodingAgent.createMcpExtension() });
1222
+ extensionFactories.push({ name: "mcp", builtin: true, replaceable: true,
1223
+ factory: configurationMcpFactory(PiCodingAgent.createMcpExtension(), configCwd, settingsManager) });
1191
1224
  }
1192
1225
 
1193
- const settingsManager = SettingsManager.create(configCwd, agentDir);
1226
+ for (const path of extensionsSpec?.paths ?? []) {
1227
+ if (deniedProjectResource(path)) {
1228
+ throw new Error(`Project extension source "${path}" requested by agent "${type}" is not trusted.`);
1229
+ }
1230
+ }
1194
1231
  let additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
1195
1232
  let settingsBeforeLoad: string | undefined;
1196
1233
  // Pi 0.99 resolves replacement conflicts BEFORE extensionsOverride. A rogue
1197
1234
  // discovered extension can register /mcp, replace builtin:mcp, and only then
1198
1235
  // get filtered out by our allowlist. Resolve configured resources through the
1199
1236
  // public package API first and feed only the selected paths into a loader with
1200
- // discovery off. Older Pi hosts have no replaceable built-ins; keep their
1201
- // existing post-load filter instead of relying on new host 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.
1202
1240
  const builtinNames = extensionFactories.filter((factory) => typeof factory !== "function" && factory.builtin)
1203
1241
  .map((factory) => factory.name);
1204
- const prefilter = !noExtensions && (!loadAll || hasExcludes) && builtinNames.length > 0;
1242
+ const prefilter = !noExtensions;
1205
1243
  if (prefilter) {
1206
1244
  await settingsManager.reload();
1207
1245
  throwIfAborted(options.signal);
@@ -1211,26 +1249,54 @@ export async function runAgent(
1211
1249
  // state or replace its source-resolution rules with path heuristics.
1212
1250
  const configured = await packages.resolve();
1213
1251
  throwIfAborted(options.signal);
1214
- const explicit = await packages.resolveExtensionSources(extensionsSpec?.paths ?? [], { temporary: true });
1215
- throwIfAborted(options.signal);
1252
+ // Validate each source before cross-source dedup discards valid alias metadata.
1253
+ const explicitSources = new Map<string, ResolvedResource[]>();
1216
1254
  for (const source of extensionsSpec?.paths ?? []) {
1217
- 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) {
1218
1259
  throw new Error(`Extension source "${source}" requested by agent "${type}" was not found or has no enabled extensions.`);
1219
1260
  }
1261
+ explicitSources.set(source, enabled);
1220
1262
  }
1263
+ const explicitResources = [...explicitSources.values()].flat();
1221
1264
  settingsBeforeLoad = JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()]);
1222
- const enabledConfigured = configured.extensions.filter((resource) => resource.enabled).map((resource) => resource.path);
1223
- const enabledExplicit = explicit.extensions.filter((resource) => resource.enabled);
1224
- discoveredNames = new Set([...enabledConfigured, ...enabledExplicit.map((resource) => resource.path)].flatMap(extensionCanonicalNames));
1265
+ const enabledConfigured = configured.extensions.filter((resource) => resource.enabled && !deniedProjectResource(resource.path));
1266
+ const enabledExplicit = explicitResources;
1267
+ for (const resource of enabledExplicit) {
1268
+ if (deniedProjectResource(resource.path)) {
1269
+ throw new Error(`Project extension "${resource.path}" requested by agent "${type}" is not trusted.`);
1270
+ }
1271
+ }
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
+ };
1225
1291
  const selectedExplicit = enabledExplicit.filter((resource) =>
1226
- !extensionCanonicalNames(resource.path).some((name) => excludeNames.has(name)));
1227
- 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)));
1228
1294
  // For priority, use the package's declared name rather than a generic entry
1229
1295
  // alias like "src"; unrelated packages often have src/index.ts too.
1230
1296
  const explicitNames = new Set(selectedExplicit.map((resource) =>
1231
- extensionPackageName(resource.path) ?? extensionCanonicalName(resource.path)));
1232
- for (const source of extensionsSpec?.paths ?? []) {
1233
- 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)))) {
1234
1300
  resolvedExplicitNames.add(extensionCanonicalName(source));
1235
1301
  }
1236
1302
  }
@@ -1238,15 +1304,23 @@ export async function runAgent(
1238
1304
  // The loader normally loads temporary paths before discovered paths. Keep
1239
1305
  // that priority (including for directory/package paths with many entries).
1240
1306
  ...selectedExplicit.map((resource) => resource.path),
1241
- ...enabledConfigured.filter((path) => {
1242
- const names = extensionCanonicalNames(path);
1307
+ ...enabledConfigured.filter((resource) => {
1308
+ const names = resolvedNames.get(resource.path) ?? extensionCanonicalNames(resource.path);
1243
1309
  if (names.some((name) => excludeNames.has(name))) return false;
1244
- if (explicitPaths.has(path) || explicitNames.has(extensionPackageName(path) ?? extensionCanonicalName(path))) return false;
1245
- return loadAll || names.some((name) => bareNames.has(name));
1246
- }),
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),
1247
1315
  ];
1248
- prefilteredExtensionPaths = new Set(selectedPaths);
1249
- 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);
1250
1324
  }
1251
1325
 
1252
1326
  const loader = new DefaultResourceLoader({
@@ -1258,7 +1332,11 @@ export async function runAgent(
1258
1332
  extensionsOverride,
1259
1333
  extensionFactories,
1260
1334
  noSkills,
1261
- skillsOverride: noSkills ? () => ({ skills: [], diagnostics: [] }) : undefined,
1335
+ skillsOverride: noSkills
1336
+ ? () => ({ skills: [], diagnostics: [] })
1337
+ : !projectTrusted
1338
+ ? (base) => ({ ...base, skills: base.skills.filter((skill) => !deniedProjectResource(skill.filePath)) })
1339
+ : undefined,
1262
1340
  noPromptTemplates: true,
1263
1341
  noThemes: true,
1264
1342
  noContextFiles: true,
@@ -1272,7 +1350,12 @@ export async function runAgent(
1272
1350
  throw new Error(`Pi extension settings changed while loading agent "${type}"; retry with the current settings.`);
1273
1351
  }
1274
1352
  const loadedPaths = new Set(loader.getExtensions().extensions.map((extension) => extension.path));
1275
- 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 : []) {
1276
1359
  if (!loadedPaths.has(path)) {
1277
1360
  const diagnostic = loader.getExtensions().errors.find((error) => error.path === path);
1278
1361
  throw new Error(`Selected extension "${path}" was not loaded for agent "${type}"${diagnostic ? `: ${diagnostic.error}` : " (possibly replaced by another extension)"}.`);
@@ -1305,7 +1388,7 @@ export async function runAgent(
1305
1388
  // already raised above instead of silently losing a requested capability.
1306
1389
  // Two remaining misconfigurations to catch:
1307
1390
  // - `extensions: [foo]` but no extension named foo was discovered (typo;
1308
- // on older hosts path entries also fold their canonical name into
1391
+ // without preselection path entries also fold their canonical name into
1309
1392
  // `keepNames`, so a failed path load is warned here).
1310
1393
  // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
1311
1394
  // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
@@ -1333,7 +1416,7 @@ export async function runAgent(
1333
1416
  }
1334
1417
  if (keepNames.size > 0 || extNames.size > 0) {
1335
1418
  const survivingNames = new Set(
1336
- loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
1419
+ loader.getExtensions().extensions.flatMap((e) => resolvedNames.get(e.path) ?? extensionCanonicalNames(e.path)),
1337
1420
  );
1338
1421
  for (const name of keepNames) {
1339
1422
  // A directory/package source can resolve to entries with different
@@ -1372,7 +1455,8 @@ export async function runAgent(
1372
1455
  const { configuredModel, source } = agentTierSnapshot;
1373
1456
  const scopeVerdict = checkModelScope({
1374
1457
  model,
1375
- cwd: ctx.cwd,
1458
+ cwd: projectTrust.cwd,
1459
+ authority: projectTrust,
1376
1460
  modelRegistry: ctx.modelRegistry,
1377
1461
  // scopeModels polices a tier chosen per dispatch, by the host model or by
1378
1462
  // a workflow script alike; a tier from the agent file or the configured
@@ -1411,6 +1495,7 @@ export async function runAgent(
1411
1495
  maxSubagentDepth: effectiveMaxDepth,
1412
1496
  allowedSubagents: agentConfig.allowedSubagents,
1413
1497
  configCwd,
1498
+ projectTrust,
1414
1499
  })
1415
1500
  : [];
1416
1501
  // `contact_supervisor` is injected separately from the nested tools and under
@@ -1420,8 +1505,9 @@ export async function runAgent(
1420
1505
  // only a human to answer, which `hasUI` decides. Isolation still suppresses
1421
1506
  // it: an `isolated: true` agent is defined as built-in tools only.
1422
1507
  const supervisorTools =
1423
- ctx.hasUI && !isolated && options.supervisorQuestions !== false
1508
+ (ctx.hasUI || options.supervisorQuestions === "parent") && !isolated && options.supervisorQuestions !== false
1424
1509
  ? createSupervisorTool({
1510
+ ...(options.supervisorQuestions === "parent" ? { parentQuestion: options.parentQuestion } : {}),
1425
1511
  // Use the canonical agent type, not display_name: the question must
1426
1512
  // identify the actual subagent that requested the decision.
1427
1513
  agentLabel: agentConfig?.name ?? type,
@@ -1562,7 +1648,7 @@ export async function runAgent(
1562
1648
  throwIfAborted(options.signal);
1563
1649
 
1564
1650
  const inScope = noExtensions ? undefined : extensionToolNamesInScope(session, {
1565
- loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames,
1651
+ loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames,
1566
1652
  });
1567
1653
  checkToolCall = async (name, input, deadline) => {
1568
1654
  const run = async (): Promise<ToolCallEventResult | undefined> => {
@@ -302,11 +302,15 @@ export function getConfig(type: string): {
302
302
  excludeExtensions?: string[];
303
303
  skills: true | string[] | false;
304
304
  promptMode: "replace" | "append";
305
+ source?: AgentConfig["source"];
306
+ sourcePath?: string;
305
307
  } {
306
308
  const key = resolveKey(type);
307
309
  const config = key ? agents.get(key) : undefined;
308
310
  if (config && config.enabled !== false) {
309
311
  return {
312
+ source: config.source,
313
+ sourcePath: config.sourcePath,
310
314
  displayName: config.displayName ?? config.name,
311
315
  color: config.color,
312
316
  description: config.description,
@@ -322,6 +326,8 @@ export function getConfig(type: string): {
322
326
  const gp = agents.get("general-purpose");
323
327
  if (gp && gp.enabled !== false) {
324
328
  return {
329
+ source: gp.source,
330
+ sourcePath: gp.sourcePath,
325
331
  displayName: gp.displayName ?? gp.name,
326
332
  color: gp.color,
327
333
  description: gp.description,
@@ -8,6 +8,7 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { isValidAgentTierKey } from "./agent-tiers.js";
9
9
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
10
  import { DEFAULT_AGENTS } from "./default-agents.js";
11
+ import { isProjectResource } from "./project-trust.js";
11
12
  import type { AgentConfig, MemoryScope } from "./types.js";
12
13
  import { sanitizeDisplayText } from "./ui/safe-text.js";
13
14
 
@@ -129,7 +130,8 @@ function createWarningSink(root: string): WarningSink {
129
130
  };
130
131
  }
131
132
 
132
- export function loadCustomAgents(cwd: string, strict = false): Map<string, AgentConfig> {
133
+ /** Standalone callers retain discovery defaults; runtime callers must pass captured trust. */
134
+ export function loadCustomAgents(cwd: string, strict = false, projectTrusted = true, deniedRoots: readonly string[] = []): Map<string, AgentConfig> {
133
135
  const discoveryRoot = normalizeDiscoveryRoot(cwd);
134
136
  const warningRoot = warningIdentity(cwd);
135
137
  const globalDir = join(getAgentDir(), "agents");
@@ -140,9 +142,12 @@ export function loadCustomAgents(cwd: string, strict = false): Map<string, Agent
140
142
  const agents = new Map<string, AgentConfig>();
141
143
  const priorities = new Map<string, number>();
142
144
  const skipped: SkippedAgent[] = [];
143
- loadFromDir(globalDir, agents, "global", strict, warn, 0, priorities, skipped); // lowest priority
144
- loadFromDir(workspaceProjectDir, agents, "project", strict, warn, 1, priorities, skipped); // shared workspace
145
- loadFromDir(projectDir, agents, "project", strict, warn, 2, priorities, skipped); // highest priority (overwrites)
145
+ const denied = projectTrusted ? deniedRoots : [...deniedRoots, discoveryRoot];
146
+ loadFromDir(globalDir, agents, "global", strict, warn, 0, priorities, skipped, denied);
147
+ if (projectTrusted) {
148
+ loadFromDir(workspaceProjectDir, agents, "project", strict, warn, 1, priorities, skipped, denied);
149
+ loadFromDir(projectDir, agents, "project", strict, warn, 2, priorities, skipped, denied);
150
+ }
146
151
 
147
152
  // Only report a fallback when the skipped file was higher priority than the
148
153
  // definition that survived the complete discovery pass. A valid higher
@@ -164,7 +169,9 @@ function loadFromDir(
164
169
  priority: number,
165
170
  priorities: Map<string, number>,
166
171
  skipped: SkippedAgent[],
172
+ deniedRoots: readonly string[],
167
173
  ): void {
174
+ if (deniedRoots.some(root => isProjectResource(dir, root))) return;
168
175
  if (!existsSync(dir)) return;
169
176
 
170
177
  let files: string[];
@@ -184,6 +191,7 @@ function loadFromDir(
184
191
  const filenameType = basename(file, ".md");
185
192
 
186
193
  const path = join(dir, file);
194
+ if (deniedRoots.some(root => isProjectResource(path, root))) continue;
187
195
 
188
196
  const parsed = readAgentFile(path, strict, warn);
189
197
  if (!parsed) {
@@ -3,7 +3,7 @@
3
3
  * + project-local `<cwd>/.pi/settings.json`, project wins) and resolves
4
4
  * entries to concrete `provider/modelId` keys for scope validation.
5
5
  *
6
- * **Project overrides global**, mirroring pi's own `SettingsManager`
6
+ * **Admitted project settings override global**, mirroring pi's own `SettingsManager`
7
7
  * deep-merge behavior and matching the precedence we use for our own
8
8
  * `subagents.json` settings (see `src/settings.ts:loadSettings`). If
9
9
  * project file has `enabledModels` set, it wholly replaces global's
@@ -25,6 +25,7 @@ import { join } from "node:path";
25
25
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
26
  import { minimatch } from "minimatch";
27
27
  import type { ModelEntry } from "./model-resolver.js";
28
+ import { isProjectResource, type ProjectTrust } from "./project-trust.js";
28
29
 
29
30
  /** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
30
31
  export interface ModelRegistryRef {
@@ -61,11 +62,17 @@ function readField(path: string): string[] | undefined {
61
62
  * Read enabledModels from pi's settings — project-local overrides global.
62
63
  * Mirrors pi's SettingsManager deep-merge for the `enabledModels` field
63
64
  * (and matches our own loadSettings precedence in src/settings.ts).
64
- * Returns undefined when neither file has the field.
65
+ * Runtime callers pass captured configuration authority to exclude denied
66
+ * project sources and global aliases; standalone callers keep project defaults.
67
+ * Returns undefined when neither admitted file has the field.
65
68
  */
66
- export function readEnabledModels(cwd: string): string[] | undefined {
67
- const [project, global] = settingsPaths(cwd);
68
- return readField(project) ?? readField(global);
69
+ export function readEnabledModels(cwd: string, authority?: ProjectTrust): string[] | undefined {
70
+ const [project, global] = settingsPaths(authority?.cwd ?? cwd);
71
+ // Standalone readers retain project precedence. Runtime readers must pass
72
+ // captured configuration authority, never infer it from execution cwd.
73
+ const denied = (path: string): boolean => authority?.deniedRoots.some(root => isProjectResource(path, root)) ?? false;
74
+ return (authority?.trusted !== false && !denied(project) ? readField(project) : undefined)
75
+ ?? (!denied(global) ? readField(global) : undefined);
69
76
  }
70
77
 
71
78
  /**
@@ -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
+ }
package/src/gate.ts CHANGED
Binary file