@marcoscale98/pi-extensible-workflows 5.17.0-fork.1 → 6.2.1-fork.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +2 -2
  3. package/dist/src/agent-execution.d.ts +43 -10
  4. package/dist/src/agent-execution.js +442 -162
  5. package/dist/src/background-widget.d.ts +5 -1
  6. package/dist/src/background-widget.js +5 -10
  7. package/dist/src/decoders.d.ts +2 -3
  8. package/dist/src/decoders.js +59 -37
  9. package/dist/src/execution.d.ts +6 -2
  10. package/dist/src/execution.js +48 -7
  11. package/dist/src/host-delivery.js +5 -8
  12. package/dist/src/host-navigator.js +5 -2
  13. package/dist/src/host-phases.d.ts +1 -0
  14. package/dist/src/host-recovery.d.ts +11 -8
  15. package/dist/src/host-recovery.js +14 -22
  16. package/dist/src/host-runtime.js +56 -5
  17. package/dist/src/host-view.d.ts +6 -1
  18. package/dist/src/host-view.js +46 -20
  19. package/dist/src/host.d.ts +2 -2
  20. package/dist/src/host.js +246 -118
  21. package/dist/src/index.d.ts +1 -2
  22. package/dist/src/index.js +1985 -1690
  23. package/dist/src/index.js.map +3 -3
  24. package/dist/src/paths.d.ts +8 -5
  25. package/dist/src/paths.js +24 -13
  26. package/dist/src/persistence.d.ts +1 -0
  27. package/dist/src/persistence.js +1 -0
  28. package/dist/src/pi-runtime-runner.js +4 -1
  29. package/dist/src/registry.d.ts +9 -7
  30. package/dist/src/registry.js +29 -71
  31. package/dist/src/runtime/agent-runner.d.ts +2 -0
  32. package/dist/src/script-tools.d.ts +27 -0
  33. package/dist/src/script-tools.js +87 -0
  34. package/dist/src/session-lease.d.ts +1 -1
  35. package/dist/src/session-lease.js +45 -11
  36. package/dist/src/settings.d.ts +19 -3
  37. package/dist/src/settings.js +63 -50
  38. package/dist/src/store.d.ts +3 -1
  39. package/dist/src/store.js +17 -1
  40. package/dist/src/trajectory.d.ts +12 -3
  41. package/dist/src/trajectory.js +32 -11
  42. package/dist/src/types.d.ts +129 -45
  43. package/dist/src/types.js +5 -8
  44. package/dist/src/utils.d.ts +21 -1
  45. package/dist/src/utils.js +46 -6
  46. package/dist/src/validation.d.ts +14 -3
  47. package/dist/src/validation.js +143 -87
  48. package/dist/starter/index.js +8 -11
  49. package/dist/starter/index.js.map +1 -1
  50. package/dist/starter/review-loop.d.ts +0 -13
  51. package/dist/starter/review-loop.js +8 -9
  52. package/dist/subagents/index.js +904 -956
  53. package/dist/subagents/index.js.map +3 -3
  54. package/dist/subagents/src/contracts.d.ts +48 -31
  55. package/dist/subagents/src/contracts.js +20 -9
  56. package/dist/subagents/src/index.d.ts +1 -1
  57. package/dist/subagents/src/index.js +14 -8
  58. package/dist/subagents/src/manager.js +52 -73
  59. package/dist/subagents/src/navigator.js +3 -14
  60. package/dist/subagents/src/view.d.ts +1 -1
  61. package/dist/subagents/src/view.js +15 -68
  62. package/dist/subagents/src/worktree.js +1 -1
  63. package/dist/trajectory/assets/index.html +35 -13
  64. package/dist/trajectory/index.js +576 -932
  65. package/dist/trajectory/index.js.map +3 -3
  66. package/dist/trajectory/src/assets/index.html +35 -13
  67. package/dist/trajectory/src/index.js +134 -104
  68. package/dist/trajectory/src/server.js +268 -27
  69. package/dist/trajectory/src/server.js.map +3 -3
  70. package/dist/trajectory/src/startup-mutex.d.ts +17 -0
  71. package/dist/trajectory/src/startup-mutex.js +172 -0
  72. package/examples/workflow-extension-template/README.md +4 -7
  73. package/examples/workflow-extension-template/extension.test.mjs +4 -8
  74. package/examples/workflow-extension-template/index.js +0 -3
  75. package/package.json +10 -10
  76. package/skills/pi-extensible-workflows/SKILL.md +12 -8
  77. package/src/agent-execution.ts +412 -164
  78. package/src/background-widget.ts +5 -11
  79. package/src/decoders.ts +51 -37
  80. package/src/execution.ts +46 -9
  81. package/src/host-delivery.ts +5 -8
  82. package/src/host-navigator.ts +4 -2
  83. package/src/host-phases.ts +1 -1
  84. package/src/host-recovery.ts +23 -28
  85. package/src/host-runtime.ts +44 -6
  86. package/src/host-view.ts +41 -16
  87. package/src/host.ts +229 -142
  88. package/src/index.ts +1 -2
  89. package/src/paths.ts +18 -9
  90. package/src/persistence.ts +1 -0
  91. package/src/pi-runtime-runner.ts +3 -1
  92. package/src/registry.ts +28 -64
  93. package/src/runtime/agent-runner.ts +2 -0
  94. package/src/script-tools.ts +76 -0
  95. package/src/session-lease.ts +34 -6
  96. package/src/settings.ts +56 -50
  97. package/src/store.ts +18 -2
  98. package/src/trajectory.ts +32 -15
  99. package/src/types.ts +82 -34
  100. package/src/utils.ts +42 -5
  101. package/src/validation.ts +121 -80
  102. package/starter/index.ts +0 -1
  103. package/starter/review-loop.ts +8 -9
  104. package/starter/roles/developer.md +0 -1
  105. package/starter/roles/oracle.md +0 -1
  106. package/starter/roles/researcher.md +0 -1
  107. package/starter/roles/reviewer.md +0 -1
  108. package/starter/roles/scout.md +0 -1
  109. package/subagents/README.md +10 -16
  110. package/subagents/src/contracts.ts +21 -11
  111. package/subagents/src/index.ts +17 -8
  112. package/subagents/src/manager.ts +48 -128
  113. package/subagents/src/navigator.ts +4 -13
  114. package/subagents/src/view.ts +16 -63
  115. package/subagents/src/worktree.ts +1 -1
  116. package/trajectory/src/assets/index.html +35 -13
  117. package/trajectory/src/index.ts +112 -91
  118. package/trajectory/src/server.ts +46 -10
  119. package/trajectory/src/startup-mutex.ts +127 -0
  120. package/dist/src/roles.d.ts +0 -70
  121. package/dist/src/roles.js +0 -325
  122. package/dist/starter/roles/developer.md +0 -6
  123. package/dist/starter/roles/oracle.md +0 -18
  124. package/dist/starter/roles/researcher.md +0 -20
  125. package/dist/starter/roles/reviewer.md +0 -19
  126. package/dist/starter/roles/scout.md +0 -18
  127. package/examples/workflow-extension-template/roles/reviewer.md +0 -4
  128. package/src/roles.ts +0 -326
