@marcoscale98/pi-extensible-workflows 5.17.0-fork.1 → 5.19.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 (80) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/src/agent-execution.js +81 -39
  3. package/dist/src/background-widget.d.ts +5 -0
  4. package/dist/src/background-widget.js +1 -1
  5. package/dist/src/decoders.js +26 -0
  6. package/dist/src/execution.d.ts +4 -2
  7. package/dist/src/execution.js +43 -2
  8. package/dist/src/host-delivery.js +1 -0
  9. package/dist/src/host-recovery.d.ts +5 -2
  10. package/dist/src/host-recovery.js +3 -3
  11. package/dist/src/host-view.d.ts +5 -0
  12. package/dist/src/host-view.js +29 -5
  13. package/dist/src/host.d.ts +2 -2
  14. package/dist/src/host.js +190 -37
  15. package/dist/src/index.js +495 -93
  16. package/dist/src/index.js.map +3 -3
  17. package/dist/src/paths.d.ts +2 -0
  18. package/dist/src/paths.js +2 -0
  19. package/dist/src/registry.js +1 -1
  20. package/dist/src/roles.js +4 -3
  21. package/dist/src/script-tools.d.ts +27 -0
  22. package/dist/src/script-tools.js +87 -0
  23. package/dist/src/settings.d.ts +13 -0
  24. package/dist/src/settings.js +27 -3
  25. package/dist/src/trajectory.d.ts +12 -2
  26. package/dist/src/trajectory.js +30 -8
  27. package/dist/src/types.d.ts +30 -2
  28. package/dist/src/types.js +1 -1
  29. package/dist/src/utils.d.ts +19 -0
  30. package/dist/src/utils.js +30 -0
  31. package/dist/src/validation.d.ts +6 -1
  32. package/dist/src/validation.js +21 -1
  33. package/dist/starter/index.js +0 -2
  34. package/dist/starter/index.js.map +1 -1
  35. package/dist/starter/review-loop.d.ts +0 -13
  36. package/dist/starter/review-loop.js +0 -2
  37. package/dist/subagents/index.js +267 -159
  38. package/dist/subagents/index.js.map +2 -2
  39. package/dist/subagents/src/contracts.d.ts +45 -29
  40. package/dist/subagents/src/contracts.js +18 -7
  41. package/dist/subagents/src/index.d.ts +1 -1
  42. package/dist/subagents/src/index.js +13 -6
  43. package/dist/subagents/src/manager.js +3 -3
  44. package/dist/subagents/src/view.d.ts +1 -0
  45. package/dist/subagents/src/view.js +13 -52
  46. package/dist/trajectory/assets/index.html +27 -5
  47. package/dist/trajectory/index.js +76 -28
  48. package/dist/trajectory/index.js.map +2 -2
  49. package/dist/trajectory/src/assets/index.html +27 -5
  50. package/dist/trajectory/src/index.js +8 -18
  51. package/dist/trajectory/src/server.js +4 -2
  52. package/dist/trajectory/src/server.js.map +2 -2
  53. package/package.json +6 -6
  54. package/skills/pi-extensible-workflows/SKILL.md +2 -0
  55. package/src/agent-execution.ts +77 -41
  56. package/src/background-widget.ts +1 -1
  57. package/src/decoders.ts +20 -0
  58. package/src/execution.ts +41 -4
  59. package/src/host-delivery.ts +1 -0
  60. package/src/host-recovery.ts +8 -7
  61. package/src/host-view.ts +23 -5
  62. package/src/host.ts +173 -35
  63. package/src/paths.ts +3 -0
  64. package/src/registry.ts +1 -1
  65. package/src/roles.ts +4 -3
  66. package/src/script-tools.ts +76 -0
  67. package/src/settings.ts +22 -4
  68. package/src/trajectory.ts +30 -11
  69. package/src/types.ts +11 -4
  70. package/src/utils.ts +28 -0
  71. package/src/validation.ts +19 -1
  72. package/starter/review-loop.ts +0 -2
  73. package/subagents/README.md +2 -2
  74. package/subagents/src/contracts.ts +18 -7
  75. package/subagents/src/index.ts +16 -6
  76. package/subagents/src/manager.ts +3 -3
  77. package/subagents/src/view.ts +13 -50
  78. package/trajectory/src/assets/index.html +27 -5
  79. package/trajectory/src/index.ts +6 -9
  80. package/trajectory/src/server.ts +2 -2
@@ -5,7 +5,7 @@ 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
7
  import { Compile } from "typebox/compile";
8
- import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
8
+ import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultPackageManager, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type InlineExtension } from "@earendil-works/pi-coding-agent";
9
9
  import type { ExtensionContext, ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
10
10
  type HerdrModelContext = { readonly model: ExtensionContext["model"]; readonly modelRegistry: ModelRegistry | undefined };
11
11
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
@@ -46,7 +46,7 @@ export interface PiResourceInspection {
46
46
  readonly systemPromptSource?: string;
47
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";
49
+ import { SerialLane, assertModelThinking, byPriorityThenName, deepFreeze, errorText, jsonObject, jsonValue, mergeWorkflowExtensionSettings, object, physicalModel, resolveModelReference, resourcePatternHasMagic, unmatchedResourcePatterns } from "./utils.js";
50
50
  import { SETTLED_AGENT_STATES, WorkflowError, isContextFileScope, zeroAccounting, type AgentDefinition, type AgentState } from "./types.js";
51
51
  import { createLiveSessionHandoff } from "./session-handoff.js";
52
52
  import { createToolTimingExtension } from "./tool-timing.js";
@@ -55,7 +55,8 @@ import { createPiRuntimeAgentRunner, isRuntimeAgentProviderError, normalizePiRun
55
55
  import type { RuntimeAgentProgress, RuntimeUsage } from "./runtime/agent-runner.js";
56
56
  import { defaultWorkflowResultSchema } from "./runtime/workflow-result.js";
57
57
  import { validateAgentOptions, validateSchema } from "./validation.js";
58
- import { canonicalPath } from "./paths.js";
58
+ import { canonicalPath, extensionIdentity } from "./paths.js";
59
+ import { isCorePackageName } from "./package-identity.js";
59
60
  import { canonicalExtensionSelector, resolveRole } from "./roles.js";
60
61
  import type { RunStore } from "./persistence.js";
61
62
  type AgentExecutionRunStore = Pick<RunStore, "recordSystemPrompt" | "validateWorktree" | "worktree" | "snapshotWorktree">;
@@ -141,16 +142,23 @@ function accounting(stats: WorkflowAgentSessionStats): AgentAccounting {
141
142
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
142
143
  }
143
144
 
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
- ]);
145
+ 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"];
146
+ function isWorkflowHostEntry(path: string): boolean {
147
+ const entryPath = canonicalPath(path);
148
+ let packageRoot = dirname(entryPath);
149
+ while (packageRoot !== dirname(packageRoot)) {
150
+ const packageMetadataPath = join(packageRoot, "package.json");
151
+ if (existsSync(packageMetadataPath)) {
152
+ try {
153
+ const metadata: unknown = JSON.parse(readFileSync(packageMetadataPath, "utf8"));
154
+ if (typeof metadata !== "object" || metadata === null || !("name" in metadata) || !isCorePackageName(metadata.name)) return false;
155
+ return WORKFLOW_HOST_ENTRY_PATHS.some((entry) => canonicalPath(join(packageRoot, entry)) === entryPath);
156
+ } catch { return false; }
157
+ }
158
+ packageRoot = dirname(packageRoot);
159
+ }
160
+ return false;
161
+ }
154
162
  const WORKFLOW_DIRECTORY = "pi-extensible-workflows";
155
163
  function workflowSystemPromptPath(cwd: string, agentDir: string, projectTrusted: boolean): string | undefined {
156
164
  const projectPath = join(cwd, ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md");
@@ -184,24 +192,29 @@ function loadPiPackageDirectory(): Promise<PiPackageDirectoryResolution> {
184
192
  }
185
193
  }).catch((error: unknown) => ({ error: `could not load @earendil-works/pi-coding-agent package metadata: ${errorText(error)}` }));
186
194
  }
