@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.
- package/CHANGELOG.md +40 -0
- package/README.md +76 -8
- package/package.json +8 -8
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +27 -2
- package/src/agent-runner.ts +142 -56
- package/src/agent-types.ts +6 -0
- package/src/custom-agents.ts +12 -4
- package/src/enabled-models.ts +12 -5
- package/src/extension-resource-names.ts +105 -0
- package/src/gate.ts +0 -0
- package/src/index.ts +177 -67
- package/src/model-scope.ts +4 -1
- package/src/nested-tools.ts +10 -4
- package/src/parent-questions.ts +453 -0
- package/src/project-trust.ts +146 -0
- package/src/question-origin.ts +24 -0
- package/src/settings.ts +7 -4
- package/src/skill-loader.ts +22 -10
- package/src/supervisor.ts +28 -8
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +49 -18
- package/src/ui/fleet-list.ts +125 -58
- package/src/ui/owner-group.ts +102 -0
- package/src/ui/schedule-menu.ts +14 -7
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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
|
|
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
|
-
//
|
|
1071
|
-
//
|
|
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).
|
|
1093
|
-
//
|
|
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
|
|
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
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
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,
|
|
1222
|
+
extensionFactories.push({ name: "mcp", builtin: true, replaceable: true,
|
|
1223
|
+
factory: configurationMcpFactory(PiCodingAgent.createMcpExtension(), configCwd, settingsManager) });
|
|
1191
1224
|
}
|
|
1192
1225
|
|
|
1193
|
-
const
|
|
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.
|
|
1201
|
-
//
|
|
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
|
|
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
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
|
1223
|
-
const enabledExplicit =
|
|
1224
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
1232
|
-
for (const source of
|
|
1233
|
-
if (
|
|
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((
|
|
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(
|
|
1245
|
-
|
|
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
|
-
|
|
1249
|
-
additionalExtensionPaths =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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> => {
|
package/src/agent-types.ts
CHANGED
|
@@ -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,
|
package/src/custom-agents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
144
|
-
loadFromDir(
|
|
145
|
-
|
|
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) {
|
package/src/enabled-models.ts
CHANGED
|
@@ -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
|
-
* **
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|