@@ -1,11 +1,12 @@
1
- import { existsSync, realpathSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { copyFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Type } from "@earendil-works/pi-ai";
7
+ import { Value } from "typebox/value";
7
8
  import { Compile } from "typebox/compile";
8
- import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
9
+ import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultPackageManager, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, SettingsManager, loadSkills, type InlineExtension } from "@earendil-works/pi-coding-agent";
9
10
  import type { ExtensionContext, ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
10
11
  type HerdrModelContext = { readonly model: ExtensionContext["model"]; readonly modelRegistry: ModelRegistry | undefined };
11
12
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
@@ -45,29 +46,30 @@ export interface PiResourceInspection {
45
46
  readonly diagnostics: readonly { type: "warning" | "error" | "collision"; message: string; source?: string }[];
46
47
  readonly systemPromptSource?: string;
47
48
  }
48
- import type { AgentAccounting, AgentActivity, AgentContinuity, AgentIdentity, AgentResourceInspection, AgentResourcePolicy, AgentResourceSelectors, AgentResourceSelectorSources, AgentSetup, AgentSetupSummary, AgentTransport, AgentTransportContext, ContextFileScope, JsonSchema, JsonValue, LiveSessionHandoff, ModelSpec, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowRunContext, WorkflowSessionStartEvent } from "./types.js";
49
- import { SerialLane, assertModelThinking, byPriorityThenName, deepFreeze, errorText, jsonObject, jsonValue, mergeWorkflowExtensionSettings, object, resolveModelReference, resourcePatternHasMagic, unmatchedResourcePatterns } from "./utils.js";
50
- import { SETTLED_AGENT_STATES, WorkflowError, isContextFileScope, zeroAccounting, type AgentDefinition, type AgentState } from "./types.js";
49
+ import type { AgentPreparation, AgentPreparationContext, PreparedAgentConfiguration, RegisteredAgentPreparationHook, AgentAccounting, AgentActivity, AgentContinuity, AgentIdentity, AgentResourceInspection, AgentResourcePolicy, AgentResourceSelectors, AgentResourceSelectorSources, AgentSetup, AgentSetupSummary, AgentTransport, AgentTransportContext, ContextFileScope, JsonSchema, JsonValue, LiveSessionHandoff, ModelSpec, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowRunContext, WorkflowSessionStartEvent } from "./types.js";
50
+ import { SerialLane, assertModelThinking, byPriorityThenName, deepFreeze, errorCode, errorText, jsonObject, jsonValue, object, physicalModel, resolveModelReference, resourcePatternHasMagic, selectResourcesByLayers, modelCapability, unmatchedResourcePatterns } from "./utils.js";
51
+ import { SETTLED_AGENT_STATES, WorkflowError, isContextFileScope, zeroAccounting, type AgentState, type WorkflowErrorCode } from "./types.js";
51
52
  import { createLiveSessionHandoff } from "./session-handoff.js";
52
53
  import { createToolTimingExtension } from "./tool-timing.js";
53
54
  import { isEmptyAbortedAssistant, normalizePiMessage, normalizePiSessionEvent, runtimeProgressToAgentProgress } from "./pi-runtime-adapter.js";
54
55
  import { createPiRuntimeAgentRunner, isRuntimeAgentProviderError, normalizePiRuntimeError } from "./pi-runtime-runner.js";
55
56
  import type { RuntimeAgentProgress, RuntimeUsage } from "./runtime/agent-runner.js";
56
57
  import { defaultWorkflowResultSchema } from "./runtime/workflow-result.js";
57
- import { validateAgentOptions, validateSchema } from "./validation.js";
58
- import { canonicalPath } from "./paths.js";
59
- import { canonicalExtensionSelector, resolveRole } from "./roles.js";
58
+ import { isCoreAgentOption, validateAgentOptions, validateSchema } from "./validation.js";
59
+ import { canonicalPath, extensionIdentity, piResourceContains, piResourcePath, sameFilesystemPath } from "./paths.js";
60
+ import { isCorePackageName } from "./package-identity.js";
61
+ import { canonicalExtensionSelector, validateContextFileScopes, validateSelectorList, validateWorkflowExtensionSettings } from "./settings.js";
62
+ import { agentIdentityPath } from "./execution.js";
60
63
  import type { RunStore } from "./persistence.js";
61
- type AgentExecutionRunStore = Pick<RunStore, "recordSystemPrompt" | "validateWorktree" | "worktree" | "snapshotWorktree">;
64
+ type AgentExecutionRunStore = Pick<RunStore, "recordSystemPrompt" | "validateWorktree" | "worktree" | "snapshotWorktree"> & Partial<Pick<RunStore, "load" | "saveAgentConfiguration">>;
62
65
  const localToolTimingExtension = createToolTimingExtension();
63
- export type { AgentAccounting, AgentActivity, AgentInspectionMode, AgentSetup, AgentSetupContext, AgentSetupHook, AgentTransport, AgentTransportContext, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult } from "./types.js";
66
+ export type { AgentPreparation, AgentPreparationContext, PreparedAgentConfiguration, RegisteredAgentPreparationHook, AgentAccounting, AgentActivity, AgentInspectionMode, AgentSetup, AgentSetupContext, AgentSetupHook, AgentTransport, AgentTransportContext, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult } from "./types.js";
64
67
  export interface AgentBudgetHooks {
65
68
  beforeAttempt(): void;
66
69
  beforeTurn(): void;
67
70
  afterTurn(accounting: AgentAccounting, final: boolean): void;
68
71
  instruction(): string | undefined;
69
72
  }
70
- export type { AgentDefinition } from "./types.js";
71
73
  export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
72
74
  export type AgentProviderRecovery = "retry" | "abort" | { model: string };
73
75
  export interface AgentExecutionOptions {
@@ -86,7 +88,6 @@ export interface AgentExecutionOptions {
86
88
  skills?: readonly string[];
87
89
  extensions?: readonly string[];
88
90
  effectiveTools?: readonly string[];
89
- role?: string;
90
91
  contextFiles?: readonly ContextFileScope[];
91
92
  schema?: JsonSchema;
92
93
  retries?: number;
@@ -100,31 +101,46 @@ export interface AgentExecutionOptions {
100
101
  agentIdentity?: AgentIdentity;
101
102
  inheritedExtensionSettings?: Readonly<WorkflowExtensionSettings>;
102
103
  agentNodeId?: string;
104
+ capabilities?: Readonly<{ tools: readonly string[]; skills: readonly string[]; extensions: readonly string[] }>;
105
+ parentSkills?: readonly string[];
106
+ /** Tool names that ancestors excluded; with this agent's own `excludeTools`, they are removed from root and custom tools alike. */
107
+ inheritedExcludeTools?: readonly string[];
108
+ projectTrusted?: boolean;
109
+ configuration?: PreparedAgentConfiguration;
110
+ onConfiguration?: (configuration: PreparedAgentConfiguration) => void | Promise<void>;
103
111
  }
104
112
  export interface AgentExecutionRoot {
113
+ projectTrusted?: boolean;
105
114
  cwd: string;
106
115
  model: ModelSpec;
107
116
  tools: ReadonlySet<string>;
108
117
  extensionSettings?: Readonly<WorkflowExtensionSettings> | undefined;
109
118
  validateExtensionSettings?: (settings: Readonly<WorkflowExtensionSettings> | undefined, context: WorkflowExtensionSettingsValidatorContext) => void;
110
119
  onAgentSettings?: (agentNodeId: string, settings: Readonly<WorkflowExtensionSettings>) => void;
120
+ onAgentCapabilities?: (agentNodeId: string, capabilities: AgentPreparationContext["capabilities"], projectTrusted: boolean) => void;
111
121
  resourceSelectors?: AgentResourceSelectors;
112
- agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
113
122
  agentDir?: string;
114
123
  additionalSkillPaths?: readonly string[];
115
124
  availableModels?: ReadonlySet<string>;
116
125
  knownModels?: ReadonlySet<string>;
117
126
  modelAliases?: Readonly<Record<string, string>>;
127
+ dynamicModelAliasNames?: readonly string[];
118
128
  blockedAliases?: ReadonlySet<string>;
119
129
  blockedAliasTargets?: Readonly<Record<string, string>>;
120
130
  settingsPath?: string;
121
131
  extensionSettingsPath?: string;
122
132
  runStore?: AgentExecutionRunStore;
123
133
  providerPause?: () => Promise<void>;
134
+ agentPreparationHooks?: readonly RegisteredAgentPreparationHook[];
124
135
  agentSetupHooks?: readonly RegisteredAgentSetupHook[];
125
136
  agentResourcePolicy?: () => AgentResourcePolicy | Promise<AgentResourcePolicy>;
126
137
  runContext?: Readonly<WorkflowRunContext>;
127
138
  onResourceWarning?: (message: string) => void;
139
+ /**
140
+ * Load failures this host tolerated and the identities of the extensions it did load. With failures, a non-core option
141
+ * counts as owned only by the schema of a preparation hook whose `source` is a loaded extension; others fail closed.
142
+ */
143
+ extensionLoad?: Readonly<{ errors: readonly string[]; loaded: readonly string[] }>;
128
144
  }
129
145
  export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
130
146
  export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; state?: WorkflowAgentSessionState; activity?: AgentActivity; lastEventAt?: number; persist: boolean }
@@ -137,20 +153,67 @@ function parseModel(value: string | undefined, fallback: ModelSpec, aliases: Rea
137
153
  return resolveModelReference(value, aliases, knownModels, settingsPath);
138
154
  }
139
155
 
156
+ async function authorizedResources(root: AgentExecutionRoot, cwd: string, options: AgentExecutionOptions): Promise<{ tools: string[]; skills: string[]; extensions: string[] }> {
157
+ const agentDir = root.agentDir ?? getAgentDir();
158
+ const trusted = root.projectTrusted ?? false;
159
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
160
+ settingsManager.setProjectTrusted(trusted);
161
+ const resolved = await new DefaultPackageManager({ cwd, agentDir, settingsManager, builtinExtensions: ["codemode", "tool-search", "mcp"] }).resolve();
162
+ const extensions = resolved.extensions.filter(({ enabled, metadata }) => enabled && (trusted || metadata.scope !== "project")).map(({ path }) => extensionIdentity(path));
163
+ const skillPaths = resolved.skills.filter(({ enabled, metadata }) => enabled && (trusted || metadata.scope !== "project")).map(({ path }) => path);
164
+ const skills = loadSkills({ cwd, agentDir, skillPaths: [...skillPaths, ...(root.additionalSkillPaths ?? [])], includeDefaults: false }).skills.map(({ name }) => name);
165
+ const parent = options.capabilities;
166
+ return { tools: [...root.tools].filter((tool) => options.effectiveTools === undefined || options.effectiveTools.includes(tool)).filter((tool) => parent === undefined || parent.tools.includes(tool)), skills: [...new Set(skills)].filter((name) => parent === undefined || parent.skills.includes(name)), extensions: [...new Set(extensions)].filter((path) => parent === undefined || parent.extensions.includes(path)) };
167
+ }
168
+ function validatePreparedConfiguration(root: AgentExecutionRoot, configuration: PreparedAgentConfiguration, capabilities: { tools: readonly string[]; skills: readonly string[]; extensions: readonly string[] }, cwd: string, resumed: boolean): PreparedAgentConfiguration {
169
+ const code = resumed ? "RESUME_INCOMPATIBLE" : "INVALID_METADATA";
170
+ if (!jsonObject(configuration) || typeof configuration.projectTrusted !== "boolean" || typeof configuration.systemPromptAppend !== "string" || configuration.systemPrompt !== undefined && typeof configuration.systemPrompt !== "string") throw new WorkflowError(code, "Invalid prepared agent configuration");
171
+ if (configuration.projectTrusted && !root.projectTrusted) throw new WorkflowError("RESUME_INCOMPATIBLE", "Prepared agent configuration lost project trust");
172
+ if (!object(configuration.model) || typeof configuration.model.provider !== "string" || typeof configuration.model.model !== "string") throw new WorkflowError(code, "Invalid prepared model");
173
+ const model = resolveModelReference(`${configuration.model.provider}/${configuration.model.model}${configuration.model.thinking === undefined ? "" : `:${configuration.model.thinking}`}`);
174
+ if (root.availableModels !== undefined && !root.availableModels.has(modelCapability(model))) throw new WorkflowError(resumed ? "RESUME_INCOMPATIBLE" : "UNKNOWN_MODEL", `Required model is unavailable: ${modelCapability(model)}`);
175
+ const tools = validateSelectorList(configuration.tools, "configuration", "tools", "INVALID_METADATA", false);
176
+ if (tools === undefined) throw new WorkflowError(code, "Prepared tools are missing");
177
+ const missing = tools.find((tool) => !capabilities.tools.includes(tool));
178
+ if (missing) throw new WorkflowError(resumed ? "RESUME_INCOMPATIBLE" : "UNKNOWN_TOOL", `Required tool is unavailable: ${missing}`);
179
+ const excludeTools = configuration.excludeTools;
180
+ try { validateAgentOptions({ excludeTools }); } catch { throw new WorkflowError(code, "Invalid prepared excludeTools"); }
181
+ if (!Array.isArray(excludeTools) || tools.some((tool) => excludeTools.includes(tool))) throw new WorkflowError(code, "Prepared excludeTools contradict the prepared tools");
182
+ validateSelectorList(configuration.skills, "configuration", "skills", "INVALID_METADATA", false);
183
+ validateSelectorList(configuration.extensions, "configuration", "extensions", "INVALID_METADATA", false);
184
+ if (resumed) for (const key of ["skills", "extensions"] as const) {
185
+ const selectors = configuration[key];
186
+ const selected = selectors.slice(selectors.lastIndexOf("!*") + 1);
187
+ const missing = selected.find((name) => !capabilities[key].includes(name));
188
+ if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required ${key} capability is unavailable: ${missing}`);
189
+ }
190
+ validateContextFileScopes(configuration.contextFiles, "configuration");
191
+ validateWorkflowExtensionSettings(configuration.settings, "configuration", "INVALID_METADATA");
192
+ root.validateExtensionSettings?.(configuration.settings, { source: "effective", cwd, projectTrusted: root.projectTrusted ?? false });
193
+ return deepFreeze(structuredClone(configuration));
194
+ }
195
+
140
196
  function accounting(stats: WorkflowAgentSessionStats): AgentAccounting {
141
197
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
142
198
  }
143
199
 
144
- const extensionDirectory = dirname(fileURLToPath(import.meta.url));
145
- const workflowPackageRoot = basename(dirname(extensionDirectory)) === "dist" ? resolve(extensionDirectory, "../..") : resolve(extensionDirectory, "..");
146
- const WORKFLOW_HOST_ENTRIES = new Set([
147
- canonicalPath(resolve(workflowPackageRoot, "src/index.ts")),
148
- canonicalPath(resolve(workflowPackageRoot, "dist/src/index.js")),
149
- canonicalPath(resolve(workflowPackageRoot, "starter/index.ts")),
150
- canonicalPath(resolve(workflowPackageRoot, "dist/starter/index.js")),
151
- canonicalPath(resolve(workflowPackageRoot, "subagents/index.ts")),
152
- canonicalPath(resolve(workflowPackageRoot, "dist/subagents/index.js")),
153
- ]);
200
+ const WORKFLOW_HOST_ENTRY_PATHS = ["src/index.ts", "dist/src/index.js", "starter/index.ts", "dist/starter/index.js", "subagents/index.ts", "dist/subagents/index.js"];
201
+ function isWorkflowHostEntry(path: string): boolean {
202
+ const entryPath = canonicalPath(path);
203
+ let packageRoot = dirname(entryPath);
204
+ while (packageRoot !== dirname(packageRoot)) {
205
+ const packageMetadataPath = join(packageRoot, "package.json");
206
+ if (existsSync(packageMetadataPath)) {
207
+ try {
208
+ const metadata: unknown = JSON.parse(readFileSync(packageMetadataPath, "utf8"));
209
+ if (typeof metadata !== "object" || metadata === null || !("name" in metadata) || !isCorePackageName(metadata.name)) return false;
210
+ return WORKFLOW_HOST_ENTRY_PATHS.some((entry) => canonicalPath(join(packageRoot, entry)) === entryPath);
211
+ } catch { return false; }
212
+ }
213
+ packageRoot = dirname(packageRoot);
214
+ }
215
+ return false;
216
+ }
154
217
  const WORKFLOW_DIRECTORY = "pi-extensible-workflows";
155
218
  function workflowSystemPromptPath(cwd: string, agentDir: string, projectTrusted: boolean): string | undefined {
156
219
  const projectPath = join(cwd, ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md");
@@ -184,24 +247,29 @@ function loadPiPackageDirectory(): Promise<PiPackageDirectoryResolution> {
184
247
  }
185
248
  }).catch((error: unknown) => ({ error: `could not load @earendil-works/pi-coding-agent package metadata: ${errorText(error)}` }));
186
249
  }
187
- let nativePromptTemplateModule: Promise<NativePromptTemplateModule> | undefined;
188
- async function loadNativePromptTemplateModule(): Promise<NativePromptTemplateModule> {
250
+ const nativeCoreModules = new Map<string, Promise<unknown>>();
251
+ /** Pi does not export these core modules, so inspection loads them from the installed package. */
252
+ async function loadNativeCoreModule(file: string, label: string): Promise<unknown> {
189
253
  const packageDirectory = await loadPiPackageDirectory();
190
- let promptTemplatePath: string | undefined;
254
+ let modulePath: string | undefined;
191
255
  if (packageDirectory.directory) {
192
- const candidate = resolve(packageDirectory.directory, "dist/core/prompt-templates.js");
193
- if (existsSync(candidate)) promptTemplatePath = candidate;
256
+ const candidate = resolve(packageDirectory.directory, "dist/core", file);
257
+ if (existsSync(candidate)) modulePath = candidate;
194
258
  }
195
- promptTemplatePath ??= resolve(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "core/prompt-templates.js");
259
+ modulePath ??= resolve(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "core", file);
196
260
  const packageDirectoryError = packageDirectory.error ? ` Package directory lookup failed: ${packageDirectory.error}` : "";
197
- return nativePromptTemplateModule ??= import(promptTemplatePath).then((module) => module as NativePromptTemplateModule).catch((error: unknown) => { throw new Error(`Could not load Pi prompt templates.${packageDirectoryError}`, { cause: error }); });
261
+ let loaded = nativeCoreModules.get(file);
262
+ if (!loaded) { loaded = import(modulePath).catch((error: unknown) => { throw new Error(`Could not load Pi ${label}.${packageDirectoryError}`, { cause: error }); }); nativeCoreModules.set(file, loaded); }
263
+ return loaded;
198
264
  }
199
265
  async function expandPromptTemplateForInspection(text: string, templates: readonly { name: string; content: string }[]): Promise<string> {
200
- return (await loadNativePromptTemplateModule()).expandPromptTemplate(text, templates);
266
+ return (await loadNativeCoreModule("prompt-templates.js", "prompt templates") as NativePromptTemplateModule).expandPromptTemplate(text, templates);
267
+ }
268
+ async function buildSystemPromptForInspection(options: unknown): Promise<string> {
269
+ return (await loadNativeCoreModule("system-prompt.js", "system prompt builder") as { buildSystemPrompt(options: unknown): string }).buildSystemPrompt(options);
201
270
  }
202
271
  type NativePromptSession = PiSession & {
203
- readonly _extensionRunner?: { hasHandlers(event: string): boolean; emitInput(text: string, images: undefined, source: "interactive"): Promise<unknown>; emitBeforeAgentStart(prompt: string, images: undefined, systemPrompt: string, options: unknown): Promise<unknown>; onError(listener: (error: unknown) => void): () => void };
204
- readonly _baseSystemPrompt?: string;
272
+ readonly _extensionRunner?: { hasHandlers(event: string): boolean; emitInput(text: string, images: undefined, source: "interactive"): Promise<unknown>; emitBeforeAgentStart(prompt: string, images: undefined, options: unknown): Promise<unknown>; onError(listener: (error: unknown) => void): () => void };
205
273
  readonly _baseSystemPromptOptions?: unknown;
206
274
  readonly _expandSkillCommand?: (text: string) => string;
207
275
  readonly promptTemplates?: readonly { name: string; content: string }[];
@@ -210,12 +278,11 @@ async function preparePiPrompt(native: PiSession, text: string): Promise<PiPromp
210
278
  const session = native as NativePromptSession;
211
279
  const diagnostics: Array<{ type: "error"; message: string; source?: string }> = [];
212
280
  const runner = session._extensionRunner;
213
- const baseSystemPrompt = typeof session._baseSystemPrompt === "string" ? session._baseSystemPrompt : session.systemPrompt ?? "";
281
+ const baseSystemPrompt = session.systemPrompt ?? "";
214
282
  const baseOptions = session._baseSystemPromptOptions;
215
283
  const expandSkillCommand = session._expandSkillCommand;
216
284
  const templates = session.promptTemplates;
217
285
  if (!runner) diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: extension runner is missing", source: "Pi session" });
218
- if (typeof session._baseSystemPrompt !== "string") diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: base system prompt is missing", source: "Pi session" });
219
286
  if (baseOptions === undefined) diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: base system prompt options are missing", source: "Pi session" });
220
287
  if (typeof expandSkillCommand !== "function") diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: skill expansion is missing", source: "Pi session" });
221
288
  if (!Array.isArray(templates)) diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: prompt templates are missing", source: "Pi session" });
@@ -237,9 +304,11 @@ async function preparePiPrompt(native: PiSession, text: string): Promise<PiPromp
237
304
  try { expandedPrompt = await expandPromptTemplateForInspection(skillExpanded, Array.isArray(templates) ? templates : []); }
238
305
  catch (error) { diagnostics.push({ type: "error", message: `Pi prompt template expansion is unavailable: ${errorText(error)}`, source: "Pi session" }); }
239
306
  }
240
- const result = !inputHandled && runner && baseOptions !== undefined ? await runner.emitBeforeAgentStart(expandedPrompt, undefined, baseSystemPrompt, baseOptions) : undefined;
241
- const prepared = result as { messages?: readonly unknown[]; systemPrompt?: unknown } | undefined;
242
- return { prompt: text, expandedPrompt, systemPrompt: typeof prepared?.systemPrompt === "string" ? prepared.systemPrompt : baseSystemPrompt, systemPromptOptions: baseOptions, messages: prepared?.messages ?? [], inputHandled, diagnostics };
307
+ const result = !inputHandled && runner && baseOptions !== undefined ? await runner.emitBeforeAgentStart(expandedPrompt, undefined, baseOptions) : undefined;
308
+ const prepared = result as { messages?: readonly unknown[]; systemPromptOptions?: unknown } | undefined;
309
+ const systemPromptOptions = prepared?.systemPromptOptions ?? baseOptions;
310
+ const systemPrompt = prepared?.systemPromptOptions === undefined ? baseSystemPrompt : await buildSystemPromptForInspection(prepared.systemPromptOptions);
311
+ return { prompt: text, expandedPrompt, systemPrompt, systemPromptOptions, messages: prepared?.messages ?? [], inputHandled, diagnostics };
243
312
  } finally { unsubscribe?.(); }
244
313
  }
245
314
 
@@ -281,6 +350,57 @@ export function flushExtensionProviders(resourceLoader: DefaultResourceLoader, m
281
350
  return failures;
282
351
  }
283
352
 
353
+ /**
354
+ * The CLI adds Pi's built-in extensions itself; SDK sessions only get them from these public
355
+ * factories (docs/sdk.md). As `builtin` entries they obey `-builtin:<name>` settings, and like the
356
+ * CLI's they are replaceable by an extension that registers the same tools or commands.
357
+ * NOTE: Pi does not export the built-in llama.cpp extension, so workflow agents cannot use models it
358
+ * provides. Declare the server as a provider in models.json instead.
359
+ */
360
+ function builtinExtensions(): InlineExtension[] {
361
+ return [
362
+ { name: "codemode", factory: createCodemodeExtension(), replaceable: true, builtin: true },
363
+ { name: "tool-search", factory: createToolSearchExtension(), replaceable: true, builtin: true },
364
+ { name: "mcp", factory: createMcpExtension(), replaceable: true, builtin: true },
365
+ ];
366
+ }
367
+ const BUILTIN_EXTENSION_PREFIX = "builtin:";
368
+ function isLlamaModel(model: ModelSpec): boolean { return model.provider === "llama.cpp"; }
369
+ /**
370
+ * Pi only extends resources with paths that loaded extensions return from resources_discover. Recording them, normalized as
371
+ * Pi does, lets selection tell those skills from the static inventory. Like Pi's own source attribution, the record belongs
372
+ * to one resource generation: reload() starts a new one, so withdrawn contributions stop counting as discovered.
373
+ */
374
+ class WorkflowResourceLoader extends DefaultResourceLoader {
375
+ readonly extensionSkillPaths: string[] = [];
376
+ constructor(private readonly loaderCwd: string, options: ConstructorParameters<typeof DefaultResourceLoader>[0]) { super(options); }
377
+ override async reload(...args: Parameters<DefaultResourceLoader["reload"]>): Promise<void> {
378
+ this.extensionSkillPaths.length = 0;
379
+ await super.reload(...args);
380
+ }
381
+ override extendResources(paths: Parameters<DefaultResourceLoader["extendResources"]>[0]): void {
382
+ this.extensionSkillPaths.push(...(paths.skillPaths ?? []).map(({ path }) => piResourcePath(path, this.loaderCwd)));
383
+ super.extendResources(paths);
384
+ }
385
+ }
386
+ /**
387
+ * Prepared selectors end with a frozen segment: `!*` followed by the static inventory. Extension-discovered skills keep every
388
+ * other selector, including negations setup hooks inserted anywhere (a terminal `!*` too), but not that frozen restriction.
389
+ * Setup can only insert negations, so the inventory names are matched from the right and the boundary is the nearest `!*`
390
+ * before them; any `!*` with only negations up to the inventory selects the same skills.
391
+ */
392
+ function discoveredSkillSelectors(selectors: readonly string[] | undefined, inventory: readonly string[] | undefined): readonly string[] | undefined {
393
+ if (selectors === undefined || inventory === undefined) return selectors;
394
+ const frozen = new Set<number>();
395
+ let index = selectors.length;
396
+ for (const name of [...inventory].reverse().concat("!*")) {
397
+ do index -= 1; while (index >= 0 && selectors[index] !== name);
398
+ if (index < 0) return selectors;
399
+ frozen.add(index);
400
+ }
401
+ return selectors.filter((_selector, position) => !frozen.has(position));
402
+ }
403
+
284
404
  async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent?: WorkflowSessionStartEvent): Promise<LocalPiSessionHandle> {
285
405
  const agentDir = input.agentDir ?? getAgentDir();
286
406
  const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
@@ -293,33 +413,34 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
293
413
  const tools = [...new Set([...input.tools, ...customTools.map(({ name }) => name)])];
294
414
  let settingsManager: SettingsManager;
295
415
  let resourceLoader: DefaultResourceLoader;
416
+ const builtins = builtinExtensions();
417
+ const builtinNames = builtins.flatMap((extension) => typeof extension === "function" ? [] : [extension.name]);
418
+ const extensionFactories = [...builtins, ...(input.extensionFactories ?? [])];
296
419
  const policy = input.resourcePolicy;
297
420
  if (policy) {
298
421
  settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: false });
299
422
  settingsManager.setProjectTrusted(policy.projectTrusted);
300
- const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
423
+ const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager, builtinExtensions: builtinNames });
301
424
  const resolved = await packageManager.resolve();
302
- const discoveredExtensions = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalPath(path)))];
425
+ const discoveredExtensions = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => extensionIdentity(path)))];
303
426
  const selectorSources = policy.selectorSources;
304
- const selection = resolveRole(undefined, {
305
- cwd: input.cwd,
306
- selectorSources,
307
- resources: { extensions: discoveredExtensions },
308
- });
309
- const selectedExtensions = selection.selectedExtensions ?? [];
310
- const extensionPaths = selectedExtensions.filter((path) => !WORKFLOW_HOST_ENTRIES.has(path));
427
+ const selectedExtensions = selectResourcesByLayers(resourceSelectorLayers(selectorSources, "extensions"), discoveredExtensions).filter((path) => policy.capabilities === undefined || policy.capabilities.extensions.includes(path));
428
+ const extensionPaths = selectedExtensions.filter((path) => path.startsWith(BUILTIN_EXTENSION_PREFIX) || !isWorkflowHostEntry(path));
311
429
  policy.selectedExtensions = selectedExtensions;
312
- policy.unmatchedExtensions = selection.unmatchedExtensions ?? [];
430
+ policy.unmatchedExtensions = unmatchedResourcePatterns(policy.effective.extensions, discoveredExtensions);
313
431
  const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
314
- const updateSkillMatches = (skills: readonly { name: string }[]): Set<string> => {
432
+ const updateSkillMatches = (skills: readonly { name: string; filePath: string }[], extensionSkillPaths: readonly string[]): Set<string> => {
315
433
  const names = [...new Set(skills.map(({ name }) => name))];
316
- const selected = resolveRole(undefined, { cwd: input.cwd, selectorSources, resources: { skills: names } });
317
- const selectedSkills = selected.selectedSkills ?? [];
434
+ const discovered = new Set(skills.filter(({ filePath }) => extensionSkillPaths.some((path) => piResourceContains(path, filePath))).map(({ name }) => name));
435
+ const [global, project, call] = resourceSelectorLayers(selectorSources, "skills");
436
+ const selectedStatic = selectResourcesByLayers([global, project, call], names.filter((name) => !discovered.has(name))).filter((name) => policy.capabilities === undefined || policy.capabilities.skills.includes(name));
437
+ const selectedDiscovered = selectResourcesByLayers([discoveredSkillSelectors(global, policy.capabilities?.skills), project, call], [...discovered]).filter((name) => policy.parentSkills === undefined || policy.parentSkills.includes(name));
438
+ const selectedSkills = [...selectedStatic, ...selectedDiscovered];
318
439
  policy.selectedSkills = selectedSkills;
319
- policy.unmatchedSkills = selected.unmatchedSkills ?? [];
440
+ policy.unmatchedSkills = unmatchedResourcePatterns(policy.effective.skills, names);
320
441
  return new Set(names.filter((name) => !selectedSkills.includes(name)));
321
442
  };
322
- resourceLoader = new DefaultResourceLoader({
443
+ const workflowLoader: WorkflowResourceLoader = new WorkflowResourceLoader(input.cwd, {
323
444
  cwd: input.cwd,
324
445
  agentDir,
325
446
  settingsManager,
@@ -327,22 +448,23 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
327
448
  additionalExtensionPaths: extensionPaths,
328
449
  noSkills: true,
329
450
  additionalSkillPaths: [...new Set([...skillPaths, ...(input.additionalSkillPaths ?? [])])],
330
- ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
451
+ extensionFactories,
331
452
  ...(contextFilesOverride ? { agentsFilesOverride: contextFilesOverride } : {}),
332
453
  skillsOverride: (base) => {
333
- const disabledSkills = updateSkillMatches(base.skills);
454
+ const disabledSkills = updateSkillMatches(base.skills, workflowLoader.extensionSkillPaths);
334
455
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
335
456
  },
336
457
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
337
458
  ...systemPromptOptions,
338
459
  });
460
+ resourceLoader = workflowLoader;
339
461
  await resourceLoader.reload();
340
462
  } else {
341
463
  settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: true });
342
- const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
464
+ const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager, builtinExtensions: builtinNames });
343
465
  const resolved = await packageManager.resolve();
344
- const extensionPaths = [...new Set(resolved.extensions.filter(({ enabled }) => enabled).map(({ path }) => canonicalPath(path)).filter((path) => !WORKFLOW_HOST_ENTRIES.has(path)))];
345
- resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, settingsManager, noExtensions: true, additionalExtensionPaths: extensionPaths, ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: [...input.additionalSkillPaths] } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(contextFilesOverride ? { agentsFilesOverride: contextFilesOverride } : {}), ...systemPromptOptions, ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
466
+ const extensionPaths = [...new Set(resolved.extensions.filter(({ enabled }) => enabled).map(({ path }) => extensionIdentity(path)).filter((path) => path.startsWith(BUILTIN_EXTENSION_PREFIX) || !isWorkflowHostEntry(path)))];
467
+ resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, settingsManager, noExtensions: true, additionalExtensionPaths: extensionPaths, ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: [...input.additionalSkillPaths] } : {}), extensionFactories, ...(contextFilesOverride ? { agentsFilesOverride: contextFilesOverride } : {}), ...systemPromptOptions, ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
346
468
  await resourceLoader.reload();
347
469
  }
348
470
  const providerFailures = flushExtensionProviders(resourceLoader, modelRuntime);
@@ -353,11 +475,11 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
353
475
  // missing, so the failure travels with the complaint rather than being
354
476
  // swallowed — otherwise a misconfigured gateway reads as a typo in a model
355
477
  // name.
356
- const because = providerFailures.length > 0 ? ` (provider registration failed — ${providerFailures.join("; ")})` : "";
478
+ const because = providerFailures.length > 0 ? ` (provider registration failed — ${providerFailures.join("; ")})` : isLlamaModel(input.model) ? " (Pi does not expose its built-in llama.cpp provider to extension sessions; declare the llama.cpp server as a provider in models.json and use that model instead)" : "";
357
479
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}${because}`);
358
480
  }
359
481
  const effectiveSessionStartEvent = input.settings === undefined && sessionStartEvent === undefined ? undefined : { ...(sessionStartEvent ?? { type: "session_start" as const, reason: "startup" as const }), settings: input.settings ?? Object.freeze({}) };
360
- const { session } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, settingsManager, ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), resourceLoader, ...(effectiveSessionStartEvent ? { sessionStartEvent: effectiveSessionStartEvent } : {}), sessionManager: manager });
482
+ const { session } = await createAgentSession({ cwd: input.cwd, agentDir, modelRuntime, model, settingsManager, ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), resourceLoader, ...(effectiveSessionStartEvent ? { sessionStartEvent: effectiveSessionStartEvent } : {}), sessionManager: manager });
361
483
  const nativeDispose = session.dispose.bind(session);