187
- let nativePromptTemplateModule: Promise<NativePromptTemplateModule> | undefined;
188
- async function loadNativePromptTemplateModule(): Promise<NativePromptTemplateModule> {
195
+ const nativeCoreModules = new Map<string, Promise<unknown>>();
196
+ /** Pi does not export these core modules, so inspection loads them from the installed package. */
197
+ async function loadNativeCoreModule(file: string, label: string): Promise<unknown> {
189
198
  const packageDirectory = await loadPiPackageDirectory();
190
- let promptTemplatePath: string | undefined;
199
+ let modulePath: string | undefined;
191
200
  if (packageDirectory.directory) {
192
- const candidate = resolve(packageDirectory.directory, "dist/core/prompt-templates.js");
193
- if (existsSync(candidate)) promptTemplatePath = candidate;
201
+ const candidate = resolve(packageDirectory.directory, "dist/core", file);
202
+ if (existsSync(candidate)) modulePath = candidate;
194
203
  }
195
- promptTemplatePath ??= resolve(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "core/prompt-templates.js");
204
+ modulePath ??= resolve(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "core", file);
196
205
  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 }); });
206
+ let loaded = nativeCoreModules.get(file);
207
+ if (!loaded) { loaded = import(modulePath).catch((error: unknown) => { throw new Error(`Could not load Pi ${label}.${packageDirectoryError}`, { cause: error }); }); nativeCoreModules.set(file, loaded); }
208
+ return loaded;
198
209
  }
199
210
  async function expandPromptTemplateForInspection(text: string, templates: readonly { name: string; content: string }[]): Promise<string> {
200
- return (await loadNativePromptTemplateModule()).expandPromptTemplate(text, templates);
211
+ return (await loadNativeCoreModule("prompt-templates.js", "prompt templates") as NativePromptTemplateModule).expandPromptTemplate(text, templates);
212
+ }
213
+ async function buildSystemPromptForInspection(options: unknown): Promise<string> {
214
+ return (await loadNativeCoreModule("system-prompt.js", "system prompt builder") as { buildSystemPrompt(options: unknown): string }).buildSystemPrompt(options);
201
215
  }
202
216
  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;
217
+ 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
218
  readonly _baseSystemPromptOptions?: unknown;
206
219
  readonly _expandSkillCommand?: (text: string) => string;
207
220
  readonly promptTemplates?: readonly { name: string; content: string }[];
@@ -210,12 +223,11 @@ async function preparePiPrompt(native: PiSession, text: string): Promise<PiPromp
210
223
  const session = native as NativePromptSession;
211
224
  const diagnostics: Array<{ type: "error"; message: string; source?: string }> = [];
212
225
  const runner = session._extensionRunner;
213
- const baseSystemPrompt = typeof session._baseSystemPrompt === "string" ? session._baseSystemPrompt : session.systemPrompt ?? "";
226
+ const baseSystemPrompt = session.systemPrompt ?? "";
214
227
  const baseOptions = session._baseSystemPromptOptions;
215
228
  const expandSkillCommand = session._expandSkillCommand;
216
229
  const templates = session.promptTemplates;
217
230
  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
231
  if (baseOptions === undefined) diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: base system prompt options are missing", source: "Pi session" });
220
232
  if (typeof expandSkillCommand !== "function") diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: skill expansion is missing", source: "Pi session" });
221
233
  if (!Array.isArray(templates)) diagnostics.push({ type: "error", message: "Pi prompt inspection seam is unavailable: prompt templates are missing", source: "Pi session" });
@@ -237,9 +249,11 @@ async function preparePiPrompt(native: PiSession, text: string): Promise<PiPromp
237
249
  try { expandedPrompt = await expandPromptTemplateForInspection(skillExpanded, Array.isArray(templates) ? templates : []); }
238
250
  catch (error) { diagnostics.push({ type: "error", message: `Pi prompt template expansion is unavailable: ${errorText(error)}`, source: "Pi session" }); }
239
251
  }
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 };
252
+ const result = !inputHandled && runner && baseOptions !== undefined ? await runner.emitBeforeAgentStart(expandedPrompt, undefined, baseOptions) : undefined;
253
+ const prepared = result as { messages?: readonly unknown[]; systemPromptOptions?: unknown } | undefined;
254
+ const systemPromptOptions = prepared?.systemPromptOptions ?? baseOptions;
255
+ const systemPrompt = prepared?.systemPromptOptions === undefined ? baseSystemPrompt : await buildSystemPromptForInspection(prepared.systemPromptOptions);
256
+ return { prompt: text, expandedPrompt, systemPrompt, systemPromptOptions, messages: prepared?.messages ?? [], inputHandled, diagnostics };
243
257
  } finally { unsubscribe?.(); }
244
258
  }
245
259
 
@@ -281,6 +295,23 @@ export function flushExtensionProviders(resourceLoader: DefaultResourceLoader, m
281
295
  return failures;
282
296
  }
