@narumitw/pi-subagents 0.42.0 → 0.43.1

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.
@@ -0,0 +1,183 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
4
+ import type { ConsultationCwdPolicy, DelegationCwdPolicy } from "./agents.js";
5
+ import { safeTerminalLine } from "./safe-text.js";
6
+
7
+ export type TargetBoundary = "current-workspace" | "external";
8
+ export type TargetTrustKind =
9
+ | "session-trusted"
10
+ | "session-untrusted"
11
+ | "saved-trusted"
12
+ | "saved-denied"
13
+ | "unsaved"
14
+ | "trust-error";
15
+
16
+ export interface ResolvedTargetTrust {
17
+ kind: TargetTrustKind;
18
+ projectTrusted: boolean;
19
+ sourcePath?: string;
20
+ warning?: string;
21
+ }
22
+
23
+ export interface ResolvedSubagentTarget {
24
+ cwd: string;
25
+ workspace: string;
26
+ boundary: TargetBoundary;
27
+ trust: ResolvedTargetTrust;
28
+ }
29
+
30
+ export interface TargetPolicyAudit {
31
+ cwd: string;
32
+ boundary: TargetBoundary;
33
+ trust: {
34
+ kind: TargetTrustKind;
35
+ projectTrusted: boolean;
36
+ sourcePath?: string;
37
+ warning?: string;
38
+ };
39
+ }
40
+
41
+ export function targetPolicyAudit(target: ResolvedSubagentTarget): TargetPolicyAudit {
42
+ return {
43
+ cwd: safeTerminalLine(target.cwd),
44
+ boundary: target.boundary,
45
+ trust: {
46
+ ...target.trust,
47
+ sourcePath: target.trust.sourcePath ? safeTerminalLine(target.trust.sourcePath) : undefined,
48
+ warning: target.trust.warning ? safeTerminalLine(target.trust.warning, 512) : undefined,
49
+ },
50
+ };
51
+ }
52
+
53
+ export interface ResolveSubagentTargetOptions {
54
+ workspace: string;
55
+ requestedCwd?: string;
56
+ currentProjectTrusted: boolean;
57
+ agentDir?: string;
58
+ }
59
+
60
+ export function resolveSubagentTarget(
61
+ options: ResolveSubagentTargetOptions,
62
+ ): ResolvedSubagentTarget {
63
+ const workspace = canonicalDirectory(options.workspace, "Current workspace");
64
+ const requested = path.resolve(options.workspace, options.requestedCwd ?? options.workspace);
65
+ const cwd = canonicalDirectory(requested, "Subagent working directory");
66
+ const boundary: TargetBoundary = isEqualOrDescendant(cwd, workspace)
67
+ ? "current-workspace"
68
+ : "external";
69
+ if (boundary === "current-workspace") {
70
+ return {
71
+ cwd,
72
+ workspace,
73
+ boundary,
74
+ trust: {
75
+ kind: options.currentProjectTrusted ? "session-trusted" : "session-untrusted",
76
+ projectTrusted: options.currentProjectTrusted,
77
+ sourcePath: workspace,
78
+ },
79
+ };
80
+ }
81
+ try {
82
+ const entry = new ProjectTrustStore(options.agentDir ?? getAgentDir()).getEntry(cwd);
83
+ if (!entry) {
84
+ return {
85
+ cwd,
86
+ workspace,
87
+ boundary,
88
+ trust: { kind: "unsaved", projectTrusted: false },
89
+ };
90
+ }
91
+ return {
92
+ cwd,
93
+ workspace,
94
+ boundary,
95
+ trust: {
96
+ kind: entry.decision ? "saved-trusted" : "saved-denied",
97
+ projectTrusted: entry.decision,
98
+ sourcePath: entry.path,
99
+ },
100
+ };
101
+ } catch {
102
+ return {
103
+ cwd,
104
+ workspace,
105
+ boundary,
106
+ trust: {
107
+ kind: "trust-error",
108
+ projectTrusted: false,
109
+ warning: safeTerminalLine(
110
+ "Could not resolve Pi trust store; protected target resources were disabled. Repair trust with Pi /trust and restart Pi.",
111
+ 512,
112
+ ),
113
+ },
114
+ };
115
+ }
116
+ }
117
+
118
+ export function assertConsultationTargetAllowed(
119
+ target: ResolvedSubagentTarget,
120
+ policy: ConsultationCwdPolicy,
121
+ ): void {
122
+ if (policy === "current-workspace" && target.boundary !== "current-workspace") {
123
+ throw new Error(
124
+ `Read-only consultation target is outside the current workspace: ${safeTerminalLine(target.cwd)}`,
125
+ );
126
+ }
127
+ }
128
+
129
+ export function assertDelegationTargetAllowed(
130
+ target: ResolvedSubagentTarget,
131
+ policy: DelegationCwdPolicy,
132
+ ): void {
133
+ if (target.boundary === "current-workspace" || policy === "anywhere") return;
134
+ if (policy === "trusted-targets" && target.trust.kind === "saved-trusted") return;
135
+ if (policy === "current-workspace") {
136
+ throw new Error(
137
+ `General delegation target is outside the current workspace: ${safeTerminalLine(target.cwd)}`,
138
+ );
139
+ }
140
+ const reason =
141
+ target.trust.kind === "trust-error"
142
+ ? target.trust.warning
143
+ : `Target trust is ${target.trust.kind}.`;
144
+ throw new Error(
145
+ [
146
+ `General delegation target is not a saved-trusted folder: ${safeTerminalLine(target.cwd)}`,
147
+ reason,
148
+ "Open Pi in that folder, manage trust with /trust, restart Pi, or choose Anywhere in /subagents settings.",
149
+ ]
150
+ .filter(Boolean)
151
+ .join(" "),
152
+ );
153
+ }
154
+
155
+ function canonicalDirectory(value: string, label: string): string {
156
+ let canonical: string;
157
+ try {
158
+ canonical = fs.realpathSync(value);
159
+ } catch (error) {
160
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
161
+ throw new Error(`${label} does not exist: ${safeTerminalLine(value)}`);
162
+ }
163
+ throw new Error(
164
+ `${label} cannot be resolved: ${safeTerminalLine(value)}: ${formatError(error)}`,
165
+ );
166
+ }
167
+ if (!fs.statSync(canonical).isDirectory()) {
168
+ throw new Error(`${label} is not a directory: ${safeTerminalLine(canonical)}`);
169
+ }
170
+ return canonical;
171
+ }
172
+
173
+ function isEqualOrDescendant(candidate: string, workspace: string): boolean {
174
+ const relative = path.relative(workspace, candidate);
175
+ return (
176
+ relative === "" ||
177
+ (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
178
+ );
179
+ }
180
+
181
+ function formatError(error: unknown): string {
182
+ return error instanceof Error ? error.message : String(error);
183
+ }
package/src/execution.ts CHANGED
@@ -4,8 +4,15 @@ import {
4
4
  type AgentConfig,
5
5
  type AgentScope,
6
6
  discoverAgents,
7
+ type SubagentSettings,
7
8
  type SubagentThinkingLevel,
8
9
  } from "./agents.js";
10
+ import {
11
+ assertDelegationTargetAllowed,
12
+ type ResolvedSubagentTarget,
13
+ resolveSubagentTarget,
14
+ targetPolicyAudit,
15
+ } from "./cwd-policy.js";
9
16
  import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
10
17
  import { hasUsableAggregator, type SubagentParams } from "./params.js";
11
18
  import {
@@ -19,7 +26,12 @@ import {
19
26
  type SingleResult,
20
27
  type SubagentDetails,
21
28
  } from "./runner.js";
22
- import { readSubagentSettings, resolveSubagentThinkingLevel } from "./settings.js";
29
+ import { safeTerminalLine } from "./safe-text.js";
30
+ import {
31
+ DEFAULT_DELEGATION_CWD_POLICY,
32
+ readSubagentSettings,
33
+ resolveSubagentThinkingLevel,
34
+ } from "./settings.js";
23
35
 
24
36
  const MAX_PARALLEL_TASKS = 8;
25
37
  const MAX_CONCURRENCY = 4;
@@ -104,11 +116,15 @@ export async function executeSubagent(
104
116
  signal: AbortSignal | undefined,
105
117
  onUpdate: AgentToolUpdateCallback<SubagentDetails> | undefined,
106
118
  ctx: ExtensionContext,
119
+ settingsOverride?: SubagentSettings,
107
120
  ): Promise<AgentToolResult<SubagentDetails> & { isError?: boolean }> {
108
121
  assertSubagentDepthAllowed();
109
122
  const agentScope: AgentScope = params.agentScope ?? "user";
123
+ if ((agentScope === "project" || agentScope === "both") && !ctx.isProjectTrusted()) {
124
+ throw new Error("Project-local subagent definitions require a trusted project");
125
+ }
110
126
  const aggregator = hasUsableAggregator(params.aggregator) ? params.aggregator : undefined;
111
- const config = readSubagentSettings();
127
+ const config = settingsOverride ?? readSubagentSettings();
112
128
  const discovery = discoverAgents(ctx.cwd, agentScope, config);
113
129
  const agents = discovery.agents;
114
130
  const confirmProjectAgents = params.confirmProjectAgents ?? true;
@@ -152,6 +168,28 @@ export async function executeSubagent(
152
168
  };
153
169
  }
154
170
 
171
+ const delegationPolicy = config?.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY;
172
+ const resolveTarget = (cwd: string | undefined): ResolvedSubagentTarget => {
173
+ const target = resolveSubagentTarget({
174
+ workspace: ctx.cwd,
175
+ requestedCwd: cwd,
176
+ currentProjectTrusted: ctx.isProjectTrusted(),
177
+ });
178
+ assertDelegationTargetAllowed(target, delegationPolicy);
179
+ return target;
180
+ };
181
+ const singleTarget = hasSingle ? resolveTarget(params.cwd) : undefined;
182
+ const chainTargets = params.chain?.map((step) => resolveTarget(step.cwd)) ?? [];
183
+ const parallelTargets = params.tasks?.map((task) => resolveTarget(task.cwd)) ?? [];
184
+ const aggregatorTarget = aggregator ? resolveTarget(aggregator.cwd) : undefined;
185
+ const attachTarget = (result: SingleResult, target: ResolvedSubagentTarget): SingleResult => {
186
+ result.target = targetPolicyAudit(target);
187
+ return result;
188
+ };
189
+ const launchPolicy = (target: ResolvedSubagentTarget) => ({
190
+ projectTrust: target.trust.projectTrusted,
191
+ });
192
+
155
193
  if (agentScope === "project" || agentScope === "both") {
156
194
  const requestedAgentNames = new Set<string>();
157
195
  if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
@@ -168,12 +206,19 @@ export async function executeSubagent(
168
206
  throw new Error("Project-local subagent definitions require a trusted project");
169
207
  }
170
208
  if (confirmProjectAgents && ctx.hasUI) {
171
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
172
- const dir = discovery.projectAgentsDir ?? "(unknown)";
209
+ const names = projectAgentsRequested
210
+ .map((agent) => safeTerminalLine(agent.name, 256))
211
+ .join(", ");
212
+ const dir = safeTerminalLine(discovery.projectAgentsDir ?? "(unknown)");
173
213
  const ok = await ctx.ui.confirm(
174
214
  "Run project-local agents?",
175
215
  `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
176
216
  );
217
+ if (signal?.aborted) {
218
+ const error = new Error("Subagent call was aborted during project-agent confirmation");
219
+ error.name = "AbortError";
220
+ throw error;
221
+ }
177
222
  if (!ok) {
178
223
  return {
179
224
  content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
@@ -213,18 +258,24 @@ export async function executeSubagent(
213
258
  }
214
259
  : undefined;
215
260
 
216
- const result = await runSingleAgent(
217
- ctx.cwd,
218
- agents,
219
- step.agent,
220
- taskWithContext,
221
- step.cwd,
222
- i + 1,
223
- signal,
224
- resolveThinkingLevel(step.agent, step.thinkingLevel),
225
- resolveTimeoutMs(step.agent, step.timeoutMs),
226
- chainUpdate,
227
- makeDetails("chain"),
261
+ const target = chainTargets[i];
262
+ const result = attachTarget(
263
+ await runSingleAgent(
264
+ ctx.cwd,
265
+ agents,
266
+ step.agent,
267
+ taskWithContext,
268
+ target.cwd,
269
+ i + 1,
270
+ signal,
271
+ resolveThinkingLevel(step.agent, step.thinkingLevel),
272
+ resolveTimeoutMs(step.agent, step.timeoutMs),
273
+ chainUpdate,
274
+ makeDetails("chain"),
275
+ undefined,
276
+ launchPolicy(target),
277
+ ),
278
+ target,
228
279
  );
229
280
  results.push(result);
230
281
 
@@ -346,24 +397,30 @@ export async function executeSubagent(
346
397
  params.tasks,
347
398
  MAX_CONCURRENCY,
348
399
  async (t, index) => {
349
- const result = await runSingleAgent(
350
- ctx.cwd,
351
- agents,
352
- t.agent,
353
- t.task,
354
- t.cwd,
355
- undefined,
356
- signal,
357
- resolveThinkingLevel(t.agent, t.thinkingLevel),
358
- resolveTimeoutMs(t.agent, t.timeoutMs),
359
- // Per-task update callback
360
- (partial) => {
361
- if (partial.details?.results[0]) {
362
- allResults[index] = { ...partial.details.results[0], exitCode: -1 };
363
- emitParallelUpdate();
364
- }
365
- },
366
- makeDetails("parallel"),
400
+ const target = parallelTargets[index];
401
+ const result = attachTarget(
402
+ await runSingleAgent(
403
+ ctx.cwd,
404
+ agents,
405
+ t.agent,
406
+ t.task,
407
+ target.cwd,
408
+ undefined,
409
+ signal,
410
+ resolveThinkingLevel(t.agent, t.thinkingLevel),
411
+ resolveTimeoutMs(t.agent, t.timeoutMs),
412
+ // Per-task update callback
413
+ (partial) => {
414
+ if (partial.details?.results[0]) {
415
+ allResults[index] = { ...partial.details.results[0], exitCode: -1 };
416
+ emitParallelUpdate();
417
+ }
418
+ },
419
+ makeDetails("parallel"),
420
+ undefined,
421
+ launchPolicy(target),
422
+ ),
423
+ target,
367
424
  );
368
425
  allResults[index] = result;
369
426
  doneCount += 1;
@@ -399,26 +456,32 @@ export async function executeSubagent(
399
456
  : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`,
400
457
  DEFAULT_MAX_CONTEXT_BYTES,
401
458
  ).text;
402
- aggregatorResult = await runSingleAgent(
403
- ctx.cwd,
404
- agents,
405
- aggregator.agent,
406
- aggregatorTask,
407
- aggregator.cwd,
408
- undefined,
409
- signal,
410
- resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
411
- resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
412
- (partial) => {
413
- status.update(fanInStatus(aggregator.agent));
414
- if (onUpdate && partial.details?.results[0]) {
415
- onUpdate({
416
- content: partial.content,
417
- details: makeDetails("parallel")(results, partial.details.results[0]),
418
- });
419
- }
420
- },
421
- makeDetails("parallel"),
459
+ const target = aggregatorTarget as ResolvedSubagentTarget;
460
+ aggregatorResult = attachTarget(
461
+ await runSingleAgent(
462
+ ctx.cwd,
463
+ agents,
464
+ aggregator.agent,
465
+ aggregatorTask,
466
+ target.cwd,
467
+ undefined,
468
+ signal,
469
+ resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
470
+ resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
471
+ (partial) => {
472
+ status.update(fanInStatus(aggregator.agent));
473
+ if (onUpdate && partial.details?.results[0]) {
474
+ onUpdate({
475
+ content: partial.content,
476
+ details: makeDetails("parallel")(results, partial.details.results[0]),
477
+ });
478
+ }
479
+ },
480
+ makeDetails("parallel"),
481
+ undefined,
482
+ launchPolicy(target),
483
+ ),
484
+ target,
422
485
  );
423
486
  }
424
487
 
@@ -463,18 +526,24 @@ export async function executeSubagent(
463
526
  const status = startSubagentStatus(ctx, toolCallId, singleStatus(params.agent));
464
527
 
465
528
  try {
466
- const result = await runSingleAgent(
467
- ctx.cwd,
468
- agents,
469
- params.agent,
470
- params.task,
471
- params.cwd,
472
- undefined,
473
- signal,
474
- resolveThinkingLevel(params.agent, params.thinkingLevel),
475
- resolveTimeoutMs(params.agent, params.timeoutMs),
476
- onUpdate,
477
- makeDetails("single"),
529
+ const target = singleTarget as ResolvedSubagentTarget;
530
+ const result = attachTarget(
531
+ await runSingleAgent(
532
+ ctx.cwd,
533
+ agents,
534
+ params.agent,
535
+ params.task,
536
+ target.cwd,
537
+ undefined,
538
+ signal,
539
+ resolveThinkingLevel(params.agent, params.thinkingLevel),
540
+ resolveTimeoutMs(params.agent, params.timeoutMs),
541
+ onUpdate,
542
+ makeDetails("single"),
543
+ undefined,
544
+ launchPolicy(target),
545
+ ),
546
+ target,
478
547
  );
479
548
  const isError = isResultError(result);
480
549
  if (isError) {
@@ -339,7 +339,8 @@ export async function createSdkChildSession(
339
339
  options.agent.cwd,
340
340
  agentDir,
341
341
  options.agentConfig.systemPrompt,
342
- options.agent.agentScope === "project" || options.agent.agentScope === "both",
342
+ options.agent.target?.trust.projectTrusted ??
343
+ (options.agent.agentScope === "project" || options.agent.agentScope === "both"),
343
344
  );
344
345
  const resolved = await resolveChildModel(options);
345
346
  const modelRuntime = await createChildModelRuntime(
@@ -508,8 +509,7 @@ export async function createInProcessResourceLoader(
508
509
  agentSystemPrompt: string,
509
510
  projectTrusted = false,
510
511
  ): Promise<{ loader: DefaultResourceLoader; settingsManager: SettingsManager }> {
511
- const settingsManager = SettingsManager.create(cwd, agentDir);
512
- settingsManager.setProjectTrusted(projectTrusted);
512
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
513
513
  const loader = new DefaultResourceLoader({
514
514
  cwd,
515
515
  agentDir,