362
484
  let disposal: Promise<void> | undefined;
363
485
  const shutdown = (reason: LocalSessionShutdownReason, targetSessionFile?: string): Promise<void> => {
@@ -378,13 +500,13 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
378
500
  await shutdown("quit").catch(() => undefined);
379
501
  throw error;
380
502
  }
381
- const resourcePaths = { extensions: resourceLoader.getExtensions().extensions.filter(({ path }) => !path.startsWith("<")).map(({ resolvedPath }) => canonicalPath(resolvedPath)), skills: resourceLoader.getSkills().skills.map(({ filePath }) => canonicalPath(filePath)) };
503
+ const resourcePaths = { extensions: resourceLoader.getExtensions().extensions.filter(({ path }) => !path.startsWith("<")).map(({ resolvedPath }) => extensionIdentity(resolvedPath)), skills: resourceLoader.getSkills().skills.map(({ filePath }) => canonicalPath(filePath)) };
382
504
  const resourceInspection = (): PiResourceInspection => {
383
505
  const extensions = resourceLoader.getExtensions();
384
506
  const skills = resourceLoader.getSkills();
385
507
  const diagnostics = [...extensions.errors.map(({ path, error }) => ({ type: "error" as const, message: error, source: path })), ...skills.diagnostics.map(({ type, path, message }) => ({ type, message, ...(path ? { source: path } : {}) }))];
386
508
  const systemSource = systemPromptSource;
387
- return { extensions: resourceLoader.getExtensions().extensions.filter(({ path }) => !path.startsWith("<")).map(({ resolvedPath }) => canonicalPath(resolvedPath)), skills: skills.skills.map(({ name }) => name), diagnostics, ...(systemSource ? { systemPromptSource: systemSource } : resourceLoader.getSystemPrompt() !== undefined ? { systemPromptSource: "Pi resource loader" } : {}) };
509
+ return { extensions: resourceLoader.getExtensions().extensions.filter(({ path }) => !path.startsWith("<")).map(({ resolvedPath }) => extensionIdentity(resolvedPath)), skills: skills.skills.map(({ name }) => name), diagnostics, ...(systemSource ? { systemPromptSource: systemSource } : resourceLoader.getSystemPrompt() !== undefined ? { systemPromptSource: "Pi resource loader" } : {}) };
388
510
  };