283
297
 
298
+ /**
299
+ * The CLI adds Pi's built-in extensions itself; SDK sessions only get them from these public
300
+ * factories (docs/sdk.md). As `builtin` entries they obey `-builtin:<name>` settings, and like the
301
+ * CLI's they are replaceable by an extension that registers the same tools or commands.
302
+ * NOTE: Pi does not export the built-in llama.cpp extension, so workflow agents cannot use models it
303
+ * provides. Declare the server as a provider in models.json instead.
304
+ */
305
+ function builtinExtensions(): InlineExtension[] {
306
+ return [
307
+ { name: "codemode", factory: createCodemodeExtension(), replaceable: true, builtin: true },
308
+ { name: "tool-search", factory: createToolSearchExtension(), replaceable: true, builtin: true },
309
+ { name: "mcp", factory: createMcpExtension(), replaceable: true, builtin: true },
310
+ ];
311
+ }
312
+ const BUILTIN_EXTENSION_PREFIX = "builtin:";
313
+ function isLlamaModel(model: ModelSpec): boolean { return model.provider === "llama.cpp"; }
314
+
284
315
  async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent?: WorkflowSessionStartEvent): Promise<LocalPiSessionHandle> {
285
316
  const agentDir = input.agentDir ?? getAgentDir();
286
317
  const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
@@ -293,13 +324,16 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
293
324
  const tools = [...new Set([...input.tools, ...customTools.map(({ name }) => name)])];
294
325
  let settingsManager: SettingsManager;
295
326
  let resourceLoader: DefaultResourceLoader;
327
+ const builtins = builtinExtensions();
328
+ const builtinNames = builtins.flatMap((extension) => typeof extension === "function" ? [] : [extension.name]);
329
+ const extensionFactories = [...builtins, ...(input.extensionFactories ?? [])];
296
330
  const policy = input.resourcePolicy;
297
331
  if (policy) {
298
332
  settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: false });
299
333
  settingsManager.setProjectTrusted(policy.projectTrusted);
300
- const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
334
+ const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager, builtinExtensions: builtinNames });
301
335
  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)))];
336
+ const discoveredExtensions = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => extensionIdentity(path)))];
303
337
  const selectorSources = policy.selectorSources;
304
338
  const selection = resolveRole(undefined, {
305
339
  cwd: input.cwd,
@@ -307,7 +341,7 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
307
341
  resources: { extensions: discoveredExtensions },
308
342
  });
309
343
  const selectedExtensions = selection.selectedExtensions ?? [];
310
- const extensionPaths = selectedExtensions.filter((path) => !WORKFLOW_HOST_ENTRIES.has(path));
344
+ const extensionPaths = selectedExtensions.filter((path) => path.startsWith(BUILTIN_EXTENSION_PREFIX) || !isWorkflowHostEntry(path));
311
345
  policy.selectedExtensions = selectedExtensions;
312
346
  policy.unmatchedExtensions = selection.unmatchedExtensions ?? [];
313
347
  const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
@@ -327,7 +361,7 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
327
361
  additionalExtensionPaths: extensionPaths,
328
362
  noSkills: true,
329
363
  additionalSkillPaths: [...new Set([...skillPaths, ...(input.additionalSkillPaths ?? [])])],
330
- ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
364
+ extensionFactories,
331
365
  ...(contextFilesOverride ? { agentsFilesOverride: contextFilesOverride } : {}),
332
366
  skillsOverride: (base) => {
333
367
  const disabledSkills = updateSkillMatches(base.skills);
@@ -339,10 +373,10 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
339
373
  await resourceLoader.reload();
340
374
  } else {
341
375
  settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: true });
342
- const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
376
+ const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager, builtinExtensions: builtinNames });
343
377
  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 ?? ""] } : {}) });
378
+ const extensionPaths = [...new Set(resolved.extensions.filter(({ enabled }) => enabled).map(({ path }) => extensionIdentity(path)).filter((path) => path.startsWith(BUILTIN_EXTENSION_PREFIX) || !isWorkflowHostEntry(path)))];
379
+ 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
380
  await resourceLoader.reload();
347
381
  }
348
382
  const providerFailures = flushExtensionProviders(resourceLoader, modelRuntime);
@@ -353,7 +387,7 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
353
387
  // missing, so the failure travels with the complaint rather than being
354
388
  // swallowed — otherwise a misconfigured gateway reads as a typo in a model
355
389
  // name.
