@narumitw/pi-subagents 0.46.0 → 0.47.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/README.md CHANGED
@@ -253,11 +253,11 @@ without launching or charging a child.
253
253
 
254
254
  | Value | Behavior |
255
255
  | --- | --- |
256
- | `"project-context"` (default) | Keep ordinary user context/system files and trusted project `AGENTS.md`, `CLAUDE.md`, and `SYSTEM.md`; disable skills and prompt templates |
256
+ | `"project-context"` (default) | Keep ordinary user context/system files and trusted project `AGENTS.md`, `CLAUDE.md`, `SYSTEM.md`, and `APPEND_SYSTEM.md`; disable skills and prompt templates |
257
257
  | `"none"` | Use only the package consultation base, selected agent prompt, and enforced read-only instruction |
258
258
  | `"all"` | Keep ordinarily discoverable trusted context/system/append-system files, skills, and prompt templates |
259
259
 
260
- Extensions remain disabled for all three values. A current target uses the session's effective project trust, including session-only or CLI overrides. An external target uses the nearest saved trust decision. For an untrusted, explicitly denied, unsaved, or trust-error target, consultation remains available when `cwdPolicy.consultation` permits it but automatically downgrades to `resources: "none"`. This also disables context files because Pi does not protect `AGENTS.md` and `CLAUDE.md` with project trust alone. A saved-trusted external target uses the configured resource policy and discovers `SYSTEM.md`, `APPEND_SYSTEM.md`, and ordinary child context from that target rather than the parent workspace.
260
+ Extensions remain disabled for all three values. Pi core owns system-prompt source precedence: a trusted project prompt wins over the global prompt, with the global prompt used as fallback. A selected Pi prompt source must be a readable regular file; directories, FIFOs, devices, sockets, and unreadable sources fail before child launch. A current target uses the session's effective project trust, including session-only or CLI overrides. An external target uses the nearest saved trust decision. For an untrusted, explicitly denied, unsaved, or trust-error target, consultation remains available when `cwdPolicy.consultation` permits it but automatically downgrades to `resources: "none"`. This also disables context files because Pi does not protect `AGENTS.md` and `CLAUDE.md` with project trust alone. A saved-trusted external target uses the configured resource policy and discovers `SYSTEM.md`, `APPEND_SYSTEM.md`, and ordinary child context from that target rather than the parent workspace.
261
261
 
262
262
  Both settings are user-owned in `~/.pi/agent/pi-subagents.json`; projects cannot override them. `cwdPolicy.consultation: "current-workspace"` rejects every canonical external target before agent discovery or launch even when that target is saved-trusted. This is not a path sandbox: read-only tools can still read an explicitly requested accessible absolute path.
263
263
 
@@ -457,12 +457,12 @@ Reasoning, tool results, custom transport messages, and non-text parts are exclu
457
457
  Stateful execution uses a transport boundary:
458
458
 
459
459
  - `subprocess` is the default compatibility and rollback path.
460
- - `in-process` uses only public Pi SDK APIs: `createAgentSession()`, `SessionManager.inMemory()`, `DefaultResourceLoader`, and normal session lifecycle methods. It isolates conversation/tool selection, not memory or crashes; child failures share the parent Node.js process.
460
+ - `in-process` uses only public Pi SDK APIs: `createAgentSessionServices()`, `createAgentSessionFromServices()`, `SessionManager.inMemory()`, and normal session lifecycle methods. It isolates conversation/tool selection, not memory or crashes; child failures share the parent Node.js process.
461
461
  - Child resource loading sets `noExtensions: true`, preventing recursive `pi-subagents` loading and duplicate extension side effects while retaining trust-eligible context/skill resources and the selected agent prompt. Both transports receive the same resolved target-trust boolean: subprocess children get explicit `--approve`/`--no-approve`, and in-process children set the same `SettingsManager.projectTrusted` value.
462
- - Agent model, thinking level, and built-in tool allow-list overrides are applied when the child is created. Parent model/thinking changes are snapshotted for subsequently created children; an existing child keeps its own session configuration.
462
+ - Agent model strings use Pi core's CLI resolver, including provider/model patterns, fuzzy matching, custom provider model IDs, and `:<thinking>` suffixes. Thinking level and built-in tool allow-list overrides are applied when the child is created. Parent model/thinking changes are snapshotted for subsequently created children; an existing child keeps its own session configuration.
463
463
  - Extension/custom tool names are rejected in-process with an actionable recommendation to use `subprocess`; permissions are never silently widened.
464
464
  - Timeout, parent abort, close, expiry, and session shutdown abort/dispose owned child sessions. A child that does not settle after abort grace is discarded rather than reused.
465
- - In-process startup failures do not silently retry through subprocesses, preventing duplicate side effects.
465
+ - In-process startup failures do not silently retry through subprocesses, preventing duplicate side effects. If the loaded Pi core lacks public `createAgentSessionServices()`, `createAgentSessionFromServices()`, or `resolveCliModel()` support, startup fails with an actionable instruction to select `stateful.transport: "subprocess"`.
466
466
 
467
467
  No private Pi imports, runtime casts, or `ExtensionAPI` monkey-patching are used. Approval policy, sandbox profile, provider-header hooks, extension state, global scheduling, and parent/child transcript switching are not inherited or provided by the in-process transport.
468
468
 
@@ -610,7 +610,7 @@ For `subagent_spawn`, the root agent should choose the lowest sufficient level:
610
610
 