389
511
  const managedSession = Object.assign(session, {
390
512
  getLeafId: () => manager.getLeafId(),
@@ -637,6 +759,11 @@ export async function createLocalWorkflowAgentSession(prepared: Readonly<Prepare
637
759
  return session;
638
760
  }
639
761
  export const localAgentTransport: AgentTransport = Object.freeze({ id: "local", createSession: createLocalWorkflowAgentSession });
762
+ /** Like Pi's excludeTools, exclusions name tools exactly and apply to every tool the session would get, custom tools included. */
763
+ function excludedToolNames(options: Pick<AgentExecutionOptions, "agentOptions" | "inheritedExcludeTools">): Set<string> {
764
+ const own = options.agentOptions?.excludeTools;
765
+ return new Set([...(options.inheritedExcludeTools ?? []), ...(Array.isArray(own) ? own.filter((tool): tool is string => typeof tool === "string") : [])]);
766
+ }
640
767
  function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
641
768
  interface ChildAgentToolParams {
642
769
  prompt: string;
@@ -645,7 +772,6 @@ interface ChildAgentToolParams {
645
772
  skills?: string[];
646
773
  extensions?: string[];
647
774
  model?: string;
648
- role?: string;
649
775
  contextFiles?: ContextFileScope[];
650
776
  outputSchema?: unknown;
651
777
  retries?: number;
@@ -658,7 +784,6 @@ function isChildAgentToolParams(value: unknown): value is ChildAgentToolParams &
658
784
  if (value.skills !== undefined && (!Array.isArray(value.skills) || value.skills.some((skill) => typeof skill !== "string"))) return false;
659
785
  if (value.extensions !== undefined && (!Array.isArray(value.extensions) || value.extensions.some((extension) => typeof extension !== "string"))) return false;
660
786
  if (value.model !== undefined && typeof value.model !== "string") return false;
661
- if (value.role !== undefined && (typeof value.role !== "string" || !value.role.trim())) return false;
662
787
  if (value.contextFiles !== undefined && (!Array.isArray(value.contextFiles) || !value.contextFiles.every(isContextFileScope))) return false;
663
788
  if (value.outputSchema !== undefined && !jsonObject(value.outputSchema)) return false;
664
789
  if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0)) return false;
@@ -681,7 +806,7 @@ function resourcePolicySummary(policy: AgentResourcePolicy, tools: readonly stri
681
806
  const RESOURCE_SELECTOR_KEYS = ["skills", "extensions", "tools"] as const;
682
807
  type ResourceSelectorKey = (typeof RESOURCE_SELECTOR_KEYS)[number];
683
808
  function resourceSelectorLayers(sources: AgentResourceSelectorSources, key: ResourceSelectorKey): readonly (readonly string[] | undefined)[] {
684
- return [sources.global[key], sources.project[key], sources.role?.[key], sources.call?.[key]];
809
+ return [sources.global[key], sources.project[key], sources.call?.[key]];
685
810
  }
686
811
  function selectorListWidened(ceiling: readonly string[], candidate: readonly string[]): boolean {
687
812
  let candidateIndex = 0;
@@ -738,7 +863,7 @@ function appendResourcePolicyNarrowing(ceiling: AgentResourcePolicy | undefined,
738
863
  };
739
864
  }
740
865
  function packageRoot(start: string): string | undefined {
741
- let current = dirname(realpathSync(start));
866
+ let current = dirname(canonicalPath(start));
742
867
  for (;;) {
743
868
  const candidates = [current, join(current, "..", "@earendil-works", "pi-coding-agent"), join(current, "..", "pi-coding-agent")];
744
869
  for (const candidate of candidates) {
@@ -785,10 +910,9 @@ async function resolvePiRuntime(): Promise<PiRuntimeResolution> {
785
910
  }
786
911
  }
787
912
  const piRuntimeResolution = resolvePiRuntime();
788
- function workflowAgentPrompt(task: string, options: AgentExecutionOptions, attempt: number, previousError?: string): string {
789
- const basePrompt = [`Workflow: ${options.workflowName}`, `Agent: ${options.label}`, options.phase ? `Phase: ${options.phase}` : "", options.parent ? `Parent: ${options.parent}` : "", "You own this task and any direct child agents you create. Return child results to your parent; do not leave descendants running.", options.schema ? "Call workflow_result exactly once with the final result matching its schema. Do not return prose as the final result." : "Call workflow_result exactly once with { result: \"final response\" } to submit the final response. Do not return prose as the final result.", attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? previousError ?? "failed attempt"}` : ""].filter(Boolean).join("\n");
790
- const instruction = options.budget?.instruction();
791
- return `${basePrompt}\n\nTask:\n${task}${instruction ? `\n\n${instruction}` : ""}`;
913
+ /** Transient per-attempt control text. It is queued as a separate steered message after the task, so the system prompt stays stable and the task message stays exact. */
914
+ function attemptControlText(options: AgentExecutionOptions, attempt: number, previousError?: string): string {
915
+ return [attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? previousError ?? "failed attempt"}` : "", options.budget?.instruction() ?? ""].filter(Boolean).join("\n");
792
916
  }
793
917
  async function preparedAgentSession(input: SessionInput, initialPrompt?: string): Promise<Readonly<PreparedAgentSession>> {
794
918
  const { runtime: piRuntime, error: piRuntimeError } = await piRuntimeResolution;
@@ -812,45 +936,26 @@ async function attemptSessionInput(source: string, attempt: number): Promise<str
812
936
  await copyFile(source, target);
813
937
  return target;
814
938
  }
815
- async function prepareAgentSetup(root: AgentExecutionRoot, transport: AgentTransport, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string; contextFiles?: readonly ContextFileScope[] }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, inspection = false, previousError?: string): Promise<PreparedAgentSetup> {
939
+ async function prepareAgentSetup(root: AgentExecutionRoot, transport: AgentTransport, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string; contextFiles?: readonly ContextFileScope[] }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, inspection = false): Promise<PreparedAgentSetup> {
816
940
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
817
941
  const baselineOptions = structuredClone(options.agentOptions ?? {});
818
942
  const baseResourcePolicy = await root.agentResourcePolicy?.();
819
- const roleName = typeof options.role === "string" ? options.role : undefined;
820
- const roleDefinition = roleName ? root.agentDefinitions?.[roleName] : undefined;
821
- const roleSelectors: AgentResourceSelectors | undefined = roleDefinition ? {
822
- ...(roleDefinition.skills === undefined ? {} : { skills: roleDefinition.skills }),
823
- ...(roleDefinition.extensions === undefined ? {} : { extensions: roleDefinition.extensions.map((selector) => canonicalExtensionSelector(selector, cwd)) }),
824
- ...(roleDefinition.tools === undefined ? {} : { tools: roleDefinition.tools }),
825
- } : undefined;
826
- const selectorValue = (key: "skills" | "extensions" | "tools", fallback: readonly string[] | undefined): readonly string[] | undefined => {
827
- const value = baselineOptions[key];
828
- if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;
829
- return options.agentOptions === undefined ? fallback : undefined;
943
+ const configuration = options.configuration;
944
+ const resourcePolicy: AgentResourcePolicy = {
945
+ ...(baseResourcePolicy ?? { globalSettingsPath: "", projectSettingsPath: "", projectTrusted: root.projectTrusted ?? false, global: { skills: [], extensions: [] }, project: { skills: [], extensions: [] }, unmatchedSkills: [], unmatchedExtensions: [] }),
946
+ projectTrusted: configuration?.projectTrusted ?? root.projectTrusted ?? baseResourcePolicy?.projectTrusted ?? false,
947
+ effective: { skills: configuration?.skills ?? [], extensions: configuration?.extensions ?? [], tools: resolved.tools },
948
+ selectorSources: { global: { skills: configuration?.skills ?? [], extensions: configuration?.extensions ?? [], tools: resolved.tools }, project: {} },
949
+ ...(options.capabilities ? { capabilities: options.capabilities } : {}),
950
+ ...(options.parentSkills ? { parentSkills: [...options.parentSkills] } : {}),
951
+ selectedTools: resolved.tools,
830
952
  };
831
- const rawCallSkills = selectorValue("skills", options.skills);
832
- const rawCallExtensions = selectorValue("extensions", options.extensions)?.map((selector) => canonicalExtensionSelector(selector, cwd));
833
- const rawCallTools = selectorValue("tools", options.tools);
834
- const callSelectors: AgentResourceSelectors = { ...(rawCallSkills === undefined ? {} : { skills: rawCallSkills }), ...(rawCallExtensions === undefined ? {} : { extensions: rawCallExtensions }), ...(rawCallTools === undefined ? {} : { tools: rawCallTools }) };
835
- const resourcePolicy: AgentResourcePolicy | undefined = baseResourcePolicy ? {
836
- ...baseResourcePolicy,
837
- effective: {
838
- skills: [...baseResourcePolicy.effective.skills, ...(roleSelectors?.skills ?? []), ...(rawCallSkills ?? [])],
839
- extensions: [...baseResourcePolicy.effective.extensions, ...(roleSelectors?.extensions ?? []), ...(rawCallExtensions ?? [])],
840
- ...(baseResourcePolicy.effective.tools === undefined && roleSelectors?.tools === undefined && rawCallTools === undefined ? {} : { tools: [...(baseResourcePolicy.effective.tools ?? []), ...(roleSelectors?.tools ?? []), ...(rawCallTools ?? [])] }),
841
- },
842
- selectorSources: { global: baseResourcePolicy.selectorSources.global, project: baseResourcePolicy.selectorSources.project, ...(roleSelectors ? { role: roleSelectors } : {}), ...(Object.keys(callSelectors).length ? { call: callSelectors } : {}) },
843
- } : undefined;
844
- if (resourcePolicy) {
845
- resourcePolicy.selectedTools = resolved.tools;
846
- resourcePolicy.unmatchedTools = unmatchedResourcePatterns(resourceSelectorLayers(resourcePolicy.selectorSources, "tools").flatMap((layer) => layer ?? []), [...root.tools]);
847
- }
848
- const resourcePolicyCeiling = resourcePolicy ? structuredClone(resourcePolicy) : undefined;
953
+ const resourcePolicyCeiling = structuredClone(resourcePolicy);
849
954
  const sessionPath = options.sessionPath === undefined || inspection ? options.sessionPath : await attemptSessionInput(options.sessionPath, attempt);
850
- const extensionSettings = mergeWorkflowExtensionSettings(options.inheritedExtensionSettings ?? root.extensionSettings, roleDefinition?.extensionSettings) ?? Object.freeze({});
851
- root.validateExtensionSettings?.(extensionSettings, { source: roleName === undefined ? "effective" : "role", cwd, projectTrusted: resourcePolicy?.projectTrusted ?? true, ...((root.extensionSettingsPath ?? root.settingsPath) ? { settingsPath: root.extensionSettingsPath ?? root.settingsPath } : {}), ...(roleName === undefined ? {} : { role: roleName }) });
955
+ const extensionSettings = configuration?.settings ?? options.inheritedExtensionSettings ?? root.extensionSettings ?? Object.freeze({});
956
+ root.validateExtensionSettings?.(extensionSettings, { source: "effective", cwd, projectTrusted: resourcePolicy.projectTrusted, ...((root.extensionSettingsPath ?? root.settingsPath) ? { settingsPath: root.extensionSettingsPath ?? root.settingsPath } : {}) });
852
957
  if (options.agentNodeId !== undefined) root.onAgentSettings?.(options.agentNodeId, extensionSettings);
853
- const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(sessionPath ? { sessionPath } : {}), ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(resolved.contextFiles === undefined ? {} : { contextFiles: [...resolved.contextFiles] }), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), settings: extensionSettings, options: structuredClone(baselineOptions) };
958
+ const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(sessionPath ? { sessionPath } : {}), ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(resolved.contextFiles === undefined ? {} : { contextFiles: [...resolved.contextFiles] }), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, resourcePolicy, settings: extensionSettings, options: structuredClone(baselineOptions) };
854
959
  const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, prepared: await preparedAgentSession(sessionInput, task), transport };
855
960
  const base = fallbackSetupContext(root, options, setupSignal);
856
961
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal, settings: extensionSettings, ...(base.tuiIndex === undefined ? {} : { tuiIndex: base.tuiIndex }), ...(base.tuiLabel === undefined ? {} : { tuiLabel: base.tuiLabel }), ...(inspection ? { mode: "inspection" as const } : {}) });
@@ -868,25 +973,55 @@ async function prepareAgentSetup(root: AgentExecutionRoot, transport: AgentTrans
868
973
  try {
869
974
  if (resourcePolicyWidened(resourcePolicyCeiling, setup.sessionInput.resourcePolicy)) throw new WorkflowError("INVALID_METADATA", "Agent setup widened the prepared resource policy");
870
975
  appendResourcePolicyNarrowing(resourcePolicyCeiling, setup.sessionInput.resourcePolicy);
976
+ if (setup.sessionInput.resourcePolicy) {
977
+ const policy = setup.sessionInput.resourcePolicy;
978
+ policy.effective.extensions = policy.effective.extensions.map((selector) => canonicalExtensionSelector(selector, cwd));
979
+ for (const layer of [policy.selectorSources.global, policy.selectorSources.project, policy.selectorSources.call]) if (layer?.extensions !== undefined) layer.extensions = layer.extensions.map((selector) => canonicalExtensionSelector(selector, cwd));
980
+ if (resourcePolicyCeiling.parentSkills === undefined) Reflect.deleteProperty(policy, "parentSkills");
981
+ else policy.parentSkills = [...resourcePolicyCeiling.parentSkills];
982
+ }
871
983
  setup.sessionInput.options = setup.options;
872
984
  if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string") setup.sessionInput.model = parseModel(setup.options.model, setup.sessionInput.model, root.modelAliases, root.knownModels ?? root.availableModels, root.settingsPath);
873
985
  if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string")) setup.sessionInput.tools = [...setup.options.tools];
874
- if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
986
+ // Pi derives trust, settings and .pi resources from cwd, so neither the session input nor a cwd option may move it.
987
+ if (changedOption(setup.options, baselineOptions, "cwd") || !sameFilesystemPath(cwd, setup.sessionInput.cwd)) throw new WorkflowError("INVALID_METADATA", "Agent setup cannot change cwd");
988
+ if (!root.availableModels?.has(modelCapability(setup.sessionInput.model)) && root.availableModels !== undefined) throw new WorkflowError("UNKNOWN_MODEL", "Agent setup selected an unavailable model");
989
+ // A custom tool must not reintroduce, by name, a root tool the prepared policy left out (excludeTools, parent ceilings, selectors).
990
+ const shadowed = (setup.sessionInput.customTools ?? []).map(({ name }) => name).find((name) => root.tools.has(name) && !resolved.tools.includes(name));
991
+ if (shadowed) throw new WorkflowError("UNKNOWN_TOOL", `Custom tool reintroduces a tool outside the prepared agent policy: ${shadowed}`);
992
+ // Like Pi's excludeTools, own and inherited exclusions also remove custom tools of those names, including ones setup hooks
993
+ // add, before materialization. A name that matches no root or custom tool excludes nothing and only warns. The names are
994
+ // the frozen configuration's, so re-evaluated options of a later turn, retry or resume cannot change them.
995
+ const excludedTools = new Set(configuration?.excludeTools ?? excludedToolNames(options));
996
+ const excludedCustomTools = (setup.sessionInput.customTools ?? []).filter(({ name }) => excludedTools.has(name)).map(({ name }) => name);
997
+ if (excludedCustomTools.length) {
998
+ setup.sessionInput.customTools = setup.sessionInput.customTools?.filter(({ name }) => !excludedTools.has(name)) ?? [];
999
+ // Hooks may also list their custom tool by name; root names stay subject to the policy checks below.
1000
+ setup.sessionInput.tools = setup.sessionInput.tools.filter((tool) => root.tools.has(tool) || !excludedCustomTools.includes(tool));
1001
+ }
1002
+ for (const tool of excludedTools) if (!options.inheritedExcludeTools?.includes(tool) && !root.tools.has(tool) && !excludedCustomTools.includes(tool)) root.onResourceWarning?.(`excludeTools names no root or custom tool of this agent: ${tool}`);
875
1003
  const customToolNames = new Set([...(setup.sessionInput.customTools ?? []).map(({ name }) => name), ...(setup.sessionInput.resultTool ? [setup.sessionInput.resultTool.name] : [])]);
876
1004
  const widened = setup.sessionInput.tools.find((tool) => !resolved.tools.includes(tool) && !customToolNames.has(tool));
877
1005
  const outsideTool = widened ?? setup.sessionInput.tools.find((tool) => !root.tools.has(tool) && !customToolNames.has(tool));
878
1006
  if (outsideTool) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${outsideTool}`);
1007
+ if (setup.sessionInput.resourcePolicy) {
1008
+ const policy = setup.sessionInput.resourcePolicy;
1009
+ setup.sessionInput.tools = selectResourcesByLayers(resourceSelectorLayers(policy.selectorSources, "tools"), setup.sessionInput.tools);
1010
+ if (policy.projectTrusted !== resourcePolicyCeiling.projectTrusted) policy.capabilities = await authorizedResources({ ...root, projectTrusted: policy.projectTrusted }, cwd, options);
1011
+ }
1012
+ if (!setup.sessionInput.tools.includes("agent")) setup.sessionInput.customTools = setup.sessionInput.customTools?.filter(({ name }) => name !== "agent" && name !== "get_subagent_result" && name !== "steer_subagent") ?? [];
1013
+
879
1014
  } catch (error) {
880
1015
  setup.prepared = await preparedAgentSession(setup.sessionInput, task);
881
1016
  return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error } };
882
1017
  }
883
- setup.prepared = await preparedAgentSession(setup.sessionInput, inspection ? task : workflowAgentPrompt(setup.prompt, options, attempt, previousError));
1018
+ setup.prepared = await preparedAgentSession(setup.sessionInput, inspection ? task : setup.prompt);
884
1019
  return { setup, summary: agentSetupSummary(setup, hookNames) };
885
1020
  }
886
1021
  export async function prepareAgentSetupForInspection(root: AgentExecutionRoot, task: string, options: AgentExecutionOptions, transport: AgentTransport): Promise<PreparedAgentSetup> {
887
1022
  const executor = new WorkflowAgentExecutor(root);
888
- const resolved = executor.resolve(options);
889
- return prepareAgentSetup(root, transport, task, options, resolved, root.cwd, 1, root.runContext?.signal, [], undefined, true);
1023
+ const configuration = await executor.prepare(options, root.cwd, root.runContext?.signal, "inspection");
1024
+ return prepareAgentSetup(root, transport, task, { ...options, label: configuration.label ?? options.label, configuration, capabilities: { tools: configuration.tools, skills: configuration.skills.slice(configuration.skills.lastIndexOf("!*") + 1), extensions: configuration.extensions.slice(configuration.extensions.lastIndexOf("!*") + 1) } }, configuration, root.cwd, 1, root.runContext?.signal, [], undefined, true);
890
1025
  }
891
1026
  function attemptRecord(transport: string, attempt: number, session: WorkflowAgentSession, setup: AgentSetupSummary, stats: AgentAccounting, result?: JsonValue, error?: { code: string; message: string }): AgentAttempt {
892
1027
  return { attempt, transport, session: session.reference, ...(result === undefined ? {} : { result }), ...(error ? { error } : {}), accounting: stats, setup };
@@ -903,52 +1038,89 @@ export function getAgentAttempts(error: unknown): readonly AgentAttempt[] | unde
903
1038
  }
904
1039
  export class WorkflowAgentExecutor {
905
1040
  private readonly transport: AgentTransport;
906
- private readonly warnedRoleTools = new Set<string>();
907
1041
  constructor(private readonly root: AgentExecutionRoot, transport: AgentTransport = localAgentTransport) { this.transport = transport; }
908
1042
  setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
909
1043
 
910
1044
  resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string; contextFiles?: readonly ContextFileScope[] } {
911
- const roleName = typeof options.role === "string" ? options.role : undefined;
912
- const roleDefinition = roleName ? this.root.agentDefinitions?.[roleName] : undefined;
913
- if (roleName && !roleDefinition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${roleName}`);
914
- if (inheritedTools === undefined) for (const pattern of roleDefinition?.tools ?? []) {
915
- const body = pattern.startsWith("!") ? pattern.slice(1) : pattern;
916
- if (!pattern.startsWith("!") && !resourcePatternHasMagic(pattern) && !this.root.tools.has(body)) {
917
- const key = `${roleName ?? "agent"}:${body}`;
918
- if (!this.warnedRoleTools.has(key)) { this.warnedRoleTools.add(key); this.root.onResourceWarning?.(`Tool not available in this session for role ${roleName ?? "agent"}: ${body}. The agent runs without it.`); }
919
- }
1045
+ if (options.configuration) return { ...options.configuration, ...(options.modelOverride ? { model: options.modelOverride } : {}) };
1046
+ const ceiling = inheritedTools ?? [...this.root.tools];
1047
+ for (const pattern of options.tools ?? []) if (!pattern.startsWith("!") && !resourcePatternHasMagic(pattern) && !ceiling.includes(pattern)) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${pattern}`);
1048
+ const tools = options.effectiveTools ?? selectResourcesByLayers([this.root.resourceSelectors?.tools, options.tools], [...(inheritedTools ?? this.root.tools)]);
1049
+ const missing = tools.find((tool) => !ceiling.includes(tool));
1050
+ if (missing) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${missing}`);
1051
+ const model = options.modelOverride ?? parseModel(options.model, this.root.model, this.root.modelAliases, this.root.knownModels, this.root.settingsPath);
1052
+ return { model: physicalModel(model, this.root.modelAliases, this.root.knownModels, this.root.settingsPath), tools, systemPromptAppend: "", ...(options.contextFiles === undefined ? {} : { contextFiles: options.contextFiles }) };
1053
+ }
1054
+
1055
+ async prepare(options: AgentExecutionOptions, cwd = this.root.cwd, signal = this.root.runContext?.signal ?? new AbortController().signal, mode: "execution" | "inspection" = "execution"): Promise<PreparedAgentConfiguration> {
1056
+ const root = { ...this.root, projectTrusted: Boolean(this.root.projectTrusted) && options.projectTrusted !== false };
1057
+ const policy = await this.root.agentResourcePolicy?.();
1058
+ const capabilities = await authorizedResources(root, cwd, options);
1059
+ // Children are new spawns of their parent's attempt; their scheduler node keys the configuration their own retries reuse.
1060
+ const persistedKey = options.parent !== undefined ? options.agentNodeId : options.agentIdentity?.handle !== undefined ? `handle:${options.agentIdentity.handle}` : options.agentIdentity ? agentIdentityPath(options.agentIdentity) : options.agentNodeId;
1061
+ const stored = options.configuration ?? (mode === "execution" && persistedKey && this.root.runStore?.load ? (await this.root.runStore.load()).snapshot.agentConfigurations[persistedKey] : undefined);
1062
+ if (stored) {
1063
+ // Without the field, the configuration was prepared before exclusions were frozen. Re-evaluated options cannot prove
1064
+ // which exclusions it had, so it never resumes.
1065
+ if (stored.excludeTools === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Prepared agent configuration predates frozen excludeTools; relaunch the workflow");
1066
+ return validatePreparedConfiguration(root, stored, capabilities, cwd, true);
920
1067
  }
921
- const resolved = resolveRole(roleName, {
922
- cwd: this.root.cwd,
923
- ...(roleDefinition === undefined ? {} : { definition: roleDefinition }),
924
- ...(this.root.agentDefinitions === undefined ? {} : { definitions: this.root.agentDefinitions }),
925
- ...(this.root.agentDir === undefined ? {} : { agentDir: this.root.agentDir }),
926
- selectorSources: { global: this.root.resourceSelectors ?? {}, project: {} },
927
- ...(this.root.modelAliases === undefined ? {} : { modelAliases: this.root.modelAliases }),
928
- ...(this.root.knownModels === undefined ? {} : { knownModels: this.root.knownModels }),
929
- ...(this.root.availableModels === undefined ? {} : { availableModels: this.root.availableModels }),
930
- ...(this.root.blockedAliases === undefined ? {} : { blockedAliases: this.root.blockedAliases }),
931
- ...(this.root.blockedAliasTargets === undefined ? {} : { blockedAliasTargets: this.root.blockedAliasTargets }),
932
- ...(this.root.settingsPath === undefined ? {} : { settingsPath: this.root.settingsPath }),
933
- rootModel: this.root.model,
934
- rootTools: this.root.tools,
935
- ...(inheritedTools === undefined ? {} : { inheritedTools }),
936
- ...(options.model === undefined ? {} : { model: options.model }),
937
- ...(options.modelOverride === undefined ? {} : { modelOverride: options.modelOverride }),
938
- ...(options.tools === undefined ? {} : { tools: options.tools }),
939
- ...(options.skills === undefined ? {} : { skills: options.skills }),
940
- ...(options.extensions === undefined ? {} : { extensions: options.extensions }),
941
- ...(options.contextFiles === undefined ? {} : { contextFiles: options.contextFiles }),
942
- ...(options.effectiveTools === undefined ? {} : { effectiveTools: options.effectiveTools }),
943
- });
944
- return { model: resolved.model ?? this.root.model, ...(resolved.requestedModel === undefined ? {} : { requestedModel: resolved.requestedModel }), tools: resolved.tools ?? [], ...(resolved.overrideSystemPrompt ? { systemPrompt: resolved.prompt } : {}), systemPromptAppend: resolved.overrideSystemPrompt ? "" : resolved.prompt, ...(resolved.contextFiles === undefined ? {} : { contextFiles: resolved.contextFiles }) };
1068
+ const original = deepFreeze(structuredClone(validateAgentOptions(options.agentOptions ?? {
1069
+ ...(options.model === undefined ? {} : { model: options.model }), ...(options.tools === undefined ? {} : { tools: [...options.tools] }), ...(options.skills === undefined ? {} : { skills: [...options.skills] }), ...(options.extensions === undefined ? {} : { extensions: [...options.extensions] }), ...(options.contextFiles === undefined ? {} : { contextFiles: [...options.contextFiles] }),
1070
+ })));
1071
+ const dynamicModelAliasNames = this.root.dynamicModelAliasNames;
1072
+ const defaults = deepFreeze({ model: { ...this.root.model }, modelAliases: { ...this.root.modelAliases }, ...(dynamicModelAliasNames === undefined ? {} : { dynamicModelAliasNames: [...dynamicModelAliasNames] }), selectorSources: structuredClone(policy?.selectorSources ?? { global: this.root.resourceSelectors ?? {}, project: {} }), settings: structuredClone(options.inheritedExtensionSettings ?? this.root.extensionSettings ?? {}) });
1073
+ const context: AgentPreparationContext = Object.freeze({ options: original, cwd, projectCwd: this.root.cwd, agentDir: this.root.agentDir ?? getAgentDir(), projectTrusted: root.projectTrusted, defaults, capabilities: deepFreeze(structuredClone(capabilities)), knownModels: new Set(this.root.knownModels), availableModels: new Set(this.root.availableModels), signal, mode });
1074
+ const configuration: AgentPreparation = {
1075
+ ...(typeof original.label === "string" ? { label: original.label } : {}), ...(typeof original.model === "string" ? { model: original.model } : {}),
1076
+ tools: [...(policy?.effective.tools ?? this.root.resourceSelectors?.tools ?? []), ...(options.tools ?? [])], skills: [...(policy?.effective.skills ?? this.root.resourceSelectors?.skills ?? []), ...(options.skills ?? [])], extensions: [...(policy?.effective.extensions ?? this.root.resourceSelectors?.extensions ?? []), ...(options.extensions ?? []).map((selector) => canonicalExtensionSelector(selector, cwd))],
1077
+ ...(options.contextFiles === undefined ? {} : { contextFiles: [...options.contextFiles] }), systemPromptAppend: "", settings: structuredClone(defaults.settings),
1078
+ };
1079
+ for (const key of ["tools", "skills", "extensions"] as const) if (Array.isArray(original[key])) {
1080
+ const selectors = validateSelectorList(original[key], "agent options", key, "INVALID_METADATA", false) ?? [];
1081
+ configuration[key] = [...(policy?.effective[key] ?? this.root.resourceSelectors?.[key] ?? []), ...selectors.map((selector) => key === "extensions" ? canonicalExtensionSelector(selector, cwd) : selector)];
1082
+ }
1083
+ if (original.contextFiles !== undefined) configuration.contextFiles = [...(validateContextFileScopes(original.contextFiles, "agent options") ?? [])];
1084
+ const hooks = [...(this.root.agentPreparationHooks ?? [])].sort(byPriorityThenName);
1085
+ const load = this.root.extensionLoad;
1086
+ if (load?.errors.length) {
1087
+ // A failed extension may have owned an option, and its factory may have registered hooks before it failed. Only a
1088
+ // schema whose registration names a loaded extension as `source` proves ownership.
1089
+ const loaded = new Set(load.loaded.map(extensionIdentity));
1090
+ const declared = new Set(hooks.flatMap(({ optionsSchema, source }) => source !== undefined && loaded.has(extensionIdentity(source)) && object(optionsSchema?.properties) ? Object.keys(optionsSchema.properties) : []));
1091
+ const unowned = Object.keys(original).find((key) => !isCoreAgentOption(key) && !declared.has(key));
1092
+ if (unowned !== undefined) throw new WorkflowError("INVALID_METADATA", `Agent option ${unowned} has no owner proven to have loaded while extensions failed to load: ${load.errors.join("; ")}`);
1093
+ }
1094
+ for (const hook of hooks) if (hook.optionsSchema !== undefined && !Value.Check(hook.optionsSchema, original)) throw new WorkflowError("INVALID_METADATA", `Agent options do not match preparation schema: ${hook.name}`);
1095
+ if (typeof original.model === "string" && this.root.blockedAliases?.has(original.model.split(":")[0] ?? "")) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${original.model} resolved to ${this.root.blockedAliasTargets?.[original.model.split(":")[0] ?? ""] ?? "an unavailable target"}`);
1096
+ for (const hook of hooks) { if (signal.aborted) throw new WorkflowError("CANCELLED", "Agent preparation cancelled"); await hook.prepare(configuration, context); }
1097
+ if (signal.aborted) throw new WorkflowError("CANCELLED", "Agent preparation cancelled");
1098
+ const { model: preparedModel, ...preparedOptions } = configuration;
1099
+ validateAgentOptions(preparedOptions);
1100
+ if (preparedModel !== undefined && (typeof preparedModel !== "string" || !preparedModel.trim())) throw new WorkflowError("INVALID_METADATA", "Prepared agent model must be a non-empty string");
1101
+ if (preparedModel && this.root.blockedAliases?.has(preparedModel.split(":")[0] ?? "")) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${preparedModel} resolved to ${this.root.blockedAliasTargets?.[preparedModel.split(":")[0] ?? ""] ?? "an unavailable target"}`);
1102
+ configuration.extensions = configuration.extensions.map((selector) => canonicalExtensionSelector(selector, cwd));
1103
+ // A hook that passes on an inherited virtual `workflow/<alias>` root model gets the same physical target as no hook.
1104
+ const model = physicalModel(preparedModel === undefined ? this.root.model : resolveModelReference(preparedModel, this.root.modelAliases, this.root.knownModels, this.root.settingsPath), this.root.modelAliases, this.root.knownModels, this.root.settingsPath);
1105
+ const excludedTools = excludedToolNames({ agentOptions: original, ...(options.inheritedExcludeTools ? { inheritedExcludeTools: options.inheritedExcludeTools } : {}) });
1106
+ const tools = selectResourcesByLayers([configuration.tools], capabilities.tools).filter((tool) => !excludedTools.has(tool));
1107
+ for (const pattern of validateSelectorList(original.tools, "agent options", "tools", "INVALID_METADATA", false) ?? []) if (!pattern.startsWith("!") && !resourcePatternHasMagic(pattern) && !capabilities.tools.includes(pattern)) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${pattern}`);
1108
+ for (const pattern of unmatchedResourcePatterns(configuration.tools, capabilities.tools)) this.root.onResourceWarning?.(`Tool selector currently matches no authorized tool: ${pattern}`);
1109
+ const skills = selectResourcesByLayers([configuration.skills], capabilities.skills);
1110
+ const extensions = selectResourcesByLayers([configuration.extensions], capabilities.extensions);
1111
+ const concrete = validatePreparedConfiguration(root, { ...configuration, model, tools, excludeTools: [...excludedTools], skills: [...configuration.skills, "!*", ...skills], extensions: [...configuration.extensions, "!*", ...extensions], projectTrusted: context.projectTrusted }, capabilities, cwd, false);
1112
+ if (mode === "execution") {
1113
+ if (persistedKey && this.root.runStore?.saveAgentConfiguration) await this.root.runStore.saveAgentConfiguration(persistedKey, concrete);
1114
+ await options.onConfiguration?.(concrete);
1115
+ }
1116
+ return concrete;
945
1117
  }
946
1118
 
947
1119
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
948
1120
  const executionSignal = signal ?? this.root.runContext?.signal;
949
1121
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
950
1122
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
951
- let resolved = this.resolve(options);
1123
+ let resolved: ReturnType<WorkflowAgentExecutor["resolve"]>;
952
1124
  let recoveryModel: ModelSpec | undefined;
953
1125
  let cwd: string;
954
1126
  if (options.parent) {
@@ -970,6 +1142,14 @@ export class WorkflowAgentExecutor {
970
1142
  cwd = this.root.cwd;
971
1143
  }
972
1144
 
1145
+ const configuration = await this.prepare(options, cwd, executionSignal);
1146
+ const parentSkills = options.parent === undefined ? undefined : options.capabilities?.skills;
1147
+ options = { ...options, label: configuration.label ?? options.label, configuration, ...(parentSkills ? { parentSkills } : {}), capabilities: { tools: configuration.tools, skills: configuration.skills.slice(configuration.skills.lastIndexOf("!*") + 1), extensions: configuration.extensions.slice(configuration.extensions.lastIndexOf("!*") + 1) } };
1148
+ resolved = this.resolve(options);
1149
+ if (!resolved.tools.includes("agent")) customTools = customTools.filter(({ name }) => name !== "agent" && name !== "get_subagent_result" && name !== "steer_subagent");
1150
+ // A child's skill ceiling is what its parent session has loaded when it spawns, so withdrawn skills never stay in it.
1151
+ let publishLoadedSkills: (() => void) | undefined;
1152
+ customTools = customTools.map((tool) => tool.name !== "agent" ? tool : { ...tool, execute: (...args: Parameters<typeof tool.execute>) => { publishLoadedSkills?.(); return tool.execute(...args); } } as ToolDefinition);
973
1153
  const attempts: AgentAttempt[] = [];
974
1154
  let maxAttempts = (options.retries ?? 0) + 1;
975
1155
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
@@ -996,7 +1176,7 @@ export class WorkflowAgentExecutor {
996
1176
  const resultSchema = Compile(options.schema ?? defaultWorkflowResultSchema);
997
1177
  let resultAccepted = false;
998
1178
  const resultTool = defineTool({
999
- name: "workflow_result", label: "Workflow Result", description: "Submit the terminal workflow result", parameters: resultSchema.Type(),
1179
+ name: "workflow_result", label: "Workflow Result", description: options.schema ? "Submit the terminal workflow result. Call exactly once to finish the current task or message, with a value matching the required schema. Do not return prose as the final result." : "Submit the terminal workflow result. Call exactly once to finish the current task or message, with { result: \"final response\" }. Do not return prose as the final result.", parameters: resultSchema.Type(),
1000
1180
  async execute(_id: string, value: unknown) {
1001
1181
  if (!resultSchema.Check(value) || !jsonValue(value)) return { content: [{ type: "text" as const, text: "Result does not match the required schema." }], details: {}, isError: true };
1002
1182
  if (resultAccepted) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
@@ -1028,11 +1208,23 @@ export class WorkflowAgentExecutor {
1028
1208
 
1029
1209
  try {
1030
1210
  setupFailed = true;
1031
- const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool, false, attempts.at(-1)?.error?.message);
1211
+ const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool, false);
1032
1212
  setup = prepared.setup;
1033
1213
  const attemptSetup = prepared.setup;
1034
1214
  setupSummary = prepared.summary;
1035
1215
  if (prepared.failure) throw prepared.failure.error;
1216
+ let attemptCapabilities: AgentPreparationContext["capabilities"] | undefined;
1217
+ if (options.agentNodeId !== undefined) {
1218
+ const policy = setup.prepared.resourcePolicy;
1219
+ const sources = policy?.selectorSources;
1220
+ const capabilities = policy?.capabilities ?? options.capabilities;
1221
+ attemptCapabilities = {
1222
+ tools: setup.prepared.tools,
1223
+ skills: selectResourcesByLayers(sources ? resourceSelectorLayers(sources, "skills") : [], capabilities?.skills ?? []),
1224
+ extensions: selectResourcesByLayers(sources ? resourceSelectorLayers(sources, "extensions") : [], capabilities?.extensions ?? []),
1225
+ };
1226
+ this.root.onAgentCapabilities?.(options.agentNodeId, attemptCapabilities, policy?.projectTrusted ?? false);
1227
+ }
1036
1228
  setupFailed = false;
1037
1229
  if (attemptSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
1038
1230
  await options.onAttempt?.({ attempt, transport: attemptSetup.transport.id, accounting: zeroAccounting(), setup: setupSummary });
@@ -1067,6 +1259,13 @@ export class WorkflowAgentExecutor {
1067
1259
  } catch { /* Frozen prepared policies still receive the inspection overlay below. */ }
1068
1260
  }
1069
1261
  if (policy) setupSummary = { ...setupSummary, resourceSelectors: { ...resourcePolicySummary(policy, attemptSetup.sessionInput.tools), ...(inspection ? { skills: [...inspection.skills], extensions: [...inspection.extensions] } : {}) } };
1262
+ // The session's currently loaded skills, within its authorization, are the skill ceiling its children inherit.
1263
+ const nodeId = options.agentNodeId, selected = attemptCapabilities, live = session;
1264
+ publishLoadedSkills = nodeId === undefined || selected === undefined || live?.getResourceInspection === undefined ? undefined : () => {
1265
+ const loaded = live.getResourceInspection?.();
1266
+ if (loaded) this.root.onAgentCapabilities?.(nodeId, { ...selected, skills: [...loaded.skills] }, attemptSetup.prepared.resourcePolicy?.projectTrusted ?? false);
1267
+ };
1268
+ publishLoadedSkills?.();
1070
1269
  },
1071
1270
  onBeforeComplete: async () => {
1072
1271
  if (options.worktreeOwner) await runStore?.snapshotWorktree(options.worktreeOwner);
@@ -1093,6 +1292,7 @@ export class WorkflowAgentExecutor {
1093
1292
  const runtimeRun = { id: transportBase.run.runId, namespaceId: transportBase.run.sessionId, workflowName: transportBase.run.workflow.name };
1094
1293
  const providerRecovery = options.providerErrorRecovery;
1095
1294
  const runtimeAgent = { id: transportBase.identity.callSite, structuralPath: [...transportBase.identity.structuralPath], ...(options.parent ? { parentId: options.parent } : {}) };
1295
+ const attemptControl = attemptControlText(options, attempt, attempts.at(-1)?.error?.message);
1096
1296
  const runtimeRequest = {
1097
1297
  task: setup.prepared.initialPrompt ?? setup.prompt,
1098
1298
  cwd: setup.prepared.cwd,
@@ -1101,6 +1301,7 @@ export class WorkflowAgentExecutor {
1101
1301
  // Pi sessions execute the prepared native definitions; the executor does not duplicate them as neutral request tools.
1102
1302
  customTools: [],
1103
1303
  ...(options.schema ? { resultSchema: options.schema } : {}),
1304
+ ...(attemptControl ? { turnControl: attemptControl } : {}),
1104
1305
  run: runtimeRun,
1105
1306
  agent: runtimeAgent,
1106
1307
  signal: attemptSignal,
@@ -1167,7 +1368,6 @@ export interface ScheduledAgentOptions {
1167
1368
  extensions?: readonly string[];
1168
1369
  worktreeOwner?: string;
1169
1370
  model?: string;
1170
- role?: string;
1171
1371
  contextFiles?: readonly ContextFileScope[];
1172
1372
  schema?: JsonSchema;
1173
1373
  retries?: number;
@@ -1177,6 +1377,10 @@ export interface ScheduledAgentOptions {
1177
1377
  agentOptions?: Readonly<Record<string, JsonValue>>;
1178
1378
  agentIdentity?: AgentIdentity;
1179
1379
  extensionSettings?: Readonly<WorkflowExtensionSettings>;
1380
+ capabilities?: Readonly<{ tools: readonly string[]; skills: readonly string[]; extensions: readonly string[] }>;
1381
+ projectTrusted?: boolean;
1382
+ configuration?: PreparedAgentConfiguration;
1383
+ inheritedExcludeTools?: readonly string[];
1180
1384
  }
1181
1385
 
1182
1386
  export type ScheduledAgentResult =
@@ -1214,13 +1418,41 @@ type ScheduledNode = {
1214
1418
  resolveCompletion: () => void;
1215
1419
  task: () => Promise<void>;
1216
1420
  restored: boolean;
1421
+ //NOTE: attempt narrowing is ephemeral; the persisted logical configuration remains unchanged.
1422
+ attemptCapabilities?: AgentPreparationContext["capabilities"];
1423
+ attemptProjectTrusted?: boolean;
1217
1424
  steer?: (message: string) => void | Promise<void>;
1425
+ /** Children spawned so far per label; numbers the occurrence of each child's own identity. */
1426
+ childOccurrences?: Map<string, number>;
1218
1427
  };
1219
1428
 
1220
1429
  type ScheduledRun = { limit: number; beforeLaunch?: () => void; extensionSettings?: Readonly<WorkflowExtensionSettings> | undefined; logical: number; active: number; nextIndex: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
1221
1430
  export type OwnershipRecord = { id: string; parentId?: string; prompt?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
1222
1431
  type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
1223
1432
 
1433
+ /**
1434
+ * A child spawned by its parent's `agent` tool shares the parent's structural scope, breadcrumb and worktree, but owns its
1435
+ * call site under the parent's identity and spawn generation, with a per-label occurrence. It never carries the parent's
1436
+ * handle or turn. Children are new spawns: a retried or resumed parent spawns them again under new identities.
1437
+ */
1438
+ function childIdentity(parent: ScheduledNode, scope: AgentIdentity, label: string): AgentIdentity {
1439
+ const occurrences = parent.childOccurrences ?? new Map<string, number>();
1440
+ parent.childOccurrences = occurrences;
1441
+ const occurrence = (occurrences.get(label) ?? 0) + 1;
1442
+ occurrences.set(label, occurrence);
1443
+ // The parent's scheduler node number marks the spawn generation: a cold-resumed or retried parent runs as a new node.
1444
+ const generation = parent.id.slice(parent.id.lastIndexOf(":") + 1);
1445
+ return { structuralPath: [...scope.structuralPath], callSite: `child:${agentIdentityPath(scope)}/spawn:${generation}/${label}`, occurrence, ...(scope.parentBreadcrumb ? { parentBreadcrumb: scope.parentBreadcrumb } : {}), ...(scope.worktreeOwner ? { worktreeOwner: scope.worktreeOwner } : {}) };
1446
+ }
1447
+ /**
1448
+ * Foreign errors keep descriptive core codes such as UNKNOWN_MODEL. Cancellation and budget exhaustion drive node and run
1449
+ * state, so a foreign error may claim CANCELLED only for an aborted node and never BUDGET_EXHAUSTED.
1450
+ */
1451
+ function scheduledErrorCode(error: unknown, aborted: boolean): WorkflowErrorCode {
1452
+ if (error instanceof WorkflowError) return error.code;
1453
+ const code = errorCode(error);
1454
+ return code === undefined || code === "BUDGET_EXHAUSTED" || (code === "CANCELLED" && !aborted) ? "AGENT_FAILED" : code;
1455
+ }
1224
1456
  export class FairAgentScheduler {
1225
1457
  readonly #runs = new Map<string, ScheduledRun>();
1226
1458
  readonly #nodes = new Map<string, ScheduledNode>();
@@ -1253,6 +1485,17 @@ export class FairAgentScheduler {
1253
1485
  if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
1254
1486
  run.extensionSettings = settings === undefined ? undefined : deepFreeze(structuredClone(settings));
1255
1487
  }
1488
+ async setConfiguration(agentId: string, configuration: PreparedAgentConfiguration): Promise<void> {
1489
+ const node = this.#node(agentId);
1490
+ node.options = Object.freeze({ ...node.options, configuration: deepFreeze(structuredClone(configuration)), tools: Object.freeze([...configuration.tools]) });
1491
+ this.#persist(node.runId);
1492
+ await this.flush(node.runId);
1493
+ }
1494
+ setAttemptCapabilities(agentId: string, capabilities: AgentPreparationContext["capabilities"], projectTrusted: boolean): void {
1495
+ const node = this.#node(agentId);
1496
+ node.attemptCapabilities = deepFreeze(structuredClone(capabilities));
1497
+ node.attemptProjectTrusted = projectTrusted;
1498
+ }
1256
1499
  setExtensionSettings(agentId: string, settings: Readonly<WorkflowExtensionSettings>): void {
1257
1500
  const node = this.#node(agentId);
1258
1501
  node.options = Object.freeze({ ...node.options, extensionSettings: deepFreeze(structuredClone(settings)) });
@@ -1263,7 +1506,7 @@ export class FairAgentScheduler {
1263
1506
  const run = this.#runs.get(runId);
1264
1507
  if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
1265
1508
  const parent = parentId ? this.#nodes.get(parentId) : undefined;
1266
- if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
1509
+ if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT", "Parent agent is not owned by this run");
1267
1510
  const effective = this.#inherit(run, parent, options);
1268
1511
  const id = `${runId}:${String(++this.#nextId)}`;
1269
1512
  const tuiIndex = ++run.nextIndex;
@@ -1277,11 +1520,10 @@ export class FairAgentScheduler {
1277
1520
  node.state = "running";
1278
1521
  this.#persist(runId);
1279
1522
  try {
1280
- const value = await this.runner({ id, runId, tuiIndex, ...(parentId ? { parentId } : {}), prompt, options: effective, signal: node.controller.signal, setSteer: (handler) => { node.steer = handler; } });
1523
+ const value = await this.runner({ id, runId, tuiIndex, ...(parentId ? { parentId } : {}), prompt, options: node.options, signal: node.controller.signal, setSteer: (handler) => { node.steer = handler; } });
1281
1524
  this.#settle(node, { id, ok: true, value });
1282
1525
  } catch (error) {
1283
- const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", errorText(error));
1284
- this.#settle(node, { id, ok: false, error: { code: typed.code, message: typed.message } });
1526
+ this.#settle(node, { id, ok: false, error: { code: scheduledErrorCode(error, node.controller.signal.aborted), message: errorText(error) } });
1285
1527
  }
1286
1528
  };
1287
1529
  this.#nodes.set(id, node);
@@ -1294,7 +1536,7 @@ export class FairAgentScheduler {
1294
1536
  async result(parentId: string, childId: string): Promise<ScheduledAgentResult> {
1295
1537
  const parent = this.#node(parentId);
1296
1538
  const child = this.#node(childId);
1297
- if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Results are scoped to direct children");
1539
+ if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT", "Results are scoped to direct children");
1298
1540
  if (child.collected || !child.promise) throw new WorkflowError("AGENT_RESULT_COLLECTED", "Child result has already been collected; nested results are one-shot");
1299
1541
  if (child.collecting) throw new WorkflowError("AGENT_FAILED", "Child result is already being collected");
1300
1542
  child.collecting = true;
@@ -1328,7 +1570,7 @@ export class FairAgentScheduler {
1328
1570
 
1329
1571
  async steer(parentId: string, childId: string, message: string): Promise<void> {
1330
1572
  const child = this.#node(childId);
1331
- if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Steering is scoped to direct children");
1573
+ if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT", "Steering is scoped to direct children");
1332
1574
  if (child.state !== "running" && child.state !== "waiting_for_child") throw new WorkflowError("AGENT_FAILED", "Child is not running");
1333
1575
  if (!child.steer) throw new WorkflowError("AGENT_FAILED", "Child has not registered a steering handler");
1334
1576
  await child.steer(message);
@@ -1380,29 +1622,31 @@ export class FairAgentScheduler {
1380
1622
  this.#dispatch();
1381
1623
  }
1382
1624
 
1383
- toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], skills: readonly string[] | undefined, extensions: readonly string[] | undefined) => readonly string[]): ToolDefinition[] {
1625
+ toolsFor(parentId: string): ToolDefinition[] {
1384
1626
  const parent = this.#node(parentId);
1385
- if (!parent.options.tools.includes("agent")) return [];
1627
+ if (!(parent.attemptCapabilities?.tools ?? parent.options.tools).includes("agent")) return [];
1386
1628
  const agentTool = defineTool({
1387
- name: "agent", label: "Child Agent", description: "Start a direct child agent",
1388
- parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), skills: Type.Optional(Type.Array(Type.String())), extensions: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), role: Type.Optional(Type.String()), contextFiles: Type.Optional(Type.Array(Type.String())), outputSchema: Type.Optional(Type.Record(Type.String(), Type.Unknown())), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
1629
+ name: "agent", label: "Child Agent", description: "Start a direct child agent. You own the children you create: collect each result with get_subagent_result and do not leave descendants running.",
1630
+ parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), excludeTools: Type.Optional(Type.Array(Type.String(), { description: "Exact tool names removed after preparation; cannot remove workflow_result" })), skills: Type.Optional(Type.Array(Type.String())), extensions: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), contextFiles: Type.Optional(Type.Array(Type.String())), outputSchema: Type.Optional(Type.Record(Type.String(), Type.Unknown())), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
1631
+ outputSchema: Type.Object({ id: Type.String() }),
1389
1632
  execute: async (_id, params) => {
1390
1633
  if (!isChildAgentToolParams(params)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
1391
1634
  validateAgentOptions(params);
1392
1635
  const outputSchema = params.outputSchema;
1393
1636
  if (outputSchema !== undefined) validateSchema(outputSchema, "agent outputSchema");
1394
- const tools = (params.tools !== undefined || params.role !== undefined || params.skills !== undefined || params.extensions !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.skills, params.extensions) : undefined) ?? params.tools ?? parent.options.tools;
1637
+ const tools = parent.attemptCapabilities?.tools ?? parent.options.tools;
1395
1638
  const agentOptions = { ...params };
1396
1639
  Reflect.deleteProperty(agentOptions, "prompt");
1397
- const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.skills ? { skills: params.skills } : {}), ...(params.extensions ? { extensions: params.extensions } : {}), ...(params.model ? { model: params.model } : {}), ...(params.role ? { role: params.role } : {}), ...(params.contextFiles ? { contextFiles: params.contextFiles } : {}), ...(outputSchema === undefined ? {} : { schema: outputSchema }), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
1640
+ const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.skills ? { skills: params.skills } : {}), ...(params.extensions ? { extensions: params.extensions } : {}), ...(params.model ? { model: params.model } : {}), ...(params.contextFiles ? { contextFiles: params.contextFiles } : {}), ...(outputSchema === undefined ? {} : { schema: outputSchema }), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
1398
1641
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
1399
- return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
1642
+ return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id }, structuredContent: { id: child.id } };
1400
1643
  },