356
- const because = providerFailures.length > 0 ? ` (provider registration failed — ${providerFailures.join("; ")})` : "";
390
+ 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
391
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}${because}`);
358
392
  }
359
393
  const effectiveSessionStartEvent = input.settings === undefined && sessionStartEvent === undefined ? undefined : { ...(sessionStartEvent ?? { type: "session_start" as const, reason: "startup" as const }), settings: input.settings ?? Object.freeze({}) };
@@ -378,13 +412,13 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
378
412
  await shutdown("quit").catch(() => undefined);
379
413
  throw error;
380
414
  }
381
- const resourcePaths = { extensions: resourceLoader.getExtensions().extensions.filter(({ path }) => !path.startsWith("<")).map(({ resolvedPath }) => canonicalPath(resolvedPath)), skills: resourceLoader.getSkills().skills.map(({ filePath }) => canonicalPath(filePath)) };
415
+ const resourcePaths = { extensions: resourceLoader.getExtensions().extensions.filter(({ path }) => !path.startsWith("<")).map(({ resolvedPath }) => extensionIdentity(resolvedPath)), skills: resourceLoader.getSkills().skills.map(({ filePath }) => canonicalPath(filePath)) };
382
416
  const resourceInspection = (): PiResourceInspection => {
383
417
  const extensions = resourceLoader.getExtensions();
384
418
  const skills = resourceLoader.getSkills();
385
419
  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
420
  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" } : {}) };
421
+ 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
422
  };
389
423
  const managedSession = Object.assign(session, {
390
424
  getLeafId: () => manager.getLeafId(),
@@ -941,7 +975,7 @@ export class WorkflowAgentExecutor {
941
975
  ...(options.contextFiles === undefined ? {} : { contextFiles: options.contextFiles }),
942
976
  ...(options.effectiveTools === undefined ? {} : { effectiveTools: options.effectiveTools }),
943
977
  });
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 }) };
978
+ return { model: physicalModel(resolved.model ?? this.root.model, this.root.modelAliases, this.root.knownModels, this.root.settingsPath), ...(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 }) };
945
979
  }
946
980
 
947
981
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
@@ -1386,6 +1420,7 @@ export class FairAgentScheduler {
1386
1420
  const agentTool = defineTool({
1387
1421
  name: "agent", label: "Child Agent", description: "Start a direct child agent",
1388
1422
  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 }),
1423
+ outputSchema: Type.Object({ id: Type.String() }),
1389
1424
  execute: async (_id, params) => {
1390
1425
  if (!isChildAgentToolParams(params)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
1391
1426
  validateAgentOptions(params);
@@ -1396,13 +1431,14 @@ export class FairAgentScheduler {
1396
1431
  Reflect.deleteProperty(agentOptions, "prompt");
1397
1432
  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 }) };
1398
1433
  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 } };
1434
+ return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id }, structuredContent: { id: child.id } };
1400
1435
  },
1401
1436
  });
1402
1437
  const resultTool = defineTool({
1403
1438
  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
1439
  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 }; }
1440
+ 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() })) }),
1441
+ 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
1442
  });
1407
1443
  const steerTool = defineTool({
1408
1444
  name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
@@ -49,7 +49,7 @@ const ENTRY_TYPE = "piewf-run-receipt";
49
49
  * Repaint interval. Fast enough that the spinner reads as motion rather than a
50
50
  * stutter; the underlying data is only re-read when an event says it changed.
51
51
  */
52
- const REPAINT_MS = 125;
52
+ export const REPAINT_MS = 125;
53
53
 
54
54
  /**
55
55
  * Filesystem rescan interval. Shell-only activity has no event, but it does not
package/src/decoders.ts CHANGED
@@ -17,6 +17,7 @@ type PersistedAgent = RunRecord["agents"][number];
17
17
  type PersistedPhaseRecord = NonNullable<RunRecord["phaseHistory"]>[number];
18
18
  type PersistedShellActivity = NonNullable<RunRecord["activeShellsByPhase"]>[number];
19
19
  type PersistedDelivery = NonNullable<RunRecord["delivery"]>;
20
+ type PersistedScriptCall = NonNullable<RunRecord["scriptCalls"]>[number];
20
21
  type PersistedIdentity = NonNullable<ScheduledAgentOptions["agentIdentity"]>;
21
22
  type PersistedOptions = ScheduledAgentOptions;
22
23
 
@@ -394,6 +395,22 @@ function decodeRunEvent(value: unknown): WorkflowRunEvent | undefined {
394
395
  if (timestamp === INVALID_PERSISTED_VALUE) return undefined;
395
396
  return { type: value.type, message: value.message, ...(timestamp === undefined ? {} : { timestamp }) };
396
397
  }
398
+ function decodeScriptCall(value: unknown): PersistedScriptCall | undefined {
399
+ if (!object(value) || value.kind !== "shell" && value.kind !== "tool" || typeof value.name !== "string" || typeof value.path !== "string" || !finiteNumber(value.startedAt)) return undefined;
400
+ const shell = value.kind === "shell";
401
+ if (shell && !jsonValue(value.input)) return undefined;
402
+ const inputBytes = shell ? optionalNumber(value.inputBytes) : undefined;
403
+ const phase = optionalString(value.phase);
404
+ const finishedAt = optionalNumber(value.finishedAt);
405
+ let error: PersistedScriptCall["error"];
406
+ if (value.error !== undefined) {
407
+ if (shell) error = decodeWorkflowError(value.error);
408
+ else if (object(value.error) && typeof value.error.code === "string") error = { code: isWorkflowErrorCode(value.error.code) ? value.error.code : "INTERNAL_ERROR" };
409
+ if (!error) return undefined;
410
+ }
411
+ if (inputBytes === INVALID_PERSISTED_VALUE || phase === INVALID_PERSISTED_VALUE || finishedAt === INVALID_PERSISTED_VALUE) return undefined;
412
+ return { kind: value.kind, name: value.name, path: value.path, ...(shell && jsonValue(value.input) ? { input: value.input } : {}), ...(inputBytes === undefined ? {} : { inputBytes }), ...(phase === undefined ? {} : { phase }), startedAt: value.startedAt, ...(finishedAt === undefined ? {} : { finishedAt }), ...(error === undefined ? {} : { error }) };
413
+ }
397
414
  function decodeDelivery(value: unknown): PersistedDelivery | undefined {
398
415
  if (!object(value)) return undefined;
399
416
  const mode = value.mode;
@@ -423,6 +440,8 @@ export function decodePersistedRun(value: unknown, allowLegacyAgentSessions = fa
423
440
  const budgetEvents = value.budgetEvents === undefined ? undefined : decodeArray(value.budgetEvents, decodeBudgetEvent);
424
441
  const events = value.events === undefined ? undefined : decodeArray(value.events, decodeRunEvent);
425
442
  const delivery = value.delivery === undefined ? undefined : decodeDelivery(value.delivery);
443
+ const scriptCalls = value.scriptCalls === undefined ? undefined : decodeArray(value.scriptCalls, decodeScriptCall);
444
+ if (value.scriptCalls !== undefined && !scriptCalls) return undefined;
426
445
  if (!agentSessions || !agents || parentRunId === INVALID_PERSISTED_VALUE || value.retry !== undefined && !retry || phase === INVALID_PERSISTED_VALUE || value.phaseHistory !== undefined && !phaseHistory || phaseHistoryIndex === INVALID_PERSISTED_VALUE || activeShells === INVALID_PERSISTED_VALUE || activeShellStartedAt === INVALID_PERSISTED_VALUE || value.activeShellsByPhase !== undefined && !activeShellsByPhase || value.error !== undefined && !error || failedAt === INVALID_PERSISTED_VALUE || value.budget !== undefined && !budget || budgetVersion === INVALID_PERSISTED_VALUE || value.budgetVersion !== undefined && !integer(budgetVersion) || value.usage !== undefined && !usage || value.budgetEvents !== undefined && !budgetEvents || value.events !== undefined && !events || value.delivery !== undefined && !delivery) return undefined;
427
446
  if (value.agentSessions === undefined && !allowLegacyAgentSessions) return undefined;
428
447
  return {
@@ -441,6 +460,7 @@ export function decodePersistedRun(value: unknown, allowLegacyAgentSessions = fa
441
460
  ...(activeShells === undefined ? {} : { activeShells }),
442
461
  ...(activeShellStartedAt === undefined ? {} : { activeShellStartedAt }),
443
462
  ...(activeShellsByPhase === undefined ? {} : { activeShellsByPhase }),
463
+ ...(scriptCalls === undefined ? {} : { scriptCalls }),
444
464
  ...(error === undefined ? {} : { error }),
445
465
  ...(failedAt === undefined ? {} : { failedAt }),
446
466
  ...(budget === undefined ? {} : { budget }),
package/src/execution.ts CHANGED
@@ -5,9 +5,9 @@ import { join } from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
6
6
  import { RunStore, structuralPath as operationPath } from "./persistence.js";
7
7
  import type { AgentAttempt } from "./agent-execution.js";
8
- import type { AgentIdentity, AgentAttemptSummary, FunctionIdentity, JsonValue, ShellIdentity, ShellOptions, ShellResult, WorkflowAgentSessionReference, WorkflowBridge, WorkflowErrorCode, WorkflowExecution } from "./types.js";
8
+ import type { AgentIdentity, AgentAttemptSummary, FunctionIdentity, JsonValue, ShellIdentity, ShellOptions, ShellResult, ToolIdentity, WorkflowAgentSessionReference, WorkflowBridge, WorkflowErrorCode, WorkflowExecution } from "./types.js";
9
9
  import { WorkflowError, roleNameOf, sumAccounting, zeroAccounting } from "./types.js";
10
- import { asWorkflowError, errorText, fail, isWorkflowAuthored, isWorkflowErrorCode, jsonValue, markWorkflowAuthored, object, positiveInteger } from "./utils.js";
10
+ import { asWorkflowError, errorText, fail, isWorkflowAuthored, isWorkflowErrorCode, jsonDigest, jsonValue, markWorkflowAuthored, object, positiveInteger } from "./utils.js";
11
11
  import { instrumentWorkflow, validateAgentOptions, validateShellCommand, validateShellOptions } from "./validation.js";
12
12
 
13
13
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
@@ -275,6 +275,30 @@ const functions = Object.freeze(Object.fromEntries(Object.entries(config.functio
275
275
  Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
276
276
  return result;
277
277
  }])));
278
+ // tools.<id>(args) runs a Pi session tool through the host. Any identifier resolves, so a resumed run
279
+ // replays journaled calls even when the tool is gone; the host rejects identifiers it cannot call.
280
+ // A call is the n-th call with the same arguments in its scope, so replay does not depend on the
281
+ // order in which concurrent calls start.
282
+ const NOT_TOOLS = new Set(["then", "toJSON", "toString", "valueOf", "constructor"]);
283
+ const sortedJson = value => Array.isArray(value) ? value.map(sortedJson) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(key => [key, sortedJson(value[key])])) : value;
284
+ const toolOccurrences = new Map();
285
+ const toolCall = id => (...values) => {
286
+ if (worktreeOwners.getStore()) throw workError("INVALID_METADATA", "tools." + id + " is unavailable inside withWorktree: session tools resolve paths against the session working directory");
287
+ if (values.length > 1 || values[0] !== undefined && (!values[0] || typeof values[0] !== "object" || Array.isArray(values[0]))) throw workError("INVALID_METADATA", "tools." + id + " takes one arguments object");
288
+ const args = values[0] === undefined ? {} : values[0];
289
+ const structuralPath = inheritedAgentPath.getStore() || [];
290
+ const key = JSON.stringify([structuralPath, id, sortedJson(args)]);
291
+ const occurrence = (toolOccurrences.get(key) || 0) + 1;
292
+ toolOccurrences.set(key, occurrence);
293
+ return guardedResult(rpc("tool", [id, args, { structuralPath: [...structuralPath], occurrence }]), "tool");
294
+ };
295
+ const tools = new Proxy(Object.create(null), {
296
+ get: (_target, key) => typeof key === "string" && !NOT_TOOLS.has(key) ? toolCall(key) : undefined,
297
+ // Probing would branch on the session that resumed the run; the launch already checks named tools.
298
+ has: () => { throw workError("INVALID_METADATA", "Workflow scripts cannot probe tools with 'in'; call tools.<name>(...) directly, and the launch checks that the tool exists"); },
299
+ set: () => false,
300
+ defineProperty: () => false,
301
+ });
278
302
  const recordEntries = (value, kind) => {
279
303
  if (!value || typeof value !== "object" || Array.isArray(value)) throw workError("INVALID_METADATA", kind + " must be a record");
280
304
  return Object.entries(value);
@@ -335,7 +359,7 @@ const pipeline = async (operationName, items, stages) => {
335
359
  return Object.fromEntries(results.map(result => [result.name, result.value]));
336
360
  };
337
361
  const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
338
- const sandbox = { agent, shell, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
362
+ const sandbox = { agent, shell, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, tools, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
339
363
  for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
340
364
  for (const name of ["Date","eval","Function","WebAssembly","process","require","module","exports","console","fetch","XMLHttpRequest","WebSocket","performance","crypto","setTimeout","setInterval","setImmediate","queueMicrotask","Intl","SharedArrayBuffer","Atomics"]) sandbox[name] = undefined;
341
365
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
@@ -384,6 +408,14 @@ function readShellIdentity(value: unknown): ShellIdentity {
384
408
  const identity = readAgentIdentity(value);
385
409
  return { structuralPath: identity.structuralPath, callSite: identity.callSite, occurrence: identity.occurrence, ...(identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {}) };
386
410
  }
411
+ function readToolIdentity(value: unknown): ToolIdentity {
412
+ if (!object(value) || !Array.isArray(value.structuralPath) || !value.structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim())) || !positiveInteger(value.occurrence)) fail("INTERNAL_ERROR", "Invalid workflow tool identity");
413
+ return { structuralPath: [...value.structuralPath], occurrence: value.occurrence };
414
+ }
415
+ /** The journal path of a script tool call; the arguments' digest keeps calls with different arguments apart whatever order they start in. */
416
+ export function toolIdentityPath(identifier: string, args: JsonValue, identity: ToolIdentity): string {
417
+ return operationPath("tool", ...identity.structuralPath, identifier, `args:${jsonDigest(args)}`, String(identity.occurrence));
418
+ }
387
419
  export function agentHandleTurnPath(handle: string, turn: number): string {
388
420
  return operationPath("agent", "handle", handle, `turn:${String(turn)}`);
389
421
  }
@@ -595,6 +627,11 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
595
627
  if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
596
628
  value = branded({ name, ok: false, failedAt: name, error: workerErrorShape(typed) });
597
629
  }
630
+ } else if (method === "tool") {
631
+ if (typeof values[0] !== "string" || !object(values[1])) fail("INTERNAL_ERROR", "tool requires an identifier and object arguments");
632
+ const identity = readToolIdentity(values[2]);
633
+ if (!bridge.tool) fail("UNKNOWN_TOOL", `tools.${values[0]} is unavailable: this run has no session tools`);
634
+ value = await bridge.tool(values[0], values[1], controller.signal, identity);
598
635
  } else if (method === "worktree") {
599
636
  if (!bridge.worktree || typeof values[0] !== "string" || !values[0]) fail("INTERNAL_ERROR", "worktree requires an active host bridge and scope");
600
637
  value = await bridge.worktree(values[0], controller.signal);
@@ -649,4 +686,4 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
649
686
  return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), agentSessions: [...run.agentSessions.filter(({ transport, sessionId }) => !sessionKeys.has(`${transport}:${sessionId}`)), ...sessions] };
650
687
  });
651
688
  }
652
- export type { AgentIdentity, FunctionIdentity, ShellIdentity, WorkflowBridge, WorkflowExecution } from "./types.js";
689
+ export type { AgentIdentity, FunctionIdentity, ShellIdentity, ToolIdentity, WorkflowBridge, WorkflowExecution } from "./types.js";
@@ -102,6 +102,7 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
102
102
  RUN_NOT_FOUND: (detail) => /^Unknown workflow run\b/.test(detail) ? "The workflow run was not found." : `The workflow run was not found: ${detail}.`,
103
103
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
104
104
  SHELL_FAILED: (detail) => `The workflow shell command failed: ${detail}.`,
105
+ TOOL_FAILED: (detail) => `The workflow tool call failed: ${detail}.`,
105
106
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
106
107
  AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
107
108
  AGENT_RESULT_COLLECTED: (detail) => `The nested agent result was already collected: ${detail}.`,
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Api, Model } from "@earendil-works/pi-ai";
3
- import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { type ExtensionAPI, type ExtensionToolContext } from "@earendil-works/pi-coding-agent";
4
4
  import { FairAgentScheduler, WorkflowAgentExecutor, type AgentDefinition, type AgentExecutionRoot, type AgentProviderFailure, type AgentProviderRecovery } from "./agent-execution.js";
5
5
  import { listRunIds, RunStore, structuralPath as operationPath, type PersistedRun } from "./persistence.js";
6
6
  import { budgetUsage, budgetRelaxed, mergeBudget, resumeBudgetAllowed, validateBudget, validateBudgetPatch, WorkflowBudgetRuntime } from "./budget.js";
7
7
  import { aliasDrift, createLaunchSnapshot, errorCode, errorText, jsonValue, object } from "./utils.js";
8
- import { LAUNCH_SNAPSHOT_IDENTITY_VERSION, WorkflowError, isExternallyEndedRunState, type AgentIdentity, type AgentResourcePolicy, type BudgetApprovalRequest, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowExtensionSettings, type WorkflowMetadata, type WorkflowRetryProvenance, type WorkflowWorktreeReference } from "./types.js";
8
+ import { LAUNCH_SNAPSHOT_IDENTITY_VERSION, WorkflowError, isExternallyEndedRunState, type AgentIdentity, type AgentResourcePolicy, type BudgetApprovalRequest, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type ToolIdentity, type WorkflowExtensionSettings, type WorkflowMetadata, type WorkflowRetryProvenance, type WorkflowWorktreeReference } from "./types.js";
9
9
  import { type WorkflowRegistryApi } from "./registry.js";
10
10
  import { RunLifecycle, WorkflowEventPublisher, hostSessionContext, withWorkflowFunctions, withoutActiveShells, workflowRunContext, type WorkflowRunRecord, type WorkflowToolUpdate } from "./host-runtime.js";
11
11
  import { runWorkflow } from "./execution.js";
@@ -13,7 +13,7 @@ import { createWorkflowFailureDiagnostics, formatWorkflowFailureDelivery, format
13
13
 
14
14
  /** The subset of Pi's model registry the host relies on; every member is optional because older or headless hosts expose fewer of them. */
15
15
  export type ModelRegistryCapability = { getAll?: () => readonly Model<Api>[]; getAvailable?: () => readonly Model<Api>[]; find?: (provider: string, model: string) => Model<Api> | undefined; refresh?: () => Promise<void>; getError?: () => string | undefined };
16
- export type WorkflowRecoveryContext = { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; deliveryContext: CompletionDeliveryContext; signal?: AbortSignal; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> };
16
+ export type WorkflowRecoveryContext = { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; deliveryContext: CompletionDeliveryContext; toolContext?: ExtensionToolContext | undefined; signal?: AbortSignal; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> };
17
17
  export type WorkflowRecoveryDependencies = {
18
18
  pi: Pick<ExtensionAPI, "getThinkingLevel">;
19
19
  home: string | undefined;
@@ -32,6 +32,7 @@ export type WorkflowRecoveryDependencies = {
32
32
  resumeRoles: (store: RunStore, snapshot: Readonly<LaunchSnapshot>, cwd: string, trustedProject: boolean) => { definitions: Readonly<Record<string, AgentDefinition>>; capture: (role: string, model: ModelSpec) => Promise<void> };
33
33
  workflowAgentHandler: (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string, captureRole?: (role: string, model: ModelSpec) => Promise<void>) => (prompt: string, options: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: AgentIdentity) => Promise<JsonValue>;
34
34
  shellForRun: (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, command: string, options: ShellOptions, signal: AbortSignal, identity: ShellIdentity) => Promise<ShellResult>;
35
+ toolForRun: (store: RunStore, lifecycle: RunLifecycle, context: ExtensionToolContext | undefined, identifier: string, args: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: ToolIdentity) => Promise<JsonValue>;
35
36
  resolveWorktree: (store: RunStore, metadata: WorkflowMetadata, owner: string) => Promise<Readonly<WorkflowWorktreeReference>>;
36
37
  checkpointBridge: (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, headless?: boolean) => (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => Promise<boolean>;
37
38
  phaseBridge: (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => (phase: string) => Promise<void>;
@@ -74,7 +75,7 @@ function assertExpectedWorkflowState(expectedState: string | undefined, actualSt
74
75
  export function persistedFailure(run: PersistedRun, error: WorkflowError): PersistedRun { const failedAt = workflowFailedAt(error); return { ...run, error: { code: error.code, message: error.message, ...(failedAt ? { failedAt } : {}) }, ...(failedAt ? { failedAt } : {}) }; }
75
76
 
76
77
  export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
77
- const { pi, home, runs, scheduler, eventPublisher, persistRunState, projectTrusted, resumeHostContext, ensureSessionLease, createAgentExecutor, activeSnapshotTools, frozenResourcePolicy, resolveLaunchPrologue, resumeRoles, workflowAgentHandler, shellForRun, resolveWorktree, checkpointBridge, phaseBridge, logBridge, lifecycleFor, createProviderErrorRecovery, cleanupTerminalRun, deliver, deliverTerminal, workflowToolUpdate, registry, modelSpec } = deps;
78
+ const { pi, home, runs, scheduler, eventPublisher, persistRunState, projectTrusted, resumeHostContext, ensureSessionLease, createAgentExecutor, activeSnapshotTools, frozenResourcePolicy, resolveLaunchPrologue, resumeRoles, workflowAgentHandler, shellForRun, toolForRun, resolveWorktree, checkpointBridge, phaseBridge, logBridge, lifecycleFor, createProviderErrorRecovery, cleanupTerminalRun, deliver, deliverTerminal, workflowToolUpdate, registry, modelSpec } = deps;
78
79
  const coordinateRunMutation = deps.coordinateRunMutation ?? (<T>(task: () => Promise<T>): Promise<T> => task());
79
80
  type BudgetDecisionResult = { state: "running" | "completed" | "budget_exhausted"; approved: boolean; value?: JsonValue; run?: PersistedRun; completion?: CompletionDeliveryResult };
80
81
  const budgetDecisionDelivery = (metadata: WorkflowMetadata, request: BudgetApprovalRequest) => `Workflow ${metadata.name} budget adjustment ${request.proposalId} for run ${request.runId} requires approval. Consumed usage: ${JSON.stringify(request.consumed)}. Previous limits: ${JSON.stringify(request.previous)}. Proposed limits: ${JSON.stringify(request.proposed)}. Respond with workflow_respond using proposalId ${request.proposalId}.`;
@@ -111,7 +112,7 @@ export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
111
112
  return { hasUI: host?.hasUI === true, ui };
112
113
  };
113
114
  type ColdResumeResult = { value: JsonValue; resultPath: string; resultBytes: number; completion: CompletionDeliveryResult };
114
- const coldResumeRun = async (run: WorkflowRunRecord, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: WorkflowRecoveryContext["modelRegistry"]; deliveryContext: CompletionDeliveryContext; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }, modeOverride?: boolean, waitForCompletion = true): Promise<ColdResumeResult | undefined> => {
115
+ const coldResumeRun = async (run: WorkflowRunRecord, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: WorkflowRecoveryContext["modelRegistry"]; deliveryContext: CompletionDeliveryContext; toolContext?: ExtensionToolContext | undefined; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }, modeOverride?: boolean, waitForCompletion = true): Promise<ColdResumeResult | undefined> => {
115
116
  const loaded = await run.store.load();
116
117
  const foreground = modeOverride ?? (loaded.run.delivery?.mode === "foreground" || (loaded.run.delivery?.mode === "background" && loaded.run.delivery.toolCallId !== undefined) || (loaded.run.delivery === undefined && loaded.snapshot.launchMode === "foreground"));
117
118
  if (loaded.run.activeShells !== undefined || loaded.run.activeShellStartedAt !== undefined || loaded.run.activeShellsByPhase !== undefined) await persistRunState(run.store, run.metadata, withoutActiveShells);
@@ -137,7 +138,7 @@ export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
137
138
  run.executor.setRunContext(runContext);
138
139
  await scheduler.cancelRun(run.store.runId);
139
140
  await run.lifecycle.resume();
140
- const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId, roles.capture), worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, foreground, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.store, run.lifecycle, run.metadata.name) }, run.store, runContext, registry, snapshot.settings.extensionSettings), controller.signal);
141
+ const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ tool: (identifier, args, signal, identity) => toolForRun(run.store, run.lifecycle, context?.toolContext, identifier, args, signal, identity), shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId, roles.capture), worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, foreground, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.store, run.lifecycle, run.metadata.name) }, run.store, runContext, registry, snapshot.settings.extensionSettings), controller.signal);
141
142
  run.execution = execution;
142
143
  const completion = execution.result.then(async (value) => {
143
144
  await scheduler.flush(run.store.runId);
@@ -314,7 +315,7 @@ export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
314
315
  await eventPublisher.runStarted(prepared.childRun.store, prepared.childRun.metadata);
315
316
  const { hasUI, ui } = recoveryUi(context);
316
317
  const recoveryContext = resumeHostContext(context);
317
- const completed = await coldResumeRun(prepared.childRun, hasUI, ui, projectTrusted(context), { model: prepared.hostModel, modelRegistry: prepared.modelRegistry, deliveryContext: recoveryContext.deliveryContext, resolvedAliases: prepared.currentAliases, blockedAliases: prepared.blockedAliases, blockedAliasTargets: prepared.blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
318
+ const completed = await coldResumeRun(prepared.childRun, hasUI, ui, projectTrusted(context), { model: prepared.hostModel, modelRegistry: prepared.modelRegistry, deliveryContext: recoveryContext.deliveryContext, toolContext: recoveryContext.toolContext, resolvedAliases: prepared.currentAliases, blockedAliases: prepared.blockedAliases, blockedAliasTargets: prepared.blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
318
319
  const completion = runs.get(prepared.childRun.store.runId)?.completion;
319
320
  if (completion) {
320
321
  childStarted = true;
package/src/host-view.ts CHANGED
@@ -90,6 +90,15 @@ function formatShellActivity(activeShells: number | undefined, startedAt: number
90
90
  const timing = started && startedAt !== undefined && !Number.isNaN(started.getTime()) ? ` ${styles.dim(`started=${started.toISOString()} elapsed=${formatWorkflowRuntime(Math.max(0, now - startedAt))}`)}` : "";
91
91
  return `${styles.accent(spinner)} shell ${styles.accent("[running]")} ${styles.dim(`(${String(count)} active)`)}${timing}`;
92
92
  }
93
+ function formatToolActivity(run: Pick<PersistedRun, "activeTools">, spinner: string, styles: WorkflowProgressStyles, now: number): string | undefined {
94
+ const calls = run.activeTools ?? [];
95
+ if (!calls.length) return undefined;
96
+ const counts = new Map<string, number>();
97
+ for (const { name } of calls) counts.set(name, (counts.get(name) ?? 0) + 1);
98
+ const names = [...counts].map(([name, count]) => count > 1 ? `${name} ×${String(count)}` : name).join(", ");
99
+ const elapsed = formatWorkflowRuntime(Math.max(0, now - Math.min(...calls.map(({ startedAt }) => startedAt))));
100
+ return `${styles.accent(spinner)} tools ${styles.accent("[running]")} ${sanitizeDisplayText(names)} ${styles.dim(`elapsed=${elapsed}`)}`;
101
+ }
93
102
  function formatLogTimestamp(timestamp: number | undefined): string {
94
103
  if (timestamp === undefined || !Number.isFinite(timestamp)) return "--:--:--";
95
104
  const date = new Date(timestamp);
@@ -161,12 +170,15 @@ function workflowProgressLines(run: PersistedRun, spinner: string, styles: Workf
161
170
  const scopedShells = (run.activeShellsByPhase?.length ?? 0) > 0;
162
171
  const shellActivity = scopedShells ? undefined : formatShellActivity(run.activeShells, run.activeShellStartedAt, spinner, styles, now);
163
172
  if (shellActivity) lines.push(` ${shellActivity}`);
173
+ const toolActivity = formatToolActivity(run, spinner, styles, now);
174
+ if (toolActivity) lines.push(` ${toolActivity}`);
164
175
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
165
176
  const position = new Map(run.agents.map((agent, index) => [agent, index]));
166
177
  const renderAgents = (agents: readonly AgentRecord[], nested: boolean) => renderGroupedAgents(agents, ({ agent, depth }, grouped) => {
167
- const icon = agentStateGlyph(agent.state, spinner);
178
+ const icon = agentStateGlyph(agent.state, "●");
168
179
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
169
- const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles, now);
180
+ const idleSpinner = agent.state === "running" && !agentActivityLabel(agent) ? styles.accent(spinner) : "";
181
+ const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : [idleSpinner, formatAgentActivity(agent, spinner, styles, now)].filter(Boolean).join(" ");
170
182
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
171
183
  const state = progressStyleForState(agent.state, styles);
172
184
  const detail = expanded ? formatWorkflowAgentDetail(agent, now) : "";
@@ -469,7 +481,7 @@ export function workflowProgressBlock(run: PersistedRun, theme: Theme, progress?
469
481
  invalidate() {
470
482
  const displayed = currentRun();
471
483
  const now = Date.now();
472
- if (!progress || !refresh || displayed.state !== "running" || (!displayed.agents.some((agent) => agent.state === "running") && (displayed.activeShells ?? 0) <= 0)) return;
484
+ if (!progress || !refresh || displayed.state !== "running" || (!displayed.agents.some((agent) => agent.state === "running") && (displayed.activeShells ?? 0) <= 0 && !displayed.activeTools?.length)) return;
473
485
  if (progress.refresh || now - progress.lastRefreshAt < WORKFLOW_PROGRESS_REFRESH_MS) return;
474
486
  progress.lastRefreshAt = now;
475
487
  const inputRun = progress.inputRun;
@@ -573,7 +585,7 @@ export function formatStalledDuration(durationMs: number): string {
573
585
  const remainingMinutes = minutes % 60;
574
586
  return `${String(hours)}h${remainingMinutes ? ` ${String(remainingMinutes)}m` : ""}`;
575
587
  }
576
- function stalledDuration(agent: Pick<AgentDetailPresentation, "state" | "lastEventAt">, now: number): number | undefined {
588
+ export function stalledDuration(agent: Pick<AgentDetailPresentation, "state" | "lastEventAt">, now: number): number | undefined {
577
589
  if (agent.state !== "running" || agent.lastEventAt === undefined || !Number.isFinite(agent.lastEventAt)) return undefined;
578
590
  const duration = now - agent.lastEventAt;
579
591
  return duration >= WORKFLOW_AGENT_STALL_THRESHOLD_MS ? duration : undefined;
@@ -583,7 +595,7 @@ function elapsedDurationMs(agent: Pick<AgentDetailPresentation, "durationMs" | "
583
595
  if (agent.durationMs !== undefined && Number.isFinite(agent.durationMs)) return Math.max(0, agent.durationMs);
584
596
  return agent.startedAt === undefined || !Number.isFinite(agent.startedAt) ? undefined : Math.max(0, end - agent.startedAt);
585
597
  }
586
- function agentActivityLabel(agent: { readonly activity?: AgentRecord["activity"]; readonly toolCalls?: AgentRecord["toolCalls"] }): string {
598
+ export function agentActivityLabel(agent: { readonly activity?: AgentRecord["activity"]; readonly toolCalls?: AgentRecord["toolCalls"] }): string {
587
599
  const activity = agent.activity;
588
600
  if (activity?.kind === "reasoning" || activity?.kind === "text") {
589
601
  const name = activity.kind === "reasoning" ? "reasoning" : "responding";
@@ -715,6 +727,8 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
715
727
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
716
728
  const shellActivity = formatShellActivity(run.activeShells, run.activeShellStartedAt, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
717
729
  if (shellActivity) lines.push(` ${shellActivity}`);
730
+ const toolActivity = formatToolActivity(run, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
731
+ if (toolActivity) lines.push(` ${toolActivity}`);
718
732
  if (run.error) lines.push(`Error: ${run.error.code}: ${run.error.message}`);
719
733
  if (run.events?.length) lines.push(...run.events.filter((event) => event.type === "warning").map((event) => `Warning: ${event.message}`));
720
734
  lines.push("");
@@ -752,6 +766,8 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
752
766
  ];
753
767
  const shellActivity = formatShellActivity(run.activeShells, run.activeShellStartedAt, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
754
768
  if (shellActivity) lines.push(` ${shellActivity}`);
769
+ const toolActivity = formatToolActivity(run, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
770
+ if (toolActivity) lines.push(` ${toolActivity}`);
755
771
  if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
756
772
  if (run.events?.length) lines.push(...run.events.filter((event) => event.type === "warning").map((event) => `Warning: ${event.message}`));
757
773
  const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
@@ -849,6 +865,8 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
849
865
  const scopedShells = (run.activeShellsByPhase?.length ?? 0) > 0;
850
866
  const shellActivity = scopedShells ? undefined : formatShellActivity(run.activeShells, run.activeShellStartedAt, "⠦", styles, now);
851
867
  if (shellActivity) lines.push(` ${shellActivity}`);
868
+ const toolActivity = formatToolActivity(run, "⠦", styles, now);
869
+ if (toolActivity) lines.push(` ${toolActivity}`);
852
870
  const actionRows = (): string[] => {
853
871
  const actions = selection.actions;
854
872
  if (!actions) return [];