611
611
  Blocking thinking precedence is: task/chain step/aggregator `thinkingLevel` → top-level `thinkingLevel` → agent default from config or frontmatter → Pi subprocess default.
612
612
 
613
- Stateful spawn precedence is: `subagent_spawn.thinkingLevel` → agent default from config or frontmatter → transport fallback. The subprocess transport then uses spawned Pi model/default resolution. The in-process transport uses a configured model thinking suffix and then the parent thinking snapshot captured when the child is created. An explicit spawn value is retained for the agent lifecycle and wins over all of those fallbacks.
613
+ Stateful spawn precedence is: `subagent_spawn.thinkingLevel` → agent default from config or frontmatter → transport fallback. The subprocess transport then uses spawned Pi model/default resolution. The in-process transport delegates configured model parsing to the loaded Pi core and uses its model thinking suffix before the parent thinking snapshot captured when the child is created. An explicit spawn value is retained for the agent lifecycle and wins over all of those fallbacks.
614
614
 
615
615
  Omit `thinkingLevel` to preserve existing behavior. Reported stateful details show the requested level, not a guarantee of the provider's effective value. Pi still owns model capability clamping; `pi-subagents` does not duplicate capability detection.
616
616
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agents.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
- import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
+ import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
10
10
 
@@ -316,7 +316,7 @@ function isDirectory(p: string): boolean {
316
316
  function findNearestProjectAgentsDir(cwd: string): string | null {
317
317
  let currentDir = cwd;
318
318
  while (true) {
319
- const candidate = path.join(currentDir, ".pi", "agents");
319
+ const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
320
320
  if (isDirectory(candidate)) return candidate;
321
321
 
322
322
  const parentDir = path.dirname(currentDir);
@@ -0,0 +1,74 @@
1
+ import {
2
+ DefaultResourceLoader,
3
+ getAgentDir,
4
+ SettingsManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { ConsultResourcePolicy } from "./agents.js";
7
+ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
8
+ import { assertPiPromptSourcesAreReadableFiles } from "./prompt-source-safety.js";
9
+ import type { ChildLaunchPolicy } from "./runner.js";
10
+
11
+ const MINIMAL_CONSULT_SYSTEM_PROMPT =
12
+ "You are a read-only consultation assistant. Analyze the delegated task using only executor-provided capabilities and return a grounded answer.";
13
+
14
+ /**
15
+ * Resolve Pi-owned prompt files without loading target settings, packages, or extensions.
16
+ * The child still owns context-file, skill, and prompt-template loading according to this policy.
17
+ */
18
+ export async function resolveConsultResourceLaunchPolicy(
19
+ policy: ConsultResourcePolicy,
20
+ projectTrusted: boolean,
21
+ cwd: string,
22
+ ): Promise<ChildLaunchPolicy> {
23
+ if (policy === "none") {
24
+ return {
25
+ disableExtensions: true,
26
+ disableSkills: true,
27
+ disablePromptTemplates: true,
28
+ disableContextFiles: true,
29
+ projectTrust: false,
30
+ baseSystemPrompt: MINIMAL_CONSULT_SYSTEM_PROMPT,
31
+ };
32
+ }
33
+
34
+ const agentDir = getAgentDir();
35
+ assertPiPromptSourcesAreReadableFiles(cwd, agentDir, projectTrusted, [
36
+ "SYSTEM.md",
37
+ "APPEND_SYSTEM.md",
38
+ ]);
39
+ const loader = new DefaultResourceLoader({
40
+ cwd,
41
+ agentDir,
42
+ settingsManager: SettingsManager.inMemory({}, { projectTrusted }),
43
+ noExtensions: true,
44
+ noSkills: true,
45
+ noPromptTemplates: true,
46
+ noThemes: true,
47
+ noContextFiles: true,
48
+ });
49
+ await loader.reload();
50
+
51
+ const discoveredSystemPrompt = loader.getSystemPrompt();
52
+ const baseSystemPrompt = discoveredSystemPrompt
53
+ ? truncateUtf8(discoveredSystemPrompt, DEFAULT_MAX_CONTEXT_BYTES).text
54
+ : undefined;
55
+ const appendSystemPromptPaths = loader
56
+ .getAppendSystemPromptSources()
57
+ .map((source) => source.path);
58
+ const shared = {
59
+ disableExtensions: true,
60
+ disableContextFiles: !projectTrusted,
61
+ projectTrust: projectTrusted,
62
+ baseSystemPrompt,
63
+ appendSystemPromptPaths:
64
+ appendSystemPromptPaths.length > 0 ? appendSystemPromptPaths : undefined,
65
+ };
66
+ if (policy === "project-context") {
67
+ return {
68
+ ...shared,
69
+ disableSkills: true,
70
+ disablePromptTemplates: true,
71
+ };
72
+ }
73
+ return shared;
74
+ }
package/src/consult.ts CHANGED
@@ -1,11 +1,10 @@
1
- import * as fs from "node:fs";
2
1
  import * as path from "node:path";
3
2
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
3
  import { StringEnum, type Usage } from "@earendil-works/pi-ai";
5
4
  import {
5
+ CONFIG_DIR_NAME,
6
6
  type ExtensionAPI,
7
7
  type ExtensionContext,
8
- getAgentDir,
9
8
  type ToolDefinition,
10
9
  } from "@earendil-works/pi-coding-agent";
11
10
  import { type Static, Type } from "typebox";
@@ -22,6 +21,7 @@ import {
22
21
  } from "./agents.js";
23
22
  import { resolveConsultTools } from "./consult-policy.js";
24
23
  import { renderConsultCall, renderConsultResult } from "./consult-render.js";
24
+ import { resolveConsultResourceLaunchPolicy } from "./consult-resources.js";
25
25
  import {
26
26
  assertConsultationTargetAllowed,
27
27
  type ResolvedSubagentTarget,
@@ -32,7 +32,6 @@ import {
32
32
  DEFAULT_MAX_CONTEXT_BYTES,
33
33
  DEFAULT_MAX_STDERR_BYTES,
34
34
  MAX_SUBAGENT_TIMEOUT_MS,
35
- truncateUtf8,
36
35
  } from "./limits.js";
37
36
  import {
38
37
  type ChildLaunchPolicy,
@@ -88,6 +87,7 @@ export interface RegisterSubagentConsultOptions {
88
87
  getSettings(): SubagentSettings | undefined;
89
88
  runChild?: (request: ConsultChildRequest) => Promise<SingleResult>;
90
89
  invocationOverride?: { command: string; argsPrefix?: string[] };
90
+ resolveResourceLaunchPolicy?: typeof resolveConsultResourceLaunchPolicy;
91
91
  }
92
92
 
93
93
  export interface ConsultProgressActivity {
@@ -159,8 +159,6 @@ const READ_ONLY_INSTRUCTION = [
159
159
  "If the task asks for implementation, return analysis or instructions instead of claiming changes.",
160
160
  ].join("\n");
161
161
 
162
- const MINIMAL_CONSULT_SYSTEM_PROMPT =
163
- "You are a read-only consultation assistant. Analyze the delegated task using only executor-provided capabilities and return a grounded answer.";
164
162
  const MAX_UNKNOWN_AGENT_NAME_BYTES = 128;
165
163
 
166
164
  export function registerSubagentConsult(
@@ -169,7 +167,7 @@ export function registerSubagentConsult(
169
167
  ): (catalog: string) => void {
170
168
  let generation = 0;
171
169
  const active = new Set<AbortController>();
172
- const activeChildren = new Set<Promise<SingleResult>>();
170
+ const activeWork = new Set<Promise<unknown>>();
173
171
  const cancelActive = (reason: string) => {
174
172
  generation++;
175
173
  for (const controller of active) {
@@ -177,14 +175,12 @@ export function registerSubagentConsult(
177
175
  }
178
176
  active.clear();
179
177
  };
180
- const cancelAndWaitForChildren = async (reason: string) => {
178
+ const cancelAndWaitForWork = async (reason: string) => {
181
179
  cancelActive(reason);
182
- await Promise.allSettled([...activeChildren]);
180
+ await Promise.allSettled([...activeWork]);
183
181
  };
184
- pi.on("session_start", () => cancelAndWaitForChildren("Subagent consultation session replaced"));
185
- pi.on("session_shutdown", () =>
186
- cancelAndWaitForChildren("Subagent consultation session shut down"),
187
- );
182
+ pi.on("session_start", () => cancelAndWaitForWork("Subagent consultation session replaced"));
183
+ pi.on("session_shutdown", () => cancelAndWaitForWork("Subagent consultation session shut down"));
188
184
 
189
185
  const baseDescription = () =>
190
186
  `Run one ephemeral subagent synchronously under enforced read-only tool and resource policies and return its answer. The child can use only the effective subset of Pi's built-in read, grep, find, and ls tools. Shell commands, file writes, extension tools, detached lifecycle operations, and persistent agent state are disabled. Working-directory target policy: ${options.getSettings()?.cwdPolicy?.consultation ?? DEFAULT_CONSULTATION_CWD_POLICY}; configured trusted-target resources: ${options.getSettings()?.consult?.resources ?? DEFAULT_CONSULT_RESOURCE_POLICY}; allowed targets without effective trust inherit no target/project resources. This is not a filesystem sandbox.`;
@@ -217,11 +213,11 @@ export function registerSubagentConsult(
217
213
  onUpdate?.(partial);
218
214
  },
219
215
  () => ownerGeneration === generation,
220
- (child) => {
221
- activeChildren.add(child);
222
- void child.then(
223
- () => activeChildren.delete(child),
224
- () => activeChildren.delete(child),
216
+ (work) => {
217
+ activeWork.add(work);
218
+ void work.then(
219
+ () => activeWork.delete(work),
220
+ () => activeWork.delete(work),
225
221
  );
226
222
  },
227
223
  );
@@ -334,7 +330,7 @@ async function executeConsult(
334
330
  options: RegisterSubagentConsultOptions,
335
331
  emitUpdate: (partial: AgentToolResult<ConsultDetails>) => void,
336
332
  isCurrent: () => boolean,
337
- trackChild: (child: Promise<SingleResult>) => void,
333
+ trackWork: (work: Promise<unknown>) => void,
338
334
  ): Promise<AgentToolResult<ConsultDetails>> {
339
335
  if (
340
336
  (operation.agentScope === "project" || operation.agentScope === "both") &&
@@ -360,7 +356,10 @@ async function executeConsult(
360
356
  `Available agents for agentScope "${operation.agentScope}": ${formatAvailableConsultAgents(discovery)}`,
361
357
  );
362
358
  }
363
- const setup = resolveConsultSetup(operation, agent, settings, target);
359
+ const setupWork = resolveConsultSetup(operation, agent, settings, target, options);
360
+ trackWork(setupWork);
361
+ const setup = await setupWork;
362
+ assertCurrentRequest(signal, isCurrent);
364
363
 
365
364
  if (agent.source === "project" && operation.confirmProjectAgents) {
366
365
  if (!ctx.hasUI) {
@@ -370,7 +369,7 @@ async function executeConsult(
370
369
  }
371
370
  const approved = await ctx.ui.confirm(
372
371
  "Run project-local read-only agent?",
373
- `Agent: ${safeTerminalLine(agent.name, 256)}\nSource: ${safeTerminalLine(path.posix.join(".pi", "agents", path.basename(agent.filePath)))}`,
372
+ `Agent: ${safeTerminalLine(agent.name, 256)}\nSource: ${safeTerminalLine(path.posix.join(CONFIG_DIR_NAME, "agents", path.basename(agent.filePath)))}`,
374
373
  );
375
374
  assertCurrentRequest(signal, isCurrent);
376
375
  if (!approved) {
@@ -400,7 +399,7 @@ async function executeConsult(
400
399
  emitUpdate(consultUpdate(result, setup.details));
401
400
  },
402
401
  });
403
- trackChild(child);
402
+ trackWork(child);
404
403
  const result = await child;
405
404
  if (!isCurrent()) throw abortError("Subagent consultation owner was replaced");
406
405
  if (result.aborted && !result.processStarted) {
@@ -432,18 +431,17 @@ async function executeConsult(
432
431
  };
433
432
  }
434
433
 
435
- function resolveConsultSetup(
434
+ async function resolveConsultSetup(
436
435
  operation: ReturnType<typeof validateConsultParams>,
437
436
  agent: AgentConfig,
438
437
  settings: SubagentSettings | undefined,
439
438
  target: ResolvedSubagentTarget,
439
+ options: RegisterSubagentConsultOptions,
440
440
  ) {
441
441
  const requestedResourcePolicy = settings?.consult?.resources ?? DEFAULT_CONSULT_RESOURCE_POLICY;
442
442
  const resourcePolicy = target.trust.projectTrusted ? requestedResourcePolicy : "none";
443
443
  const effectiveTools = resolveConsultTools(agent.tools);
444
444
  const projectTrusted = target.trust.projectTrusted;
445
- const launchPolicy = resourceLaunchPolicy(resourcePolicy, projectTrusted, target.cwd);
446
- launchPolicy.tools = effectiveTools;
447
445
  const thinkingLevel = resolveSubagentThinkingLevel([agent], agent.name, operation.thinkingLevel);
448
446
  const timeoutMs = operation.timeoutMs ?? agent.timeoutMs ?? resolveDefaultSubagentTimeoutMs();
449
447
  if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_SUBAGENT_TIMEOUT_MS) {
@@ -451,6 +449,10 @@ function resolveConsultSetup(
451
449
  `Subagent consultation timeout must be between 1 and ${MAX_SUBAGENT_TIMEOUT_MS}ms`,
452
450
  );
453
451
  }
452
+ const resolveResources =
453
+ options.resolveResourceLaunchPolicy ?? resolveConsultResourceLaunchPolicy;
454
+ const launchPolicy = await resolveResources(resourcePolicy, projectTrusted, target.cwd);
455
+ launchPolicy.tools = effectiveTools;
454
456
  const childAgent: AgentConfig = {
455
457
  ...agent,
456
458
  tools: effectiveTools,
@@ -625,95 +627,6 @@ function emptyProgressUsage(): ConsultProgress["usage"] {
625
627
  };
626
628
  }
627
629
 
628
- function resourceLaunchPolicy(
629
- policy: ConsultResourcePolicy,
630
- projectTrusted: boolean,
631
- workspace: string,
632
- ): ChildLaunchPolicy {
633
- if (policy === "none") {
634
- return {
635
- disableExtensions: true,
636
- disableSkills: true,
637
- disablePromptTemplates: true,
638
- disableContextFiles: true,
639
- projectTrust: false,
640
- baseSystemPrompt: MINIMAL_CONSULT_SYSTEM_PROMPT,
641
- };
642
- }
643
- const baseSystemPrompt = discoverSystemPrompt(workspace, projectTrusted);
644
- if (policy === "project-context") {
645
- return {
646
- disableExtensions: true,
647
- disableSkills: true,
648
- disablePromptTemplates: true,
649
- disableContextFiles: !projectTrusted,
650
- projectTrust: projectTrusted,
651
- baseSystemPrompt,
652
- };
653
- }
654
- return {
655
- disableExtensions: true,
656
- disableContextFiles: !projectTrusted,
657
- projectTrust: projectTrusted,
658
- baseSystemPrompt,
659
- appendSystemPromptPaths: discoverAppendSystemPrompts(workspace, projectTrusted),
660
- };
661
- }
662
-
663
- function discoverSystemPrompt(workspace: string, projectTrusted: boolean): string | undefined {
664
- const candidates = [
665
- ...(projectTrusted ? [path.join(workspace, ".pi", "SYSTEM.md")] : []),
666
- path.join(getAgentDir(), "SYSTEM.md"),
667
- ];
668
- for (const candidate of candidates) {
669
- const prompt = readBoundedOptionalPrompt(candidate);
670
- if (prompt !== undefined) return prompt;
671
- }
672
- return undefined;
673
- }
674
-
675
- function readBoundedOptionalPrompt(filePath: string): string | undefined {
676
- let descriptor: number | undefined;
677
- try {
678
- descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
679
- if (!fs.fstatSync(descriptor).isFile()) return undefined;
680
- const buffer = Buffer.alloc(DEFAULT_MAX_CONTEXT_BYTES + 4);
681
- let bytesRead = 0;
682
- while (bytesRead < buffer.length) {
683
- const next = fs.readSync(descriptor, buffer, bytesRead, buffer.length - bytesRead, bytesRead);
684
- if (next === 0) break;
685
- bytesRead += next;
686
- }
687
- return truncateUtf8(buffer.subarray(0, bytesRead).toString("utf8"), DEFAULT_MAX_CONTEXT_BYTES)
688
- .text;
689
- } catch {
690
- // Match Pi resource discovery: an unreadable optional prompt is skipped.
691
- return undefined;
692
- } finally {
693
- if (descriptor !== undefined) {
694
- try {
695
- fs.closeSync(descriptor);
696
- } catch {
697
- // Optional prompt cleanup must not make consultation discovery fail.
698
- }
699
- }
700
- }
701
- }
702
-
703
- function discoverAppendSystemPrompts(cwd: string, projectTrusted: boolean): string[] {
704
- const candidates = [
705
- path.join(getAgentDir(), "APPEND_SYSTEM.md"),
706
- ...(projectTrusted ? [path.join(cwd, ".pi", "APPEND_SYSTEM.md")] : []),
707
- ];
708
- return candidates.filter((candidate) => {
709
- try {
710
- return fs.statSync(candidate).isFile();
711
- } catch {
712
- return false;
713
- }
714
- });
715
- }
716
-
717
630
  function projectChildResult(result: SingleResult): Record<string, unknown> {
718
631
  return {
719
632
  exitCode: result.exitCode,
@@ -1,22 +1,17 @@
1
- import { join } from "node:path";
2
1
  import type { Api, Model } from "@earendil-works/pi-ai";
3
2
  import {
4
- createAgentSession,
5
- DefaultResourceLoader,
3
+ type AgentSessionServices,
6
4
  getAgentDir,
7
5
  type ModelRegistry,
6
+ type ModelRuntime,
8
7
  SessionManager,
9
8
  SettingsManager,
10
9
  } from "@earendil-works/pi-coding-agent";
11
- import {
12
- type AgentConfig,
13
- discoverAgents,
14
- isThinkingLevel,
15
- type SubagentThinkingLevel,
16
- } from "./agents.js";
10
+ import { type AgentConfig, discoverAgents, type SubagentThinkingLevel } from "./agents.js";
17
11
  import { redactPrivateText } from "./context.js";
18
12
  import { resolveDefaultSubagentTimeoutMs } from "./execution.js";
19
13
  import { DEFAULT_MAX_CONTEXT_BYTES, DEFAULT_MAX_OUTPUT_BYTES, truncateUtf8 } from "./limits.js";
14
+ import { assertPiPromptSourcesAreReadableFiles } from "./prompt-source-safety.js";
20
15
  import type { AgentTurn, ManagedAgent, TurnOutcome } from "./registry.js";
21
16
  import { readSubagentSettings } from "./settings.js";
22
17
  import type { SubagentTransport } from "./transport.js";
@@ -32,9 +27,20 @@ interface ChildModelRuntime {
32
27
  }
33
28
 
34
29
  interface CodingAgentRuntimeModule {
35
- ModelRuntime?: {
36
- create(options?: { authPath?: string; modelsPath?: string | null }): Promise<ChildModelRuntime>;
37
- };
30
+ createAgentSessionFromServices?: typeof import("@earendil-works/pi-coding-agent").createAgentSessionFromServices;
31
+ createAgentSessionServices?: typeof import("@earendil-works/pi-coding-agent").createAgentSessionServices;
32
+ resolveCliModel?: typeof import("@earendil-works/pi-coding-agent").resolveCliModel;
33
+ }
34
+
35
+ interface CoreModelSupport {
36
+ modelRuntime: ModelRuntime;
37
+ resolveCliModel: typeof import("@earendil-works/pi-coding-agent").resolveCliModel;
38
+ }
39
+
40
+ interface CoreSessionSupport {
41
+ createAgentSessionFromServices: typeof import("@earendil-works/pi-coding-agent").createAgentSessionFromServices;
42
+ createAgentSessionServices: typeof import("@earendil-works/pi-coding-agent").createAgentSessionServices;
43
+ resolveCliModel: typeof import("@earendil-works/pi-coding-agent").resolveCliModel;
38
44
  }
39
45
 
40
46
  interface RegisteredProviderRegistry {
@@ -335,39 +341,36 @@ export async function createSdkChildSession(
335
341
  options: ChildSessionCreateOptions,
336
342
  ): Promise<ChildSession> {
337
343
  const agentDir = getAgentDir();
338
- const { loader: resourceLoader, settingsManager } = await createInProcessResourceLoader(
344
+ const projectTrusted =
345
+ options.agent.target?.trust.projectTrusted ??
346
+ (options.agent.agentScope === "project" || options.agent.agentScope === "both");
347
+ const { services, support: coreSupport } = await prepareInProcessServices(
339
348
  options.agent.cwd,
340
349
  agentDir,
341
350
  options.agentConfig.systemPrompt,
342
- options.agent.target?.trust.projectTrusted ??
343
- (options.agent.agentScope === "project" || options.agent.agentScope === "both"),
351
+ projectTrusted,
344
352
  );
345
- const resolved = await resolveChildModel(options);
346
- const modelRuntime = await createChildModelRuntime(
347
- options.modelRegistry,
348
- resolved.model,
349
- agentDir,
353
+ copyRegisteredProviders(
354
+ options.modelRegistry as unknown as RegisteredProviderRegistry,
355
+ services.modelRuntime as unknown as ChildModelRuntime,
350
356
  );
351
- const model =
352
- modelRuntime?.getModel(resolved.model.provider, resolved.model.id) ?? resolved.model;
357
+ const modelSupport: CoreModelSupport = {
358
+ modelRuntime: services.modelRuntime,
359
+ resolveCliModel: coreSupport.resolveCliModel,
360
+ };
361
+ const resolved = await resolveChildModel(options, modelSupport);
362
+ await transferChildModelAuth(options.modelRegistry, resolved.model, services.modelRuntime);
363
+ const model = resolved.model;
353
364
  const sessionManager = SessionManager.inMemory(options.agent.cwd);
354
365
  seedChildSessionManager(sessionManager, options, model);
355
- const sessionOptions: Record<string, unknown> = {
356
- cwd: options.agent.cwd,
357
- agentDir,
366
+ const created = await coreSupport.createAgentSessionFromServices({
367
+ services,
368
+ sessionManager,
358
369
  model,
359
370
  thinkingLevel: resolved.thinkingLevel,
360
- resourceLoader,
361
- settingsManager,
362
- sessionManager,
363
371
  tools: options.tools,
364
372
  noTools: options.tools?.length === 0 ? "all" : undefined,
365
- };
366
- if (modelRuntime) sessionOptions.modelRuntime = modelRuntime;
367
- else sessionOptions.modelRegistry = options.modelRegistry;
368
- const created = await createAgentSession(
369
- sessionOptions as NonNullable<Parameters<typeof createAgentSession>[0]>,
370
- );
373
+ });
371
374
  const session = created.session;
372
375
  if (options.tools !== undefined) {
373
376
  const active = session.getActiveToolNames();
@@ -397,25 +400,31 @@ export async function createSdkChildSession(
397
400
  };
398
401
  }
399
402
 
400
- async function createChildModelRuntime(
401
- modelRegistry: ModelRegistry,
402
- model: Model<Api>,
403
- agentDir: string,
404
- ): Promise<ChildModelRuntime | undefined> {
403
+ async function loadCoreSessionSupport(): Promise<CoreSessionSupport | undefined> {
405
404
  const codingAgentModule = (await import(
406
405
  "@earendil-works/pi-coding-agent"
407
406
  )) as unknown as CodingAgentRuntimeModule;
408
- if (!codingAgentModule.ModelRuntime) return undefined;
407
+ if (
408
+ !codingAgentModule.createAgentSessionFromServices ||
409
+ !codingAgentModule.createAgentSessionServices ||
410
+ !codingAgentModule.resolveCliModel
411
+ ) {
412
+ return undefined;
413
+ }
414
+ return {
415
+ createAgentSessionFromServices: codingAgentModule.createAgentSessionFromServices,
416
+ createAgentSessionServices: codingAgentModule.createAgentSessionServices,
417
+ resolveCliModel: codingAgentModule.resolveCliModel,
418
+ };
419
+ }
409
420
 
410
- const modelRuntime = await codingAgentModule.ModelRuntime.create({
411
- authPath: join(agentDir, "auth.json"),
412
- modelsPath: join(agentDir, "models.json"),
413
- });
414
- const registeredProviders = modelRegistry as unknown as RegisteredProviderRegistry;
415
- copyRegisteredProviders(registeredProviders, modelRuntime);
421
+ async function transferChildModelAuth(
422
+ modelRegistry: ModelRegistry,
423
+ model: Model<Api>,
424
+ modelRuntime: ModelRuntime,
425
+ ): Promise<void> {
416
426
  const auth = await modelRegistry.getApiKeyAndHeaders(model);
417
427
  if (auth.ok && auth.apiKey) await modelRuntime.setRuntimeApiKey(model.provider, auth.apiKey);
418
- return modelRuntime;
419
428
  }
420
429
 
421
430
  export function copyRegisteredProviders(
@@ -433,20 +442,33 @@ export function copyRegisteredProviders(
433
442
  }
434
443
  }
435
444
 
436
- export async function resolveChildModel(options: ChildSessionCreateOptions): Promise<{
445
+ export async function resolveChildModel(
446
+ options: ChildSessionCreateOptions,
447
+ support?: CoreModelSupport,
448
+ ): Promise<{
437
449
  model: Model<Api>;
438
450
  thinkingLevel: SubagentThinkingLevel;
439
451
  }> {
452
+ if (!support) throw unsupportedInProcessCoreError();
440
453
  let model = options.parentRuntime.model;
441
454
  let modelThinkingLevel: SubagentThinkingLevel | undefined;
442
- if (options.agentConfig.model) {
443
- const parsed = parseModelRequest(options.agentConfig.model);
444
- model = resolveConfiguredModel(parsed.model, options.modelRegistry);
445
- modelThinkingLevel = parsed.thinkingLevel;
455
+ if (options.agentConfig.model !== undefined) {
456
+ const requested = options.agentConfig.model.trim();
457
+ if (!requested) throw new Error("In-process subagent model cannot be empty");
458
+ const resolved = support.resolveCliModel({
459
+ cliModel: requested,
460
+ modelRuntime: support.modelRuntime,
461
+ });
462
+ if (resolved.error) throw new Error(resolved.error);
463
+ if (!resolved.model)
464
+ throw new Error(`Unable to resolve in-process subagent model ${requested}`);
465
+ model = resolved.model;
466
+ modelThinkingLevel = resolved.thinkingLevel;
446
467
  }
447
468
  if (!model) model = options.modelRegistry.getAvailable()[0];
448
469
  if (!model)
449
470
  throw new Error("No model with configured authentication is available for in-process subagent");
471
+ model = support.modelRuntime.getModel(model.provider, model.id) ?? model;
450
472
  return {
451
473
  model,
452
474
  thinkingLevel:
@@ -457,68 +479,42 @@ export async function resolveChildModel(options: ChildSessionCreateOptions): Pro
457
479
  };
458
480
  }
459
481
 
460
- function parseModelRequest(value: string): {
461
- model: string;
462
- thinkingLevel?: SubagentThinkingLevel;
463
- } {
464
- const requested = value.trim();
465
- const separator = requested.lastIndexOf(":");
466
- if (separator > 0) {
467
- const suffix = requested.slice(separator + 1);
468
- if (isThinkingLevel(suffix)) {
469
- return { model: requested.slice(0, separator), thinkingLevel: suffix };
470
- }
471
- }
472
- return { model: requested };
473
- }
474
-
475
- function resolveConfiguredModel(value: string, modelRegistry: ModelRegistry): Model<Api> {
476
- const requested = value.trim();
477
- if (!requested) throw new Error("In-process subagent model cannot be empty");
478
- const slash = requested.indexOf("/");
479
- if (slash > 0) {
480
- const exact = modelRegistry.find(requested.slice(0, slash), requested.slice(slash + 1));
481
- if (exact) return exact;
482
- }
483
- const lowered = requested.toLowerCase();
484
- const exactMatches = modelRegistry
485
- .getAll()
486
- .filter((model) => model.id.toLowerCase() === lowered || model.name.toLowerCase() === lowered);
487
- if (exactMatches.length === 1) return exactMatches[0];
488
- const partialMatches = modelRegistry
489
- .getAll()
490
- .filter(
491
- (model) =>
492
- model.id.toLowerCase().includes(lowered) || model.name.toLowerCase().includes(lowered),
493
- );
494
- if (partialMatches.length === 1) return partialMatches[0];
495
- const displayedMatches = partialMatches
496
- .slice(0, 8)
497
- .map((model) => `${model.provider}/${model.id}`);
498
- const remaining = partialMatches.length - displayedMatches.length;
499
- const suffix =
500
- displayedMatches.length > 0
501
- ? `; matches: ${displayedMatches.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`
502
- : "";
503
- throw new Error(`Unable to resolve in-process subagent model ${requested}${suffix}`);
482
+ function unsupportedInProcessCoreError(): Error {
483
+ return new Error(
484
+ 'In-process subagents require Pi core createAgentSessionServices, createAgentSessionFromServices, and resolveCliModel support; set stateful.transport to "subprocess".',
485
+ );
504
486
  }
505
487
 
506
- export async function createInProcessResourceLoader(
488
+ async function prepareInProcessServices(
507
489
  cwd: string,
508
490
  agentDir: string,
509
491
  agentSystemPrompt: string,
510
- projectTrusted = false,
511
- ): Promise<{ loader: DefaultResourceLoader; settingsManager: SettingsManager }> {
492
+ projectTrusted: boolean,
493
+ ): Promise<{ services: AgentSessionServices; support: CoreSessionSupport }> {
494
+ assertPiPromptSourcesAreReadableFiles(cwd, agentDir, projectTrusted, ["SYSTEM.md"]);
512
495
  const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
513
- const loader = new DefaultResourceLoader({
496
+ const support = await loadCoreSessionSupport();
497
+ if (!support) throw unsupportedInProcessCoreError();
498
+ const services = await support.createAgentSessionServices({
514
499
  cwd,
515
500
  agentDir,
516
501
  settingsManager,
517
- noExtensions: true,
518
- appendSystemPrompt: agentSystemPrompt.trim() ? [agentSystemPrompt] : [],
502
+ resourceLoaderOptions: {
503
+ noExtensions: true,
504
+ appendSystemPrompt: agentSystemPrompt.trim() ? [agentSystemPrompt] : [],
505
+ },
519
506
  });
520
- await loader.reload();
521
- return { loader, settingsManager };
507
+ return { services, support };
508
+ }
509
+
510
+ export async function createInProcessServices(
511
+ cwd: string,
512
+ agentDir: string,
513
+ agentSystemPrompt: string,
514
+ projectTrusted = false,
515
+ ): Promise<AgentSessionServices> {
516
+ return (await prepareInProcessServices(cwd, agentDir, agentSystemPrompt, projectTrusted))
517
+ .services;
522
518
  }
523
519
 
524
520
  export function seedChildSessionManager(
package/src/inspect.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import * as path from "node:path";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ CONFIG_DIR_NAME,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
4
8
  import { type Static, Type } from "typebox";
5
9
  import {
6
10
  type AgentConfig,
@@ -270,7 +274,9 @@ function projectAgent(
270
274
  scope: agent.source === "project" ? "project" : "user",
271
275
  path:
272
276
  agent.source === "project"
273
- ? safeTerminalLine(path.posix.join(".pi", "agents", path.basename(agent.filePath)))
277
+ ? safeTerminalLine(
278
+ path.posix.join(CONFIG_DIR_NAME, "agents", path.basename(agent.filePath)),
279
+ )
274
280
  : safeDisplayPath(agent.filePath, ctx.cwd),
275
281
  model: agent.model ? boundedPrivateText(agent.model, 256) : undefined,
276
282
  thinkingLevel: agent.thinkingLevel,
package/src/limits.ts CHANGED
@@ -1,6 +1,8 @@
1
- export const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024;
1
+ import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const DEFAULT_MAX_OUTPUT_BYTES = DEFAULT_MAX_BYTES;
2
4
  export const DEFAULT_MAX_STDERR_BYTES = 16 * 1024;
3
- export const DEFAULT_MAX_CONTEXT_BYTES = 50 * 1024;
5
+ export const DEFAULT_MAX_CONTEXT_BYTES = DEFAULT_MAX_BYTES;
4
6
  export const MAX_SUBAGENT_TIMEOUT_MS = 2_147_483_647;
5
7
  export const DEFAULT_MAX_MESSAGES = 200;
6
8
  export const TRUNCATION_MARKER = "\n… [truncated by pi-subagents]";
@@ -0,0 +1,48 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
4
+
5
+ export type PiPromptFileName = "SYSTEM.md" | "APPEND_SYSTEM.md";
6
+
7
+ /**
8
+ * Prevent Pi's synchronous prompt loader from opening a selected directory, FIFO, socket, device,
9
+ * or unreadable source. Candidate order mirrors core only as a preflight guard; core still resolves
10
+ * and loads the prompt after this check.
11
+ */
12
+ export function assertPiPromptSourcesAreReadableFiles(
13
+ cwd: string,
14
+ agentDir: string,
15
+ projectTrusted: boolean,
16
+ fileNames: readonly PiPromptFileName[],
17
+ ): void {
18
+ for (const fileName of fileNames) {
19
+ const candidates = [
20
+ ...(projectTrusted ? [path.join(cwd, CONFIG_DIR_NAME, fileName)] : []),
21
+ path.join(agentDir, fileName),
22
+ ];
23
+ for (const candidate of candidates) {
24
+ const status = promptSourceStatus(candidate);
25
+ if (status === "missing") continue;
26
+ if (status === "invalid") {
27
+ throw new Error(`Pi ${fileName} prompt source must be a readable regular file`);
28
+ }
29
+ break;
30
+ }
31
+ }
32
+ }
33
+
34
+ function promptSourceStatus(filePath: string): "missing" | "regular" | "invalid" {
35
+ let descriptor: number | undefined;
36
+ try {
37
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
38
+ return fs.fstatSync(descriptor).isFile() ? "regular" : "invalid";
39
+ } catch (error) {
40
+ const code =
41
+ error && typeof error === "object" && "code" in error
42
+ ? (error as { code?: unknown }).code
43
+ : undefined;
44
+ return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "invalid";
45
+ } finally {
46
+ if (descriptor !== undefined) fs.closeSync(descriptor);
47
+ }
48
+ }
package/src/safe-text.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { DEFAULT_MAX_LINES, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { redactPrivateText } from "./context.js";
5
5
  import { DEFAULT_MAX_OUTPUT_BYTES, TRUNCATION_MARKER, truncateUtf8 } from "./limits.js";
6
6
 
7
- export const DEFAULT_MAX_OUTPUT_LINES = 2_000;
7
+ export const DEFAULT_MAX_OUTPUT_LINES = DEFAULT_MAX_LINES;
8
8
 
9
9
  export function safeTerminalText(value: string): string {
10
10
  return (
package/src/subagents.ts CHANGED
@@ -12,7 +12,11 @@
12
12
  * Uses JSON mode to capture structured output from subagents.
13
13
  */
14
14
 
15
- import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ CONFIG_DIR_NAME,
17
+ type ExtensionAPI,
18
+ type ToolDefinition,
19
+ } from "@earendil-works/pi-coding-agent";
16
20
  import {
17
21
  type ConsultationCwdPolicy,
18
22
  type ConsultResourcePolicy,
@@ -137,7 +141,7 @@ function registerBlockingSubagent(
137
141
  "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
138
142
  "Parallel mode may include an aggregator fan-in step that receives all task outputs. Use subagent_consult instead for one synchronous child that must be executor-constrained to read-only tools.",
139
143
  'Default agent scope is "user" (from ~/.pi/agent/agents).',
140
- 'To enable project-local agents in .pi/agents, pass agentScope: "both" (or "project") as a top-level argument for that call.',
144
+ `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, pass agentScope: "both" (or "project") as a top-level argument for that call.`,
141
145
  `Working-directory target policy: ${getSettings()?.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY}. This controls launch targets and protected project resources, not filesystem access or sandboxing.`,
142
146
  ].join(" ");
143
147
  const definition: ToolDefinition<typeof SubagentParams, SubagentDetails> = {