oira666_pi-subagent 0.5.0 → 0.5.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Delegate tasks to agents running in separate Pi processes. Each worker has its own context and saved session. The parent receives its final text and usage statistics, not its reasoning or tool transcript.
4
4
 
5
- Requires Pi 0.85.1 or newer, using the `@earendil-works` SDK packages.
5
+ Requires Pi 0.87.1 or newer, using the `@earendil-works` SDK packages.
6
6
 
7
7
  ## Install
8
8
 
@@ -18,6 +18,34 @@ To remove it:
18
18
  pi remove npm:oira666_pi-subagent
19
19
  ```
20
20
 
21
+ ## Extension controls
22
+
23
+ Exclude extensions from child agents without changing which extensions the parent loads:
24
+
25
+ ```bash
26
+ PI_SUBAGENT_EXCLUDE_EXTENSIONS="oira666_pi-free-swarm,@scope/other-package,./local-extension.ts" pi
27
+ ```
28
+
29
+ The originally requested hyphenated name is also supported. Use `env` because most shells cannot assign hyphenated variable names directly:
30
+
31
+ ```bash
32
+ env 'PI-SUBAGENT-EXCLUDE-EXTENSIONS=oira666_pi-free-swarm' pi
33
+ ```
34
+
35
+ Both variables accept comma-separated lists; whitespace and empty entries are ignored, and the two lists are combined. Entries match a package source, package name (including `npm:@scope/name@version`, ignoring the version), or an extension file/directory path. Automatic and settings-listed extension paths use the nearest owning `package.json` after resolving symlinks; loose hooks do not inherit the current workspace's or Pi configuration directory's package name. Relative paths resolve from the worker's working directory; `~/` and symlinks are supported. Commas inside paths are not supported. These variables are inherited by nested workers.
36
+
37
+ When a list is nonempty, the runner discovers enabled extensions without executing them, then launches the child with `--no-extensions` and explicit paths for every remaining extension, including authentication providers. This covers automatic user/project extensions and forwarded `-e` sources. An original `--no-extensions` still suppresses automatic extensions. Project resources are included only when the parent's live context trusts that working directory; unknown trust fails closed. The same trust decision is passed to the child. With no exclusions, launch behavior is unchanged and no extra discovery runs.
38
+
39
+ Discovery does not install packages or edit settings. Remote explicit `-e` sources must already be installed in Pi's user package location (or its supported legacy global npm location); temporary-only and project-only remote installs must first be installed there or passed as local paths. If Pi leaves a directory unresolved, pass its entry-point file explicitly so file exclusions cannot be bypassed by directory loading. Missing or invalid sources fail the launch rather than falling back to unfiltered loading. Pi's offline mode skips missing automatic packages, as usual. Filtering controls extension entry points, not code imported by an allowed extension, and the allowlist is a launch-time snapshot until the next delegation.
40
+
41
+ Disable this entire extension, including its tools, commands, flags, providers, and event handlers:
42
+
43
+ ```bash
44
+ PI_SUBAGENT_DISABLED=1 pi
45
+ ```
46
+
47
+ `true` (case-insensitive) also disables it; `PI-SUBAGENT-DISABLED` is an equivalent alias. Other values leave it enabled. The setting applies to the current process and is inherited by children. It is separate from child-extension exclusions, which do not disable anything in the parent. Restart Pi or reload extensions after changing the process environment.
48
+
21
49
  ## Launch and resume
22
50
 
23
51
  ```json
@@ -75,7 +103,7 @@ See [configuration](docs/configuration.md) for frontmatter, layer restrictions,
75
103
 
76
104
  Collapsed results show each child's name, task, status, and most recent activity anywhere in its subtree. `Ctrl+O` expands the newest call from memory without reading historical transcripts.
77
105
 
78
- `/subagent-expand <name>` opens a saved worker transcript. With no name, it opens a searchable picker. Name completion is fuzzy.
106
+ `/subagent-expand <name>` opens a saved worker transcript. With no name, it opens a searchable picker. Name completion is fuzzy. Each task or resume shows its actual provider/model and thinking level from the worker's session, not the parent's settings. Missing historical metadata appears as `unknown`.
79
107
 
80
108
  | Key | Action |
81
109
  | --- | --- |
package/config.ts CHANGED
@@ -30,6 +30,77 @@ function readConfig(filePath: string): Record<string, unknown> {
30
30
  }
31
31
  }
32
32
 