1401
1644
  });
1402
1645
  const resultTool = defineTool({
1403
1646
  name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result once; repeated retrieval fails with AGENT_RESULT_COLLECTED",
1404
1647
  parameters: Type.Object({ id: Type.String() }),
1405
- execute: async (_id, params) => { const value = await this.result(parentId, params.id); if (!value.ok && value.error.code === "BUDGET_EXHAUSTED") throw new WorkflowError("BUDGET_EXHAUSTED", value.error.message); return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value }; }
1648
+ outputSchema: Type.Object({ id: Type.String(), ok: Type.Boolean(), value: Type.Optional(Type.Unknown()), error: Type.Optional(Type.Object({ code: Type.String(), message: Type.String() })) }),
1649
+ execute: async (_id, params) => { const value = await this.result(parentId, params.id); if (!value.ok && value.error.code === "BUDGET_EXHAUSTED") throw new WorkflowError("BUDGET_EXHAUSTED", value.error.message); return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value, structuredContent: value }; }
1406
1650
  });
1407
1651
  const steerTool = defineTool({
1408
1652
  name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
@@ -1452,10 +1696,14 @@ export class FairAgentScheduler {
1452
1696
  const extensionSettings = options.extensionSettings ?? parent?.options.extensionSettings ?? run.extensionSettings;
1453
1697
  if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(extensionSettings === undefined ? {} : { extensionSettings }), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
1454
1698
  if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
1455
- const forbidden = inheritedTools.find((tool: string) => !parent.options.tools.includes(tool));
1699
+ const parentCapabilities = parent.attemptCapabilities ?? (parent.options.configuration ? { tools: parent.options.configuration.tools, skills: parent.options.configuration.skills.slice(parent.options.configuration.skills.lastIndexOf("!*") + 1), extensions: parent.options.configuration.extensions.slice(parent.options.configuration.extensions.lastIndexOf("!*") + 1) } : parent.options.capabilities);
1700
+ const forbidden = inheritedTools.find((tool: string) => !(parentCapabilities?.tools ?? parent.options.tools).includes(tool));
1456
1701
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
1457
- const identity = options.agentIdentity ?? parent.options.agentIdentity;
1458
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(extensionSettings === undefined ? {} : { extensionSettings }), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
1702
+ const identity = options.agentIdentity ?? (parent.options.agentIdentity ? childIdentity(parent, parent.options.agentIdentity, options.label) : undefined);
1703
+ // Children inherit the exclusions frozen in their parent's configuration, not its re-evaluated options.
1704
+ const inheritedExcludeTools = [...(parent.options.configuration?.excludeTools ?? excludedToolNames(parent.options))];
1705
+ const projectTrusted = (parent.attemptProjectTrusted ?? parent.options.configuration?.projectTrusted ?? parent.options.projectTrusted) !== false && options.projectTrusted !== false;
1706
+ return Object.freeze({ ...options, projectTrusted, ...(inheritedExcludeTools.length ? { inheritedExcludeTools: Object.freeze(inheritedExcludeTools) } : {}), ...(parentCapabilities ? { capabilities: parentCapabilities } : {}), cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(extensionSettings === undefined ? {} : { extensionSettings }), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
1459
1707
  }
1460
1708
 
1461
1709
  #enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
@@ -1474,7 +1722,7 @@ export class FairAgentScheduler {
1474
1722
  const item = run.queue.shift() as { node?: ScheduledNode; start: () => void };
1475
1723
  if (item.node) {
1476
1724
  try { run.beforeLaunch?.(); }
1477
- catch (error) { const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", errorText(error)); this.#settle(item.node, { id: item.node.id, ok: false, error: { code: typed.code, message: typed.message } }); continue; }
1725
+ catch (error) { this.#settle(item.node, { id: item.node.id, ok: false, error: { code: scheduledErrorCode(error, item.node.controller.signal.aborted), message: errorText(error) } }); continue; }
1478
1726
  }
1479
1727
  run.active += 1; this.#active += 1; item.start();
1480
1728
  }
@@ -1510,7 +1758,7 @@ export class FairAgentScheduler {
1510
1758
 
1511
1759
  #node(id: string): ScheduledNode {
1512
1760
  const node = this.#nodes.get(id);
1513
- if (!node) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown owned agent: ${id}`);
1761
+ if (!node) throw new WorkflowError("UNKNOWN_AGENT", `Unknown owned agent: ${id}`);
1514
1762
  return node;
1515
1763
  }
1516
1764