33
+ function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ let tempFileCounter = 0;
38
+
39
+ /** Add the explicit default without allowing a failed write to change runtime behavior. */
40
+ function persistSmartDecisionFallback(config: Record<string, unknown>, filePath: string): void {
41
+ const smartDecision = config["smart-decision"];
42
+ if (!isRecord(smartDecision) || Object.hasOwn(smartDecision, "fallback")) return;
43
+
44
+ smartDecision.fallback = true;
45
+ let tempPath: string | undefined;
46
+ let ownsTemp = false;
47
+ try {
48
+ // Replace the target rather than a symlink that points to it.
49
+ const targetPath = fs.realpathSync(filePath);
50
+ // Re-read immediately before writing so unrelated edits made since the initial load survive.
51
+ // Retrying also makes concurrent nested-worker migrations converge without clobbering one another.
52
+ for (let attempt = 0; attempt < 3; attempt++) {
53
+ const source = fs.readFileSync(targetPath, "utf8");
54
+ const latest = JSON.parse(source) as unknown;
55
+ if (!isRecord(latest)) return;
56
+ const latestSmartDecision = latest["smart-decision"];
57
+ if (!isRecord(latestSmartDecision)) return;
58
+ if (Object.hasOwn(latestSmartDecision, "fallback")) {
59
+ smartDecision.fallback = latestSmartDecision.fallback;
60
+ return;
61
+ }
62
+
63
+ latestSmartDecision.fallback = true;
64
+ const mode = fs.statSync(targetPath).mode & 0o7777;
65
+ const candidatePath = path.join(
66
+ path.dirname(targetPath),
67
+ `.${path.basename(targetPath)}.${process.pid}.${tempFileCounter++}.tmp`,
68
+ );
69
+ const fd = fs.openSync(candidatePath, "wx", mode);
70
+ tempPath = candidatePath;
71
+ ownsTemp = true;
72
+ try {
73
+ fs.writeFileSync(fd, `${JSON.stringify(latest, null, 2)}\n`, "utf8");
74
+ fs.fchmodSync(fd, mode);
75
+ fs.fsyncSync(fd);
76
+ } finally {
77
+ fs.closeSync(fd);
78
+ }
79
+
80
+ if (fs.readFileSync(targetPath, "utf8") !== source) {
81
+ fs.unlinkSync(tempPath);
82
+ tempPath = undefined;
83
+ ownsTemp = false;
84
+ continue;
85
+ }
86
+ fs.renameSync(tempPath, targetPath);
87
+ tempPath = undefined;
88
+ ownsTemp = false;
89
+ return;
90
+ }
91
+ throw new Error("config changed during fallback migration");
92
+ } catch {
93
+ if (ownsTemp && tempPath) {
94
+ try {
95
+ fs.unlinkSync(tempPath);
96
+ } catch {
97
+ // Best-effort cleanup. Do not expose filesystem errors or configuration contents.
98
+ }
99
+ }
100
+ console.warn("[pi-subagent] Failed to save the default smart-decision fallback.");
101
+ }
102
+ }
103
+
33
104
  function readToolPrompts(config: Record<string, unknown>, filePath: string): Record<string, string> {
34
105
  const toolPrompts = config["tool-prompts"];
35
106
  if (toolPrompts === undefined) return {};
@@ -85,6 +156,7 @@ export function loadPiSubagentsConfig(cwd?: string, includeProject = false): PiS
85
156
  let smartDecision: SmartDecisionConfig | undefined;
86
157
  for (const filePath of new Set(paths)) {
87
158
  const config = readConfig(filePath);
159
+ persistSmartDecisionFallback(config, filePath);
88
160
  Object.assign(toolPrompts, readToolPrompts(config, filePath));
89
161
  if (Object.hasOwn(config, "smart-decision")) {
90
162
  smartDecision = parseSmartDecisionConfig(config["smart-decision"]);
@@ -80,7 +80,7 @@ Replace the example choices with models available in your Pi installation. Each
80
80
  - `enabled` must be `true`. Missing settings, disabled settings, or an empty model list leave existing behavior unchanged and make no Jev requests.
81
81
  - `model` selects the decision model, not the worker model. `jev` maps to TypeSafe's `jev-latest`; explicit TypeSafe model IDs also work.
82
82
  - `api_key` is your TypeSafe API key. Worker providers still need their own Pi credentials.
83
- - `fallback` defaults to `true`. If selection fails, a short warning is written and the worker uses the original launch settings. Set it to `false` to return a tool error without launching the affected worker. This covers network errors, the 10-second request timeout, API errors, invalid answers, and invalid enabled configuration. Cancellation cancels the task instead of falling back.
83
+ - `fallback` defaults to `true`. When a loaded `smart-decision` object omits it, the extension adds `"fallback": true` and saves that configuration file at runtime. Existing values, including `false`, are left untouched. Untrusted project files are never changed. If saving fails, a warning is emitted and the runtime default still applies. If selection fails, a short warning is written and the worker uses the original launch settings. Set it to `false` to return a tool error without launching the affected worker. This covers network errors, the 10-second request timeout, API errors, invalid answers, and invalid enabled configuration. Cancellation cancels the task instead of falling back.
84
84
 
85
85
  Each fresh launch and actual resume sends the agent definition's system prompt, the assigned task, and the candidate descriptions to `https://api.typesafe.ai/v1/systemone`. Jev must select a configured candidate. Its provider, model, and thinking level override the parent model and agent frontmatter for that run. The parent's explicit `--provider` and `--api-key` are not forwarded when a Jev choice is applied. Startup retries reuse the choice. Finished results reused without launching a process make no request.
86
86
 
@@ -1,5 +1,5 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
- import type { Context, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
+ import type { TranscriptContext, SimpleStreamOptions } from "@earendil-works/pi-ai";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { SubagentDetails } from "../types.js";
5
5
 
@@ -7,7 +7,7 @@ export type ResumeModel = NonNullable<ExtensionContext["model"]>;
7
7
  export type ResumeModelRegistry = ExtensionContext["modelRegistry"];
8
8
  export type SessionContext = ExtensionContext;
9
9
  export type StreamOptions = SimpleStreamOptions;
10
- export type ProviderContext = Context;
10
+ export type ProviderContext = TranscriptContext;
11
11
  export type ProgressUpdate = AgentToolResult<SubagentDetails>;
12
12
 
13
13
  export function isRecord(value: unknown): value is Record<string, unknown> {
@@ -46,6 +46,7 @@ export async function executeSingle(
46
46
  try {
47
47
  result = await runAgentSubprocess({
48
48
  cwd: defaultCwd,
49
+ projectTrusted: state.latestSessionCtx?.cwd === defaultCwd && state.latestSessionCtx.isProjectTrusted(),
49
50
  agents,
50
51
  agentName,
51
52
  task,
@@ -149,7 +150,12 @@ export async function executeParallel(
149
150
  updateLatestBroadcastTargets(state, undefined);
150
151
  }
151
152
  },
152
- { ...extras, namesFile: state.currentNamesFile || undefined, smartDecision: state.smartDecision },
153
+ {
154
+ ...extras,
155
+ namesFile: state.currentNamesFile || undefined,
156
+ smartDecision: state.smartDecision,
157
+ projectTrusted: state.latestSessionCtx?.cwd === defaultCwd && state.latestSessionCtx.isProjectTrusted(),
158
+ },
153
159
  );
154
160
  } finally {
155
161
  for (const id of taskIds.values()) state.activeSubagents.delete(id);
@@ -127,7 +127,11 @@ export function registerResumeProvider(state: ExtensionState): void {
127
127
  if (plans.length > 0 && phase === "tool" && triggerMatches) {
128
128
  resume.phase = "final";
129
129
  const toolCalls = plans.map((plan, index) =>
130
- fauxToolCall(SUBAGENT_TOOL_NAME, { tasks: plan.tasks }, { id: `resume_subagent_${Date.now()}_${index}` }),
130
+ fauxToolCall(
131
+ SUBAGENT_TOOL_NAME,
132
+ { tasks: plan.tasks.map((task) => ({ ...task })) },
133
+ { id: `resume_subagent_${Date.now()}_${index}` },
134
+ ),
131
135
  );
132
136
  resumeCore.setResponses([() => fauxAssistantMessage(toolCalls, { stopReason: "toolUse" })]);
133
137
  const stream = resumeCore.streamSimple(model, context, options);
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { subagentDisabled } from "./runner/extension-policy.js";
2
3
  import { registerSessionEvents } from "./extension/events.js";
3
4
  import { registerSubagentExpandCommand } from "./extension/expand-command.js";
4
5
  import { registerPromptHook } from "./extension/prompt-hook.js";
@@ -12,6 +13,7 @@ export { getSubagentsToolDescription } from "./extension/prompts.js";
12
13
  export { collectCombinedUsageStatusLine, collectLiveUsageSummary } from "./extension/usage.js";
13
14
 
14
15
  export default function (pi: ExtensionAPI): void {
16
+ if (subagentDisabled()) return;
15
17
  pi.registerFlag("subagent-max-depth", {
16
18
  description: "Maximum allowed subagent delegation depth (default: 3).",
17
19
  type: "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -61,20 +61,20 @@
61
61
  "check": "npm run typecheck && npm run format:check && npm run check:size && npm test"
62
62
  },
63
63
  "devDependencies": {
64
- "@earendil-works/pi-agent-core": "^0.85.1",
65
- "@earendil-works/pi-ai": "^0.85.1",
66
- "@earendil-works/pi-coding-agent": "^0.85.1",
67
- "@earendil-works/pi-tui": "^0.85.1",
64
+ "@earendil-works/pi-agent-core": "^0.87.1",
65
+ "@earendil-works/pi-ai": "^0.87.1",
66
+ "@earendil-works/pi-coding-agent": "^0.87.1",
67
+ "@earendil-works/pi-tui": "^0.87.1",
68
68
  "@types/node": "^25.2.3",
69
69
  "prettier": "^3.9.6",
70
70
  "tsx": "^4.23.13",
71
71
  "typescript": "^5.9.3"
72
72
  },
73
73
  "peerDependencies": {
74
- "@earendil-works/pi-agent-core": ">=0.85.1",
75
- "@earendil-works/pi-ai": ">=0.85.1",
76
- "@earendil-works/pi-coding-agent": ">=0.85.1",
77
- "@earendil-works/pi-tui": ">=0.85.1"
74
+ "@earendil-works/pi-agent-core": ">=0.87.1",
75
+ "@earendil-works/pi-ai": ">=0.87.1",
76
+ "@earendil-works/pi-coding-agent": ">=0.87.1",
77
+ "@earendil-works/pi-tui": ">=0.87.1"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "@earendil-works/pi-agent-core": {
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../agents.js";
5
5
  import { SUBAGENT_FALLBACK_MODEL_ENV } from "./constants.js";
6
+ import { childExtensionArgs, excludedExtensions } from "./extension-policy.js";
6
7
  function resolveExtensionArg(value: string): string {
7
8
  if (!value) return value;
8
9
  if (value.startsWith("npm:") || value.startsWith("git:")) return value;
@@ -202,6 +203,12 @@ function parseInheritedCliArgs(argv: string[]): InheritedCliArgs {
202
203
  /** Cached once — process.argv is immutable at runtime */
203
204
  const _inheritedCliArgs = parseInheritedCliArgs(process.argv);
204
205
 
206
+ export async function resolveChildExtensionArgs(cwd: string, projectTrusted?: boolean): Promise<string[] | undefined> {
207
+ const excludes = excludedExtensions();
208
+ if (excludes.length === 0) return undefined;
209
+ return childExtensionArgs(_inheritedCliArgs.extensionArgs, cwd, projectTrusted, excludes);
210
+ }
211
+
205
212
  export function resolveSubagentModel(agentModel?: string, currentParentModel?: string): string | undefined {
206
213
  // The active parent model is authoritative. Agent frontmatter is retained as
207
214
  // a compatibility fallback only for callers that cannot supply live context.
@@ -219,6 +226,7 @@ export function buildPiArgs(
219
226
  fallbackModelOverride?: string,
220
227
  rawPrompt = false,
221
228
  selection?: { provider: string; model: string; thinking: string },
229
+ extensionArgsOverride?: string[],
222
230
  ): { args: string[]; prompt: string } {
223
231
  // A Jev-selected provider must not inherit a conflicting provider or its CLI credential.
224
232
  const proxyArgs = selection
@@ -227,7 +235,15 @@ export function buildPiArgs(
227
235
  !["--provider", "--api-key"].includes(arg) && !["--provider", "--api-key"].includes(args[index - 1]),
228
236
  )
229
237
  : _inheritedCliArgs.alwaysProxy;
230
- const args: string[] = ["--mode", "rpc", ..._inheritedCliArgs.extensionArgs, ...proxyArgs];
238
+ const args: string[] = [
239
+ "--mode",
240
+ "rpc",
241
+ ...(extensionArgsOverride ? [] : _inheritedCliArgs.extensionArgs),
242
+ ...proxyArgs,
243
+ ];
244
+ // Keep the resolved trust decision after forwarded startup flags. Extension filtering
245
+ // must never interpret another flag's value (such as a prompt or credential) as a flag.
246
+ if (extensionArgsOverride) args.push(...extensionArgsOverride);
231
247
 
232
248
  if (sessionDir) args.push("--session-dir", sessionDir);
233
249
  if (resumeSession) args.push("--continue");
@@ -0,0 +1,174 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import {
5
+ DefaultPackageManager,
6
+ SettingsManager,
7
+ getAgentDir,
8
+ type ResolvedResource,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ export function excludedExtensions(env: NodeJS.ProcessEnv = process.env): string[] {
12
+ return [
13
+ ...new Set(
14
+ [env["PI-SUBAGENT-EXCLUDE-EXTENSIONS"], env.PI_SUBAGENT_EXCLUDE_EXTENSIONS]
15
+ .flatMap((value) => (value ?? "").split(","))
16
+ .map((value) => value.trim())
17
+ .filter(Boolean),
18
+ ),
19
+ ];
20
+ }
21
+
22
+ export function subagentDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
23
+ return [env.PI_SUBAGENT_DISABLED, env["PI-SUBAGENT-DISABLED"]].some(
24
+ (value) => value === "1" || value?.toLowerCase() === "true",
25
+ );
26
+ }
27
+
28
+ function resolvedPath(value: string, cwd: string): string {
29
+ const absolute = path.resolve(cwd, value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
30
+ return fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
31
+ }
32
+
33
+ function npmName(source: string): string | undefined {
34
+ const spec = source.replace(/^npm:/, "");
35
+ return /^(?:@[^/\s]+\/)?[^/@\s]+(?:@[^\s]+)?$/.test(spec)
36
+ ? spec.slice(0, spec.indexOf("@", 1) === -1 ? undefined : spec.indexOf("@", 1))
37
+ : undefined;
38
+ }
39
+
40
+ function manifestName(directory: string): string | undefined {
41
+ const parsed: unknown = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"));
42
+ return typeof parsed === "object" && parsed !== null && "name" in parsed && typeof parsed.name === "string"
43
+ ? parsed.name
44
+ : undefined;
45
+ }
46
+
47
+ function packageName(resource: ResolvedResource, cwd: string, agentDir: string): string | undefined {
48
+ if (resource.metadata.source.startsWith("npm:")) return npmName(resource.metadata.source);
49
+ if (resource.metadata.origin === "package" && resource.metadata.baseDir) {
50
+ const base = resolvedPath(resource.metadata.baseDir, cwd);
51
+ if (fs.existsSync(path.join(base, "package.json"))) return manifestName(base);
52
+ }
53
+
54
+ const resourcePath = resolvedPath(resource.path, cwd);
55
+ let directory = fs.statSync(resourcePath).isDirectory() ? resourcePath : path.dirname(resourcePath);
56
+ const boundaries = new Set(
57
+ [
58
+ cwd,
59
+ agentDir,
60
+ path.join(cwd, ".pi"),
61
+ path.join(cwd, ".pi/extensions"),
62
+ path.join(agentDir, "extensions"),
63
+ os.homedir(),
64
+ ].map((entry) => resolvedPath(entry, cwd)),
65
+ );
66
+ // A loose hook belongs to neither the surrounding workspace nor Pi's config
67
+ // directory. Resolve symlinks first, and stop at the nearest manifest even if unnamed.
68
+ while (!boundaries.has(directory) && directory !== path.dirname(directory)) {
69
+ if (fs.existsSync(path.join(directory, "package.json"))) return manifestName(directory);
70
+ directory = path.dirname(directory);
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function isExcluded(resource: ResolvedResource, excludes: string[], cwd: string, agentDir: string): boolean {
76
+ const resourcePath = resolvedPath(resource.path, cwd);
77
+ const name = packageName(resource, cwd, agentDir);
78
+ return excludes.some((exclude) => {
79
+ if (
80
+ (resource.metadata.origin === "package" && exclude === resource.metadata.source) ||
81
+ (name !== undefined && npmName(exclude) === name)
82
+ )
83
+ return true;
84
+ const excludedPath = resolvedPath(exclude, cwd);
85
+ return resourcePath === excludedPath || resourcePath.startsWith(`${excludedPath}${path.sep}`);
86
+ });
87
+ }
88
+
89
+ function isRemote(source: string): boolean {
90
+ return /^(?:npm:|git:|https?:\/\/|git@|ssh:)/.test(source);
91
+ }
92
+
93
+ /** Resolve before loading: filtering Extension objects would already have executed their factories. */
94
+ export async function childExtensionArgs(
95
+ args: string[],
96
+ cwd: string,
97
+ projectTrusted = false,
98
+ excludes = excludedExtensions(),
99
+ agentDir = getAgentDir(),
100
+ ): Promise<string[]> {
101
+ if (excludes.length === 0) return args;
102
+ const rest: string[] = [];
103
+ const explicit: string[] = [];
104
+ let noExtensions = false;
105
+ for (let index = 0; index < args.length; index++) {
106
+ const arg = args[index];
107
+ if (arg === "-ne" || arg === "--no-extensions") noExtensions = true;
108
+ else if (arg === "-e" || arg === "--extension") {
109
+ const source = args[++index];
110
+ if (!source) throw new Error("Missing explicit extension source");
111
+ explicit.push(source);
112
+ } else if (["-a", "--approve", "-na", "--no-approve"].includes(arg)) {
113
+ // The live parent's decision is authoritative, not stale startup flags.
114
+ } else rest.push(arg);
115
+ }
116
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
117
+ if (settingsManager.drainErrors().length > 0) throw new Error("Cannot resolve child extensions: invalid Pi settings");
118
+ const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
119
+ const automatic = noExtensions ? [] : (await manager.resolve(async () => "error")).extensions;
120
+ const explicitResources: ResolvedResource[] = [];
121
+ for (const source of explicit) {
122
+ let resolved;
123
+ if (isRemote(source)) {
124
+ // resolveExtensionSources can install or refresh remote sources. Use the read-only
125
+ // missing-source policy instead; temporary-only remote installs must be installed normally first.
126
+ const explicitSettings = SettingsManager.inMemory(
127
+ {
128
+ npmCommand: settingsManager.getGlobalSettings().npmCommand,
129
+ packages: [source],
130
+ },
131
+ { projectTrusted: false },
132
+ );
133
+ const explicitManager = new DefaultPackageManager({ cwd, agentDir, settingsManager: explicitSettings });
134
+ if (!explicitManager.getInstalledPath(source, "user")) {
135
+ throw new Error(`Explicit extension source must be installed before exclusion filtering: ${source}`);
136
+ }
137
+ resolved = await explicitManager.resolve(async () => "error");
138
+ for (const kind of ["extensions", "skills", "prompts", "themes"] as const) {
139
+ resolved[kind] = resolved[kind].filter(
140
+ (resource) => resource.metadata.origin === "package" && resource.metadata.source === source,
141
+ );
142
+ }
143
+ if (Object.values(resolved).every((resources) => resources.length === 0)) {
144
+ throw new Error(`No installed resources match explicit extension source: ${source}`);
145
+ }
146
+ } else {
147
+ const local = resolvedPath(source, cwd);
148
+ if (!fs.existsSync(local)) throw new Error(`Missing explicit extension source: ${source}`);
149
+ resolved = await manager.resolveExtensionSources([local], { temporary: true });
150
+ }
151
+ explicitResources.push(...resolved.extensions);
152
+ for (const [kind, flag] of [
153
+ ["skills", "--skill"],
154
+ ["prompts", "--prompt-template"],
155
+ ["themes", "--theme"],
156
+ ] as const) {
157
+ for (const resource of resolved[kind]) if (resource.enabled) rest.push(flag, resource.path);
158
+ }
159
+ }
160
+ const kept = new Set<string>();
161
+ for (const resource of [...explicitResources, ...automatic]) {
162
+ if (!resource.enabled || isExcluded(resource, excludes, cwd, agentDir)) continue;
163
+ if (!fs.statSync(resource.path).isFile()) {
164
+ throw new Error(`Extension source did not resolve to files: ${resource.path}. Pass its entry point explicitly.`);
165
+ }
166
+ kept.add(resource.path);
167
+ }
168
+ return [
169
+ ...rest,
170
+ projectTrusted ? "--approve" : "--no-approve",
171
+ "--no-extensions",
172
+ ...[...kept].flatMap((entry) => ["-e", entry]),
173
+ ];
174
+ }
package/runner/options.ts CHANGED
@@ -17,6 +17,8 @@ export type RunningSubagentStartedCallback = (handle: RunningSubagentHandle) =>
17
17
  export interface RunAgentOptions {
18
18
  /** Working directory inherited by every subagent process. */
19
19
  cwd: string;
20
+ /** Effective trust for this cwd; absent SDK callers fail closed. */
21
+ projectTrusted?: boolean;
20
22
  /** All available agent configs. */
21
23
  agents: AgentConfig[];
22
24
  /** Name of the agent to run. */
@@ -40,6 +40,7 @@ export async function executeParallelSubprocess(
40
40
  namesFile?: string;
41
41
  budgets?: Array<SubagentBudget | undefined>;
42
42
  smartDecision?: RunAgentOptions["smartDecision"];
43
+ projectTrusted?: boolean;
43
44
  },
44
45
  ): Promise<{
45
46
  content: Array<{ type: "text"; text: string }>;
@@ -123,6 +124,7 @@ export async function executeParallelSubprocess(
123
124
  try {
124
125
  result = await runAgentSubprocess({
125
126
  cwd: defaultCwd,
127
+ projectTrusted: extras?.projectTrusted,
126
128
  agents,
127
129
  agentName: t.agent,
128
130
  task: t.task,
package/runner/single.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  STARTUP_RETRY_BASE_BACKOFF_MS,
8
8
  } from "./constants.js";
9
9
  import type { RunAgentOptions } from "./options.js";
10
- import { buildPiArgs } from "./arguments.js";
10
+ import { buildPiArgs, resolveChildExtensionArgs } from "./arguments.js";
11
11
  import { writePromptToTempFile, cleanupTempDir, sessionDirExists } from "./files.js";
12
12
  import { appendBoundedStderr, priorDescendantUsage, endedWithSyntheticResumeFailure } from "./result.js";
13
13
  import { runAttempt } from "./attempt.js";
@@ -112,6 +112,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
112
112
  }
113
113
 
114
114
  try {
115
+ const extensionArgs = await resolveChildExtensionArgs(opts.cwd, opts.projectTrusted);
115
116
  const selection = await selectSmartDecision(
116
117
  opts.smartDecision,
117
118
  { systemPrompt: agent.systemPrompt, task },
@@ -137,6 +138,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
137
138
  fallbackModel,
138
139
  opts.rawPrompt === true,
139
140
  selection,
141
+ extensionArgs,
140
142
  );
141
143
  const prompt =
142
144
  result.budget && readBudget(result.budget).limit > 0
@@ -93,11 +93,11 @@ export function renderTurnOverviewLines(
93
93
  const meta = [
94
94
  `Turn ${blockIndex + 1} of ${detail.blocks.length}: ${turnTitle}`,
95
95
  block.at ? formatClockTime(block.at) : "",
96
- detail.model ?? "",
97
96
  ]
98
97
  .filter(Boolean)
99
98
  .join(" • ");
100
99
  out.wrap(meta, "", "dim");
100
+ out.wrap(`Model: ${block.model ?? "unknown"} • Thinking: ${block.thinkingLevel ?? "unknown"}`, "", "dim");
101
101
  out.wrap(`session total: ${usageLine(detail.usage)}`, "", "dim");
102
102
  for (const note of detail.notes) out.wrap(note, "", "warning");
103
103
 
@@ -16,6 +16,9 @@ export interface DetailBlock {
16
16
  index: number;
17
17
  prompt: string;
18
18
  at?: number;
19
+ /** Actual settings recorded for this task or resume, not the parent's model. */
20
+ model?: string;
21
+ thinkingLevel?: string;
19
22
  events: DetailEvent[];
20
23
  }
21
24
 
@@ -32,6 +35,7 @@ export interface SubagentDetail {
32
35
  name: string;
33
36
  agent: string;
34
37
  model?: string;
38
+ thinkingLevel?: string;
35
39
  tools?: string[];
36
40
  createdAt?: number;
37
41
  sessionDir: string;
@@ -1,4 +1,4 @@
1
- import { sessionFilesIn, readSessionMessages } from "./session.js";
1
+ import { sessionFilesIn, readSessionEntries } from "./session.js";
2
2
  export { sessionFilesIn, readSessionMessages } from "./session.js";
3
3
  import * as os from "node:os";
4
4
  import type { NamesRegistry, SubagentNameRecord } from "../names.js";
@@ -92,23 +92,41 @@ export interface ParsedTranscript {
92
92
  blocks: DetailBlock[];
93
93
  usage: DetailUsage;
94
94
  toolCallCount: number;
95
+ model?: string;
96
+ thinkingLevel?: string;
95
97
  }
96
98
 
97
99
  export function parseTranscriptMessages(messages: unknown[]): ParsedTranscript {
98
100
  const blocks: DetailBlock[] = [];
99
101
  const usage: DetailUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
100
102
  let toolCallCount = 0;
103
+ let model: string | undefined;
104
+ let thinkingLevel: string | undefined;
101
105
  const pendingTools = new Map<string, Extract<DetailEvent, { type: "tool" }>>();
106
+ const observedModels = new Set<DetailBlock>();
102
107
 
103
108
  const currentBlock = (): DetailBlock => {
104
109
  if (blocks.length === 0) {
105
- blocks.push({ kind: "task", index: 0, prompt: "", events: [] });
110
+ blocks.push({ kind: "task", index: 0, prompt: "", model, thinkingLevel, events: [] });
106
111
  }
107
112
  return blocks[blocks.length - 1];
108
113
  };
109
114
 
110
115
  for (const entry of messages) {
111
116
  const wrapper = asRecord(entry);
117
+ if (wrapper.type === "model_change") {
118
+ if (typeof wrapper.modelId === "string" && wrapper.modelId && wrapper.modelId !== "synthetic-tool-call") {
119
+ model =
120
+ typeof wrapper.provider === "string" && wrapper.provider
121
+ ? `${wrapper.provider}/${wrapper.modelId}`
122
+ : wrapper.modelId;
123
+ }
124
+ continue;
125
+ }
126
+ if (wrapper.type === "thinking_level_change") {
127
+ if (typeof wrapper.thinkingLevel === "string" && wrapper.thinkingLevel) thinkingLevel = wrapper.thinkingLevel;
128
+ continue;
129
+ }
112
130
  const message = asRecord(wrapper.message);
113
131
  const at = parseTimestamp(wrapper.timestamp ?? message.timestamp);
114
132
  const role = message.role;
@@ -121,6 +139,8 @@ export function parseTranscriptMessages(messages: unknown[]): ParsedTranscript {
121
139
  index,
122
140
  prompt,
123
141
  at,
142
+ model,
143
+ thinkingLevel,
124
144
  events: [],
125
145
  });
126
146
  continue;
@@ -138,6 +158,19 @@ export function parseTranscriptMessages(messages: unknown[]): ParsedTranscript {
138
158
  usage.cost += typeof cost === "number" ? cost : Number(asRecord(cost).total) || 0;
139
159
 
140
160
  const block = currentBlock();
161
+ // Legacy transcripts may have messages but no model-change entries.
162
+ if (typeof message.model === "string" && message.model && message.model !== "synthetic-tool-call") {
163
+ const actualModel =
164
+ typeof message.provider === "string" && message.provider
165
+ ? `${message.provider}/${message.model}`
166
+ : message.model;
167
+ if (!observedModels.has(block)) {
168
+ block.model = actualModel;
169
+ block.thinkingLevel = thinkingLevel;
170
+ observedModels.add(block);
171
+ }
172
+ model = actualModel;
173
+ }
141
174
  for (const rawPart of Array.isArray(message.content) ? message.content : []) {
142
175
  const part = asRecord(rawPart);
143
176
  if (part.type === "thinking" && typeof part.thinking === "string") {
@@ -188,7 +221,7 @@ export function parseTranscriptMessages(messages: unknown[]): ParsedTranscript {
188
221
  }
189
222
  }
190
223
 
191
- return { blocks, usage, toolCallCount };
224
+ return { blocks, usage, toolCallCount, model, thinkingLevel };
192
225
  }
193
226
 
194
227
  export function buildSubagentDetail(record: SubagentNameRecord, options: { sessionDir?: string } = {}): SubagentDetail {
@@ -200,11 +233,15 @@ export function buildSubagentDetail(record: SubagentNameRecord, options: { sessi
200
233
  let blocks: DetailBlock[] = [];
201
234
  let usage: DetailUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
202
235
  let toolCallCount = 0;
236
+ let model: string | undefined;
237
+ let thinkingLevel: string | undefined;
203
238
 
204
239
  if (!sessionFile) {
205
240
  notes.push(`No session transcript found in ${shortenPath(sessionDir)}`);
206
241
  } else {
207
- const parsed = parseTranscriptMessages(readSessionMessages(sessionFile));
242
+ const parsed = parseTranscriptMessages(readSessionEntries(sessionFile));
243
+ model = parsed.model;
244
+ thinkingLevel = parsed.thinkingLevel;
208
245
  blocks = parsed.blocks;
209
246
  usage = parsed.usage;
210
247
  toolCallCount = parsed.toolCallCount;
@@ -214,7 +251,7 @@ export function buildSubagentDetail(record: SubagentNameRecord, options: { sessi
214
251
  }
215
252
 
216
253
  if (blocks.length === 0 && record.task) {
217
- blocks = [{ kind: "task", index: 0, prompt: record.task, at: record.createdAt, events: [] }];
254
+ blocks = [{ kind: "task", index: 0, prompt: record.task, at: record.createdAt, model, thinkingLevel, events: [] }];
218
255
  }
219
256
  if (blocks.length > 0 && !blocks[0].prompt && record.task) {
220
257
  blocks[0].prompt = record.task;
@@ -223,7 +260,8 @@ export function buildSubagentDetail(record: SubagentNameRecord, options: { sessi
223
260
  return {
224
261
  name: record.name,
225
262
  agent: record.agent,
226
- model: record.model,
263
+ model,
264
+ thinkingLevel,
227
265
  tools: record.tools,
228
266
  createdAt: record.createdAt,
229
267
  sessionDir,
package/ui/session.ts CHANGED
@@ -18,8 +18,8 @@ export function sessionFilesIn(sessionDir: string): string[] {
18
18
  }
19
19
  }
20
20
 
21
- export function readSessionMessages(file: string): unknown[] {
22
- const messages: unknown[] = [];
21
+ export function readSessionEntries(file: string): Record<string, unknown>[] {
22
+ const messages: Record<string, unknown>[] = [];
23
23
  let raw: string;
24
24
  try {
25
25
  raw = fs.readFileSync(file, "utf8");
@@ -34,7 +34,11 @@ export function readSessionMessages(file: string): unknown[] {
34
34
  } catch {
35
35
  continue;
36
36
  }
37
- if (entry?.type === "message" && entry.message) messages.push(entry);
37
+ messages.push(entry);
38
38
  }
39
39
  return messages;
40
40
  }
41
+
42
+ export function readSessionMessages(file: string): unknown[] {
43
+ return readSessionEntries(file).filter((entry) => entry.type === "message" && entry.message);
44
+ }