@introspection-ai/recipes 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +184 -0
  3. package/dist/agent-tool.d.ts +42 -0
  4. package/dist/agent-tool.d.ts.map +1 -0
  5. package/dist/agent-tool.js +159 -0
  6. package/dist/agent-tool.js.map +1 -0
  7. package/dist/child-agent-completions.d.ts +61 -0
  8. package/dist/child-agent-completions.d.ts.map +1 -0
  9. package/dist/child-agent-completions.js +135 -0
  10. package/dist/child-agent-completions.js.map +1 -0
  11. package/dist/child-agent-store.d.ts +48 -0
  12. package/dist/child-agent-store.d.ts.map +1 -0
  13. package/dist/child-agent-store.js +98 -0
  14. package/dist/child-agent-store.js.map +1 -0
  15. package/dist/child-agent.d.ts +43 -0
  16. package/dist/child-agent.d.ts.map +1 -0
  17. package/dist/child-agent.js +268 -0
  18. package/dist/child-agent.js.map +1 -0
  19. package/dist/direct-cli.d.ts +9 -0
  20. package/dist/direct-cli.d.ts.map +1 -0
  21. package/dist/direct-cli.js +22 -0
  22. package/dist/direct-cli.js.map +1 -0
  23. package/dist/index.d.ts +14 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +14 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/inspect.d.ts +26 -0
  28. package/dist/inspect.d.ts.map +1 -0
  29. package/dist/inspect.js +70 -0
  30. package/dist/inspect.js.map +1 -0
  31. package/dist/interactions.d.ts +204 -0
  32. package/dist/interactions.d.ts.map +1 -0
  33. package/dist/interactions.js +358 -0
  34. package/dist/interactions.js.map +1 -0
  35. package/dist/mcp-catalog.d.ts +8 -0
  36. package/dist/mcp-catalog.d.ts.map +1 -0
  37. package/dist/mcp-catalog.js +60 -0
  38. package/dist/mcp-catalog.js.map +1 -0
  39. package/dist/mcp-cli-core.d.ts +79 -0
  40. package/dist/mcp-cli-core.d.ts.map +1 -0
  41. package/dist/mcp-cli-core.js +1717 -0
  42. package/dist/mcp-cli-core.js.map +1 -0
  43. package/dist/mcp-cli-help.d.ts +2 -0
  44. package/dist/mcp-cli-help.d.ts.map +1 -0
  45. package/dist/mcp-cli-help.js +36 -0
  46. package/dist/mcp-cli-help.js.map +1 -0
  47. package/dist/mcp-cli-policy.d.ts +17 -0
  48. package/dist/mcp-cli-policy.d.ts.map +1 -0
  49. package/dist/mcp-cli-policy.js +230 -0
  50. package/dist/mcp-cli-policy.js.map +1 -0
  51. package/dist/mcp-cli.d.ts +3 -0
  52. package/dist/mcp-cli.d.ts.map +1 -0
  53. package/dist/mcp-cli.js +33 -0
  54. package/dist/mcp-cli.js.map +1 -0
  55. package/dist/mcp-client.d.ts +3 -0
  56. package/dist/mcp-client.d.ts.map +1 -0
  57. package/dist/mcp-client.js +86 -0
  58. package/dist/mcp-client.js.map +1 -0
  59. package/dist/mcp-command-context.d.ts +13 -0
  60. package/dist/mcp-command-context.d.ts.map +1 -0
  61. package/dist/mcp-command-context.js +29 -0
  62. package/dist/mcp-command-context.js.map +1 -0
  63. package/dist/mcp-contract.d.ts +16 -0
  64. package/dist/mcp-contract.d.ts.map +1 -0
  65. package/dist/mcp-contract.js +306 -0
  66. package/dist/mcp-contract.js.map +1 -0
  67. package/dist/mcp-daemon-client.d.ts +10 -0
  68. package/dist/mcp-daemon-client.d.ts.map +1 -0
  69. package/dist/mcp-daemon-client.js +119 -0
  70. package/dist/mcp-daemon-client.js.map +1 -0
  71. package/dist/mcp-daemon-protocol.d.ts +64 -0
  72. package/dist/mcp-daemon-protocol.d.ts.map +1 -0
  73. package/dist/mcp-daemon-protocol.js +6 -0
  74. package/dist/mcp-daemon-protocol.js.map +1 -0
  75. package/dist/mcp-daemon.d.ts +3 -0
  76. package/dist/mcp-daemon.d.ts.map +1 -0
  77. package/dist/mcp-daemon.js +76880 -0
  78. package/dist/mcp-daemon.js.map +1 -0
  79. package/dist/mcp-run-worker.d.ts +2 -0
  80. package/dist/mcp-run-worker.d.ts.map +1 -0
  81. package/dist/mcp-run-worker.js +76579 -0
  82. package/dist/mcp-run-worker.js.map +1 -0
  83. package/dist/mcp.d.ts +168 -0
  84. package/dist/mcp.d.ts.map +1 -0
  85. package/dist/mcp.js +558 -0
  86. package/dist/mcp.js.map +1 -0
  87. package/dist/pi-extension.d.ts +30 -0
  88. package/dist/pi-extension.d.ts.map +1 -0
  89. package/dist/pi-extension.js +880 -0
  90. package/dist/pi-extension.js.map +1 -0
  91. package/dist/provider-env.d.ts +2 -0
  92. package/dist/provider-env.d.ts.map +1 -0
  93. package/dist/provider-env.js +21 -0
  94. package/dist/provider-env.js.map +1 -0
  95. package/dist/recipe/resolve.d.ts +30 -0
  96. package/dist/recipe/resolve.d.ts.map +1 -0
  97. package/dist/recipe/resolve.js +131 -0
  98. package/dist/recipe/resolve.js.map +1 -0
  99. package/dist/recipe-agent.d.ts +71 -0
  100. package/dist/recipe-agent.d.ts.map +1 -0
  101. package/dist/recipe-agent.js +555 -0
  102. package/dist/recipe-agent.js.map +1 -0
  103. package/dist/recipe-extensions.d.ts +9 -0
  104. package/dist/recipe-extensions.d.ts.map +1 -0
  105. package/dist/recipe-extensions.js +81 -0
  106. package/dist/recipe-extensions.js.map +1 -0
  107. package/dist/recipe-mcp-config.d.ts +15 -0
  108. package/dist/recipe-mcp-config.d.ts.map +1 -0
  109. package/dist/recipe-mcp-config.js +82 -0
  110. package/dist/recipe-mcp-config.js.map +1 -0
  111. package/dist/recipe-model.d.ts +49 -0
  112. package/dist/recipe-model.d.ts.map +1 -0
  113. package/dist/recipe-model.js +299 -0
  114. package/dist/recipe-model.js.map +1 -0
  115. package/dist/recipe-package.d.ts +79 -0
  116. package/dist/recipe-package.d.ts.map +1 -0
  117. package/dist/recipe-package.js +479 -0
  118. package/dist/recipe-package.js.map +1 -0
  119. package/dist/recipe-skills.d.ts +7 -0
  120. package/dist/recipe-skills.d.ts.map +1 -0
  121. package/dist/recipe-skills.js +46 -0
  122. package/dist/recipe-skills.js.map +1 -0
  123. package/dist/run-controller.d.ts +32 -0
  124. package/dist/run-controller.d.ts.map +1 -0
  125. package/dist/run-controller.js +259 -0
  126. package/dist/run-controller.js.map +1 -0
  127. package/dist/run.d.ts +28 -0
  128. package/dist/run.d.ts.map +1 -0
  129. package/dist/run.js +84 -0
  130. package/dist/run.js.map +1 -0
  131. package/dist/session.d.ts +140 -0
  132. package/dist/session.d.ts.map +1 -0
  133. package/dist/session.js +363 -0
  134. package/dist/session.js.map +1 -0
  135. package/dist/test-utils.d.ts +32 -0
  136. package/dist/test-utils.d.ts.map +1 -0
  137. package/dist/test-utils.js +259 -0
  138. package/dist/test-utils.js.map +1 -0
  139. package/dist/testing.d.ts +25 -0
  140. package/dist/testing.d.ts.map +1 -0
  141. package/dist/testing.js +96 -0
  142. package/dist/testing.js.map +1 -0
  143. package/docs/agent-composition.md +196 -0
  144. package/docs/deployment-configuration.md +65 -0
  145. package/docs/index.md +49 -0
  146. package/docs/interactions.md +210 -0
  147. package/docs/mcp-auth.md +80 -0
  148. package/docs/mcp-configuration.md +126 -0
  149. package/docs/migration.md +70 -0
  150. package/docs/pi-extension.md +97 -0
  151. package/docs/python-bindings-release.md +47 -0
  152. package/docs/recipe-evals.md +70 -0
  153. package/docs/recipe-flow.md +41 -0
  154. package/docs/recipe-format.md +186 -0
  155. package/docs/recipe-judges.md +152 -0
  156. package/docs/runtime-library.md +171 -0
  157. package/package.json +106 -0
  158. package/vendor/mcp-client/darwin-arm64/mcp-client +0 -0
  159. package/vendor/mcp-client/darwin-x64/mcp-client +0 -0
  160. package/vendor/mcp-client/linux-arm64/mcp-client +0 -0
  161. package/vendor/mcp-client/linux-x64/mcp-client +0 -0
@@ -0,0 +1,880 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, resolve } from "node:path";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { createRecipeChildAgentRunner, promptResultText, } from "./child-agent.js";
6
+ import { loadRecipeExtensionFactory } from "./recipe-extensions.js";
7
+ import { ChildAgentRunStore, } from "./child-agent-store.js";
8
+ import { ChildCompletionQueue, envelopeFromRun, renderCompletionNotice, } from "./child-agent-completions.js";
9
+ import { clearMcpSession, configureMcpLocalConfigPath, formatMcpConfigurationDiagnostics, materializeMcpSession, materializeSessionMcpCli, resolveAgentMcpSelections, stopMcpDaemon, } from "./mcp.js";
10
+ import { clearMcpCatalogPreload, preloadMcpCatalogs, } from "./mcp-catalog.js";
11
+ import { applyRecipeAgentModelConfigToModel } from "./recipe-model.js";
12
+ import { resolveRecipe, } from "./recipe/resolve.js";
13
+ import { createAgentTool, } from "./agent-tool.js";
14
+ /** Mid-turn completion delivery retries on this cadence until the session idles. */
15
+ const COMPLETION_DELIVERY_RETRY_MS = 100;
16
+ const require = createRequire(import.meta.url);
17
+ class RecipeLaunchError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "RecipeLaunchError";
21
+ }
22
+ }
23
+ function stringFlag(value) {
24
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
25
+ }
26
+ function recipeNotFoundMessage(input, resolvedPath) {
27
+ const lines = [`Recipe "${input}" was not found.`];
28
+ lines.push(`Resolved path: ${resolvedPath}`);
29
+ lines.push("Pass a local Recipe directory containing package.json with a pi block.");
30
+ lines.push("Then launch again with `pi --recipe <recipe>`.");
31
+ return lines.join("\n");
32
+ }
33
+ function recipeLoadErrorMessage(input, reason) {
34
+ return [
35
+ `Recipe "${input}" could not be loaded.`,
36
+ reason,
37
+ "Run `introspection check` from the repository for a validation report.",
38
+ ].join("\n");
39
+ }
40
+ function modelParts(spec) {
41
+ const index = spec.indexOf("/");
42
+ if (index < 0) {
43
+ throw new Error(`Invalid recipe model "${spec}" - expected "<provider>/<model_id>"`);
44
+ }
45
+ return {
46
+ provider: spec.slice(0, index),
47
+ model: spec.slice(index + 1),
48
+ };
49
+ }
50
+ function visibleSubagents(state) {
51
+ return [...state.resolved.subagents.values()];
52
+ }
53
+ function mcpSelectionsForAgent(agent) {
54
+ return resolveAgentMcpSelections(agent.mcp);
55
+ }
56
+ function scopedMcpSelections(state) {
57
+ return [state.resolved.agent, ...visibleSubagents(state)].flatMap(mcpSelectionsForAgent);
58
+ }
59
+ function textResult(text, details) {
60
+ return {
61
+ content: [{ type: "text", text }],
62
+ details,
63
+ };
64
+ }
65
+ function asRecord(value) {
66
+ return value && typeof value === "object" && !Array.isArray(value)
67
+ ? value
68
+ : null;
69
+ }
70
+ function contentText(content) {
71
+ if (typeof content === "string")
72
+ return content;
73
+ if (!Array.isArray(content))
74
+ return "";
75
+ return content
76
+ .map((part) => {
77
+ const record = asRecord(part);
78
+ return record?.type === "text" && typeof record.text === "string"
79
+ ? record.text
80
+ : "";
81
+ })
82
+ .filter(Boolean)
83
+ .join("\n");
84
+ }
85
+ function resultText(result) {
86
+ const record = asRecord(result);
87
+ return contentText(record?.content).trim();
88
+ }
89
+ function themeFg(theme, name, text) {
90
+ return theme?.fg ? theme.fg(name, text) : text;
91
+ }
92
+ function themeBold(theme, text) {
93
+ return theme?.bold ? theme.bold(text) : text;
94
+ }
95
+ function truncateLine(text, max = 120) {
96
+ const singleLine = text.replace(/\s+/g, " ").trim();
97
+ return singleLine.length > max ? `${singleLine.slice(0, max - 3)}...` : singleLine;
98
+ }
99
+ function formatAgentCall(args, theme) {
100
+ const action = args.action ?? "start";
101
+ const agent = args.name ?? args.id ?? "agent";
102
+ const label = args.label ? ` ${themeFg(theme, "muted", `(${args.label})`)}` : "";
103
+ return `${themeFg(theme, "toolTitle", themeBold(theme, `agent ${action}`))} ${themeFg(theme, "accent", agent)}${label}`;
104
+ }
105
+ // Keep the legacy wire value so resumed transcripts retain their renderer.
106
+ const AGENT_COMPLETIONS_TYPE = "recipe-agent-completions";
107
+ function formatDurationMs(ms) {
108
+ const seconds = Math.round(ms / 1000);
109
+ if (seconds < 60)
110
+ return `${seconds}s`;
111
+ const minutes = Math.floor(seconds / 60);
112
+ const rest = seconds % 60;
113
+ return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
114
+ }
115
+ /** Compact TUI rendering for a completion wake-up notice (pi-subagents style). */
116
+ function formatCompletionMessage(batch, options, theme) {
117
+ const blocks = batch.map((envelope) => {
118
+ const icon = envelope.status === "completed"
119
+ ? themeFg(theme, "success", "✓")
120
+ : themeFg(theme, "error", "✗");
121
+ const label = envelope.label
122
+ ? ` ${themeFg(theme, "muted", `(${envelope.label})`)}`
123
+ : "";
124
+ const duration = envelope.duration_ms !== undefined
125
+ ? ` ${themeFg(theme, "muted", `· ${formatDurationMs(envelope.duration_ms)}`)}`
126
+ : "";
127
+ let text = `${icon} ${themeBold(theme, envelope.agent)} ${themeFg(theme, "muted", envelope.id)}${label} ${themeFg(theme, "muted", envelope.status)}${duration}`;
128
+ const preview = (envelope.status === "failed"
129
+ ? envelope.error ?? envelope.output_preview
130
+ : envelope.output_preview)?.trim() ?? "";
131
+ const lines = preview.split("\n").filter((line) => line.trim());
132
+ const shown = options.expanded ? lines : lines.slice(0, 1);
133
+ for (const line of shown.length > 0 ? shown : ["(no output)"]) {
134
+ text += `\n ${themeFg(theme, "muted", `⎿ ${options.expanded ? line : truncateLine(line)}`)}`;
135
+ }
136
+ if (!options.expanded && lines.length > 1) {
137
+ text += `\n ${themeFg(theme, "muted", "⎿ … (expand for full output)")}`;
138
+ }
139
+ return text;
140
+ });
141
+ return blocks.join("\n");
142
+ }
143
+ function snapshotOf(run) {
144
+ return {
145
+ id: run.id,
146
+ agent: run.agent,
147
+ label: run.label,
148
+ prompt: run.prompt,
149
+ status: run.status,
150
+ startedAt: run.startedAt,
151
+ completedAt: run.completedAt,
152
+ output: run.output,
153
+ error: run.error,
154
+ toolCalls: run.toolCalls.map((call) => ({ ...call })),
155
+ };
156
+ }
157
+ function controllerSummary(run, status = run.status) {
158
+ const startedAt = Date.parse(run.startedAt);
159
+ const completedAt = run.completedAt ? Date.parse(run.completedAt) : undefined;
160
+ const currentTool = [...run.toolCalls]
161
+ .reverse()
162
+ .find((call) => call.status === "running")?.name;
163
+ return {
164
+ agent_run_id: run.id,
165
+ invocation_name: run.agent,
166
+ agent_name: run.agent,
167
+ label: run.label ?? run.agent,
168
+ prompt: run.prompt,
169
+ status,
170
+ started_at: Number.isFinite(startedAt) ? startedAt : Date.now(),
171
+ ...(completedAt !== undefined && Number.isFinite(completedAt)
172
+ ? { completed_at: completedAt }
173
+ : {}),
174
+ last_activity_at: completedAt !== undefined && Number.isFinite(completedAt)
175
+ ? completedAt
176
+ : Number.isFinite(startedAt)
177
+ ? startedAt
178
+ : Date.now(),
179
+ ...(currentTool ? { current_tool: currentTool } : {}),
180
+ nested_tools: run.toolCalls.map((call) => ({
181
+ toolName: call.name,
182
+ verb: call.name,
183
+ detail: call.output ?? call.error ?? "",
184
+ ...(asRecord(call.args) ? { toolInput: asRecord(call.args) } : {}),
185
+ })),
186
+ output_preview: run.output,
187
+ output: run.output,
188
+ error: run.error,
189
+ };
190
+ }
191
+ function nameList(names) {
192
+ return names.length > 0 ? names.join(", ") : "(none)";
193
+ }
194
+ function bulletList(items) {
195
+ return items.length > 0 ? items.map((item) => ` - ${item}`) : [" - none"];
196
+ }
197
+ function activeRecipeTools(state, activeTools) {
198
+ const active = new Set(activeTools);
199
+ const recipeTools = new Set(state.resolved.tools);
200
+ return [...recipeTools]
201
+ .filter((tool) => active.has(tool))
202
+ .sort();
203
+ }
204
+ function recipeSummary(state, activeTools) {
205
+ const subagents = visibleSubagents(state).map((agent) => agent.name);
206
+ return [
207
+ "Active Recipe",
208
+ `Name: ${state.resolved.manifest.name}@${state.resolved.manifest.version}`,
209
+ state.resolved.manifest.description ? `Description: ${state.resolved.manifest.description}` : undefined,
210
+ `Agent: ${state.resolved.agentName}`,
211
+ `Model: ${state.resolved.agent.model?.name ?? "(session default)"}`,
212
+ `Thinking level: ${state.resolved.agent.model?.thinkingLevel ?? "(session default)"}`,
213
+ `Subagents: ${nameList(subagents)}`,
214
+ "",
215
+ "Active recipe tools:",
216
+ ...bulletList(activeRecipeTools(state, activeTools)),
217
+ "",
218
+ `Directory: ${state.resolved.recipeDir}`,
219
+ `Workspace: ${state.cwd}`,
220
+ ].filter((line) => line !== undefined).join("\n");
221
+ }
222
+ function applyChildToolEvent(run, event) {
223
+ const existing = run.toolCalls.find((call) => call.id === event.id);
224
+ if (event.type === "start") {
225
+ if (existing) {
226
+ existing.name = event.name;
227
+ existing.args = event.args;
228
+ existing.status = "running";
229
+ existing.completedAt = undefined;
230
+ existing.output = undefined;
231
+ existing.error = undefined;
232
+ return;
233
+ }
234
+ run.toolCalls.push({
235
+ id: event.id,
236
+ name: event.name,
237
+ args: event.args,
238
+ status: "running",
239
+ startedAt: new Date().toISOString(),
240
+ });
241
+ return;
242
+ }
243
+ const call = existing ??
244
+ (() => {
245
+ const created = {
246
+ id: event.id,
247
+ name: event.name,
248
+ args: event.args,
249
+ status: "running",
250
+ startedAt: new Date().toISOString(),
251
+ };
252
+ run.toolCalls.push(created);
253
+ return created;
254
+ })();
255
+ call.name = event.name;
256
+ call.args = event.args;
257
+ if (event.type === "update") {
258
+ const text = resultText(event.partialResult);
259
+ if (text)
260
+ call.output = text;
261
+ return;
262
+ }
263
+ call.completedAt = new Date().toISOString();
264
+ call.status = event.isError ? "failed" : "completed";
265
+ const text = resultText(event.result);
266
+ if (text)
267
+ call.output = text;
268
+ if (event.isError)
269
+ call.error = text || "Tool failed";
270
+ }
271
+ export function createRecipesExtension(opts = {}) {
272
+ const env = opts.env ?? process.env;
273
+ const createChildAgentRunner = opts.createChildAgentRunner ?? createRecipeChildAgentRunner;
274
+ let state = null;
275
+ let lastLaunchErrorKey = null;
276
+ let childRunIndex = 0;
277
+ const childRuns = new Map();
278
+ // Runs restored from a previous Pi process via rehydrateChildRuns(). They
279
+ // have no live runner: readable (status/wait) but not controllable.
280
+ const archivedRuns = new Map();
281
+ let runStore = null;
282
+ // Background completions pending delivery to the parent model.
283
+ const completions = new ChildCompletionQueue();
284
+ // Latest extension context, for the idle check gating completion delivery.
285
+ let sessionCtx = null;
286
+ let localAgentContext = null;
287
+ let sessionConfigurationError = null;
288
+ const visibleAgentDefinitions = new Map();
289
+ function storeFor(cwd) {
290
+ if (runStore?.cwd !== cwd) {
291
+ runStore = { cwd, store: new ChildAgentRunStore(cwd) };
292
+ }
293
+ return runStore.store;
294
+ }
295
+ async function persistRun(cwd, run) {
296
+ try {
297
+ await storeFor(cwd).writeStatus(snapshotOf(run));
298
+ }
299
+ catch {
300
+ // persistence is best-effort; the live run stays authoritative
301
+ }
302
+ }
303
+ function nextChildRunId() {
304
+ return `agent-run-${++childRunIndex}`;
305
+ }
306
+ /**
307
+ * Restore run snapshots persisted under `.pi/agents/` by a previous Pi
308
+ * process, so run ids referenced in a resumed conversation stay resolvable.
309
+ * A run persisted as `running` died with the old process — it is flipped to
310
+ * `interrupted` (never silently "resumed"). Returns the number restored.
311
+ */
312
+ async function rehydrateChildRuns(cwd) {
313
+ const store = storeFor(cwd);
314
+ const persisted = await store.readPersistedSnapshots();
315
+ let restored = 0;
316
+ for (const snapshot of persisted) {
317
+ const indexMatch = /^(?:agent-run|recipe-agent)-(\d+)$/.exec(snapshot.id);
318
+ if (indexMatch) {
319
+ childRunIndex = Math.max(childRunIndex, Number(indexMatch[1]));
320
+ }
321
+ if (childRuns.has(snapshot.id) || archivedRuns.has(snapshot.id)) {
322
+ continue;
323
+ }
324
+ if (snapshot.status === "running") {
325
+ snapshot.status = "interrupted";
326
+ snapshot.completedAt = snapshot.completedAt ?? new Date().toISOString();
327
+ snapshot.error = "Pi session restarted while the run was in flight";
328
+ await persistRun(cwd, snapshot);
329
+ }
330
+ archivedRuns.set(snapshot.id, snapshot);
331
+ restored += 1;
332
+ }
333
+ return restored;
334
+ }
335
+ function findRunSnapshot(id) {
336
+ return childRuns.get(id) ?? archivedRuns.get(id);
337
+ }
338
+ const localRunController = {
339
+ list() {
340
+ return [
341
+ ...childRuns.values(),
342
+ ...[...archivedRuns.values()].filter((snapshot) => !childRuns.has(snapshot.id)),
343
+ ].map((run) => controllerSummary(run));
344
+ },
345
+ get(id) {
346
+ const run = findRunSnapshot(id);
347
+ return run ? controllerSummary(run) : null;
348
+ },
349
+ async start(input) {
350
+ if (!state || !localAgentContext) {
351
+ throw new Error("No recipe session is active");
352
+ }
353
+ const run = await runChildAgent(state, input.name, input.prompt, input.label, localAgentContext, input.onUpdate);
354
+ return controllerSummary(run);
355
+ },
356
+ async wait(id, signal) {
357
+ const run = findRunSnapshot(id);
358
+ if (!run)
359
+ throw new Error(`Unknown agent run: ${id}`);
360
+ if (run.status !== "running" || !childRuns.has(id)) {
361
+ return controllerSummary(run);
362
+ }
363
+ await waitForRun(childRuns.get(id), signal);
364
+ return controllerSummary(findRunSnapshot(id));
365
+ },
366
+ async message(id, message) {
367
+ if (archivedRuns.has(id) && !childRuns.has(id)) {
368
+ throw new Error(`Agent run ${id} belongs to a previous Pi session and cannot be controlled`);
369
+ }
370
+ const run = childRuns.get(id);
371
+ if (!run)
372
+ throw new Error(`Unknown agent run: ${id}`);
373
+ if (run.status === "running") {
374
+ await run.runner.steer(message);
375
+ return controllerSummary(run);
376
+ }
377
+ run.status = "running";
378
+ run.completedAt = undefined;
379
+ run.error = undefined;
380
+ run.output = undefined;
381
+ run.promise = executeChildPrompt(run, message, state?.cwd ?? process.cwd());
382
+ void persistRun(state?.cwd ?? process.cwd(), run);
383
+ return controllerSummary(run);
384
+ },
385
+ async interrupt(id) {
386
+ if (archivedRuns.has(id) && !childRuns.has(id)) {
387
+ throw new Error(`Agent run ${id} belongs to a previous Pi session and cannot be controlled`);
388
+ }
389
+ const run = childRuns.get(id);
390
+ if (!run)
391
+ throw new Error(`Unknown agent run: ${id}`);
392
+ if (run.status === "running") {
393
+ run.status = "interrupted";
394
+ run.completedAt = run.completedAt ?? new Date().toISOString();
395
+ await run.runner.cancel();
396
+ if (state)
397
+ void persistRun(state.cwd, run);
398
+ }
399
+ return controllerSummary(run);
400
+ },
401
+ async close(id) {
402
+ if (archivedRuns.has(id) && !childRuns.has(id)) {
403
+ throw new Error(`Agent run ${id} belongs to a previous Pi session and cannot be controlled`);
404
+ }
405
+ const run = childRuns.get(id);
406
+ if (!run)
407
+ throw new Error(`Unknown agent run: ${id}`);
408
+ if (run.status === "running") {
409
+ run.status = "interrupted";
410
+ await run.runner.cancel();
411
+ await run.promise;
412
+ }
413
+ await run.runner.shutdown();
414
+ childRuns.delete(id);
415
+ return controllerSummary(run, "closed");
416
+ },
417
+ };
418
+ async function closeAllChildRuns() {
419
+ await Promise.all([...childRuns.keys()].map((id) => localRunController.close(id)));
420
+ }
421
+ function archivedControlError(id) {
422
+ return {
423
+ ...textResult(`Agent run ${id} belongs to a previous Pi session and cannot be controlled; only status and wait are available.`, { id }),
424
+ isError: true,
425
+ };
426
+ }
427
+ // Launch selection is immutable for the lifetime of this extension. Resolved
428
+ // values are exported below for shell tools, but must not become inputs to a
429
+ // later session load in the same process.
430
+ const launchRecipeDir = stringFlag(env.PI_RECIPE_DIR);
431
+ const launchAgentName = stringFlag(env.PI_AGENT_NAME);
432
+ function recipeFlag(pi) {
433
+ return stringFlag(pi.getFlag("recipe")) ?? launchRecipeDir;
434
+ }
435
+ function selectedAgentName(pi) {
436
+ return stringFlag(pi.getFlag("agent")) ?? launchAgentName;
437
+ }
438
+ function loadState(pi, cwd) {
439
+ const flag = recipeFlag(pi);
440
+ if (!flag)
441
+ return null;
442
+ const recipeDir = resolve(cwd, flag);
443
+ if (!existsSync(recipeDir)) {
444
+ throw new RecipeLaunchError(recipeNotFoundMessage(flag, recipeDir));
445
+ }
446
+ const requestedAgentName = selectedAgentName(pi);
447
+ const key = [cwd, recipeDir, requestedAgentName ?? ""].join("\0");
448
+ if (state?.key === key)
449
+ return state;
450
+ let resolved;
451
+ try {
452
+ resolved = resolveRecipe({
453
+ recipeDir,
454
+ agentName: requestedAgentName,
455
+ });
456
+ }
457
+ catch (err) {
458
+ throw new RecipeLaunchError(recipeLoadErrorMessage(flag, err instanceof Error ? err.message : String(err)));
459
+ }
460
+ // Keep the recipe selected by CLI flags visible to shell commands and
461
+ // recipe-authored instructions. In production `env` is process.env, so
462
+ // built-in shell tools and child agents inherit these resolved values.
463
+ env.PI_RECIPE_DIR = recipeDir;
464
+ env.PI_AGENT_NAME = resolved.agentName;
465
+ state = {
466
+ key,
467
+ cwd,
468
+ resolved,
469
+ extensionsLoaded: false,
470
+ configured: false,
471
+ };
472
+ return state;
473
+ }
474
+ function safeLoadState(pi, cwd, ctx) {
475
+ try {
476
+ return loadState(pi, cwd);
477
+ }
478
+ catch (err) {
479
+ if (!(err instanceof RecipeLaunchError))
480
+ throw err;
481
+ const key = [cwd, recipeFlag(pi) ?? "", err.message].join("\0");
482
+ if (ctx && lastLaunchErrorKey !== key) {
483
+ ctx.ui.notify(err.message, "warning");
484
+ lastLaunchErrorKey = key;
485
+ }
486
+ return null;
487
+ }
488
+ }
489
+ async function loadRecipeExtensions(pi, ctx, launchState) {
490
+ if (launchState.extensionsLoaded)
491
+ return;
492
+ let loadedCount = 0;
493
+ for (const extensionPath of launchState.resolved.extensionPaths) {
494
+ try {
495
+ const factory = await loadRecipeExtensionFactory(launchState.resolved.recipeDir, extensionPath);
496
+ await factory(pi);
497
+ loadedCount += 1;
498
+ }
499
+ catch (err) {
500
+ const message = err instanceof Error ? err.message : String(err);
501
+ ctx.ui.notify(`Recipe extension failed to load: ${extensionPath}\n${message}`, "warning");
502
+ }
503
+ }
504
+ launchState.extensionsLoaded = true;
505
+ if (launchState.resolved.extensionPaths.length > 0) {
506
+ ctx.ui.notify(`Recipe extensions: ${loadedCount}/${launchState.resolved.extensionPaths.length} loaded`, "info");
507
+ }
508
+ }
509
+ async function configureSession(pi, ctx, launchState) {
510
+ if (launchState.configured)
511
+ return;
512
+ const labelParts = [
513
+ `${launchState.resolved.manifest.name}@${launchState.resolved.manifest.version}`,
514
+ `agent:${launchState.resolved.agentName}`,
515
+ ].filter(Boolean);
516
+ pi.setSessionName(labelParts.join(" "));
517
+ const { provider, model } = modelParts(launchState.resolved.modelSpec);
518
+ const lookupProvider = provider === "gemini" ? "google" : provider;
519
+ const resolvedModel = ctx.modelRegistry.find(lookupProvider, model);
520
+ if (!resolvedModel) {
521
+ throw new Error(`Recipe model is not available: ${launchState.resolved.modelSpec}`);
522
+ }
523
+ applyRecipeAgentModelConfigToModel(resolvedModel, launchState.resolved.modelConfig);
524
+ const ok = await pi.setModel(resolvedModel);
525
+ if (!ok) {
526
+ throw new Error(`Recipe model has no configured API key: ${launchState.resolved.modelSpec}`);
527
+ }
528
+ if (launchState.resolved.thinkingLevel) {
529
+ pi.setThinkingLevel(launchState.resolved.thinkingLevel);
530
+ }
531
+ const activeTools = new Set(launchState.resolved.tools);
532
+ pi.setActiveTools([...activeTools]);
533
+ launchState.configured = true;
534
+ sessionConfigurationError = null;
535
+ }
536
+ async function configureMcp(launchState, ctx) {
537
+ const mcpSelections = scopedMcpSelections(launchState);
538
+ if (mcpSelections.length === 0) {
539
+ await clearMcpSession(env, launchState.cwd);
540
+ return;
541
+ }
542
+ configureMcpLocalConfigPath({
543
+ cwd: launchState.cwd,
544
+ recipeDir: launchState.resolved.recipeDir,
545
+ env,
546
+ });
547
+ const [, session] = await Promise.all([
548
+ materializeSessionMcpCli({
549
+ cwd: launchState.cwd,
550
+ env,
551
+ }),
552
+ materializeMcpSession({
553
+ cwd: launchState.cwd,
554
+ manifest: launchState.resolved.manifest,
555
+ agentMcp: mcpSelections,
556
+ env,
557
+ }),
558
+ ]);
559
+ if (session.servers.length > 0) {
560
+ void preloadMcpCatalogs({ env }).catch((error) => {
561
+ console.warn(`Recipe MCP catalog preload failed: ${error instanceof Error ? error.message : String(error)}`);
562
+ });
563
+ ctx.ui.notify(`Recipe MCP: ${session.servers.length} server(s) configured; runtime warming in background`, "info");
564
+ const detail = formatMcpConfigurationDiagnostics(session.diagnostics ?? []);
565
+ if (detail) {
566
+ ctx.ui.notify([
567
+ "Recipe MCP: some configured servers or tools were filtered.",
568
+ "",
569
+ detail,
570
+ ].join("\n"), "warning");
571
+ }
572
+ }
573
+ else {
574
+ const detail = formatMcpConfigurationDiagnostics(session.diagnostics ?? []);
575
+ ctx.ui.notify([
576
+ "Recipe MCP: no servers are available to this agent. Check package policy and .pi/mcp.local.json.",
577
+ ...(detail ? ["", detail] : []),
578
+ ].join("\n"), "warning");
579
+ }
580
+ }
581
+ async function runChildAgent(launchState, agentName, prompt, label, ctx, onUpdate) {
582
+ const id = nextChildRunId();
583
+ let run;
584
+ const notifyUpdate = () => {
585
+ if (!run || !onUpdate)
586
+ return;
587
+ try {
588
+ void Promise.resolve(onUpdate(controllerSummary(run))).catch(() => { });
589
+ }
590
+ catch {
591
+ // Detached runs can outlive the parent tool call. Late UI updates are
592
+ // cosmetic and must not stop the child or completion delivery.
593
+ }
594
+ };
595
+ const runner = createChildAgentRunner({
596
+ recipeDir: launchState.resolved.recipeDir,
597
+ workspaceDir: launchState.cwd,
598
+ env,
599
+ agentName,
600
+ modelRegistry: ctx.modelRegistry,
601
+ onAssistantMessage(text, stream) {
602
+ if (!run)
603
+ return;
604
+ if (stream === "delta") {
605
+ run.output = `${run.output ?? ""}${text}`;
606
+ }
607
+ else if (!run.output?.trim()) {
608
+ run.output = text;
609
+ }
610
+ notifyUpdate();
611
+ },
612
+ onToolEvent(event) {
613
+ if (!run)
614
+ return;
615
+ applyChildToolEvent(run, event);
616
+ notifyUpdate();
617
+ },
618
+ });
619
+ run = {
620
+ id,
621
+ agent: agentName,
622
+ label,
623
+ prompt,
624
+ status: "running",
625
+ startedAt: new Date().toISOString(),
626
+ toolCalls: [],
627
+ runner,
628
+ promise: Promise.resolve(undefined),
629
+ notifyUpdate,
630
+ };
631
+ notifyUpdate();
632
+ void persistRun(launchState.cwd, run);
633
+ run.promise = executeChildPrompt(run, prompt, launchState.cwd);
634
+ childRuns.set(id, run);
635
+ return run;
636
+ }
637
+ function executeChildPrompt(run, prompt, cwd) {
638
+ return (async () => {
639
+ try {
640
+ await run.runner.start();
641
+ const result = await run.runner.prompt(prompt);
642
+ const finalOutput = promptResultText(result);
643
+ if (finalOutput && finalOutput.length >= (run.output?.length ?? 0)) {
644
+ run.output = finalOutput;
645
+ }
646
+ else if (!run.output?.trim()) {
647
+ run.output = "(no final response)";
648
+ }
649
+ if (run.status === "running")
650
+ run.status = "completed";
651
+ }
652
+ catch (err) {
653
+ if (run.status !== "interrupted")
654
+ run.status = "failed";
655
+ run.error = err instanceof Error ? err.message : String(err);
656
+ }
657
+ finally {
658
+ run.completedAt = new Date().toISOString();
659
+ // Queue the parent wake-up BEFORE persisting. The deliverer reads the
660
+ // in-memory queue (not disk), so enqueuing first makes a persisted
661
+ // status.json imply the completion is already queued — closing a race
662
+ // where the agent_end poke could observe the run's status.json but hit
663
+ // an empty queue, deferring delivery to the full batch window. It also
664
+ // still notifies if the disk write fails. Wait and terminal status
665
+ // reads acknowledge it back out.
666
+ const envelope = envelopeFromRun(run);
667
+ if (envelope)
668
+ completions.enqueue(envelope);
669
+ run.notifyUpdate();
670
+ await persistRun(cwd, run);
671
+ }
672
+ return run;
673
+ })();
674
+ }
675
+ async function waitForRun(run, signal) {
676
+ if (!signal) {
677
+ await run.promise;
678
+ return;
679
+ }
680
+ if (signal.aborted)
681
+ return;
682
+ let onAbort = () => { };
683
+ const aborted = new Promise((resolve) => {
684
+ onAbort = resolve;
685
+ signal.addEventListener("abort", onAbort, { once: true });
686
+ });
687
+ await Promise.race([run.promise, aborted]);
688
+ signal.removeEventListener("abort", onAbort);
689
+ }
690
+ return (pi) => {
691
+ // Deliver queued background completions by waking the parent model with
692
+ // a triggerTurn message. Delivery only happens when the session is
693
+ // genuinely idle: a message queued while the parent turn is streaming
694
+ // (or tearing down at the agent_end boundary) would be stranded in pi's
695
+ // queues, and holding until idle also gives synchronous readers
696
+ // (wait or terminal status) time to acknowledge results
697
+ // the model already saw. While mid-turn, retry on a short timer armed by
698
+ // the agent_end poke.
699
+ let deliveryRetryTimer = null;
700
+ const deliverCompletions = () => {
701
+ if (deliveryRetryTimer) {
702
+ clearTimeout(deliveryRetryTimer);
703
+ deliveryRetryTimer = null;
704
+ }
705
+ if (!completions.hasPending())
706
+ return;
707
+ // The retry timer can outlive this extension instance across a reload,
708
+ // where pi API calls throw as stale. Never crash the timer callback;
709
+ // the reload rebuilds run state and drops the batch with it.
710
+ try {
711
+ if (!(sessionCtx?.isIdle?.() ?? true)) {
712
+ deliveryRetryTimer = setTimeout(deliverCompletions, COMPLETION_DELIVERY_RETRY_MS);
713
+ deliveryRetryTimer.unref?.();
714
+ return;
715
+ }
716
+ const batch = completions.consumeBatch();
717
+ if (batch.length === 0)
718
+ return;
719
+ pi.sendMessage({
720
+ customType: AGENT_COMPLETIONS_TYPE,
721
+ content: renderCompletionNotice(batch),
722
+ display: true,
723
+ details: { completions: batch },
724
+ },
725
+ // followUp keeps a wake race-safe: if a user turn started between
726
+ // the idle check and here, the notice queues behind it.
727
+ { triggerTurn: true, deliverAs: "followUp" });
728
+ }
729
+ catch {
730
+ // swallow — see above
731
+ }
732
+ };
733
+ completions.setDeliverer(deliverCompletions);
734
+ pi.on("agent_end", (_event, ctx) => {
735
+ sessionCtx = ctx;
736
+ completions.poke();
737
+ });
738
+ pi.registerMessageRenderer(AGENT_COMPLETIONS_TYPE, (message, options, theme) => {
739
+ const batch = message.details
740
+ ?.completions;
741
+ if (!batch?.length)
742
+ return undefined;
743
+ return new Text(formatCompletionMessage(batch, options, theme), 0, 0);
744
+ });
745
+ pi.registerFlag("recipe", {
746
+ description: "Recipe folder, installed recipe name, or installed recipe source to use for this Pi session",
747
+ type: "string",
748
+ });
749
+ pi.registerFlag("agent", {
750
+ description: "Agent to use",
751
+ type: "string",
752
+ });
753
+ pi.registerCommand("recipe", {
754
+ description: "Inspect or reload the active recipe",
755
+ handler: async (args, ctx) => {
756
+ const action = args.trim();
757
+ if (action === "reload") {
758
+ const launchState = safeLoadState(pi, ctx.cwd, ctx);
759
+ if (!launchState) {
760
+ if (!recipeFlag(pi)) {
761
+ ctx.ui.notify("No recipe is active. Launch Pi with --recipe <recipe>.", "info");
762
+ }
763
+ return;
764
+ }
765
+ await closeAllChildRuns();
766
+ state = null;
767
+ archivedRuns.clear();
768
+ completions.clear();
769
+ await ctx.waitForIdle();
770
+ await ctx.reload();
771
+ ctx.ui.notify(`Recipe reload requested: ${launchState.resolved.manifest.name}@${launchState.resolved.manifest.version}`, "info");
772
+ return;
773
+ }
774
+ if (action) {
775
+ ctx.ui.notify("Usage: /recipe [reload]", "info");
776
+ return;
777
+ }
778
+ const launchState = safeLoadState(pi, ctx.cwd, ctx);
779
+ if (!launchState) {
780
+ if (!recipeFlag(pi)) {
781
+ ctx.ui.notify("No recipe is active. Launch Pi with --recipe <recipe>.", "info");
782
+ }
783
+ return;
784
+ }
785
+ ctx.ui.notify(recipeSummary(launchState, pi.getActiveTools()), "info");
786
+ },
787
+ });
788
+ const agentTool = createAgentTool(localRunController, visibleAgentDefinitions, {
789
+ acknowledgeCompletions: (ids) => completions.acknowledge(ids),
790
+ });
791
+ agentTool.label = "Agent";
792
+ agentTool.renderCall = (params, theme, context) => {
793
+ const text = context.lastComponent ?? new Text("", 0, 0);
794
+ text.setText(formatAgentCall(params, theme));
795
+ return text;
796
+ };
797
+ pi.registerTool(agentTool);
798
+ pi.on("session_start", async (_event, ctx) => {
799
+ sessionCtx = ctx;
800
+ localAgentContext = ctx;
801
+ const launchState = safeLoadState(pi, ctx.cwd, ctx);
802
+ if (!launchState)
803
+ return;
804
+ visibleAgentDefinitions.clear();
805
+ for (const [name, definition] of launchState.resolved.subagents) {
806
+ visibleAgentDefinitions.set(name, definition);
807
+ }
808
+ await loadRecipeExtensions(pi, ctx, launchState);
809
+ try {
810
+ await configureMcp(launchState, ctx);
811
+ }
812
+ catch (err) {
813
+ ctx.ui.notify(`Recipe MCP failed to configure: ${err instanceof Error ? err.message : String(err)}`, "warning");
814
+ return;
815
+ }
816
+ try {
817
+ await configureSession(pi, ctx, launchState);
818
+ }
819
+ catch (err) {
820
+ const message = err instanceof Error ? err.message : String(err);
821
+ // A recipe's declared model is part of its behavior contract. Continuing
822
+ // with Pi's previously active model can produce plausible but invalid
823
+ // results, so stop the session instead of silently falling back.
824
+ sessionConfigurationError = message;
825
+ pi.setActiveTools([]);
826
+ ctx.ui.notify(`Recipe session cannot start: ${message}`, "warning");
827
+ if (ctx.mode === "json" || ctx.mode === "print") {
828
+ process.exitCode = 1;
829
+ }
830
+ return;
831
+ }
832
+ // Restore run snapshots persisted by a previous Pi process so run ids
833
+ // referenced in a resumed conversation stay resolvable (read-only).
834
+ try {
835
+ const restored = await rehydrateChildRuns(launchState.cwd);
836
+ if (restored > 0) {
837
+ ctx.ui.notify(`Agents: rehydrated ${restored} previous run(s) (read-only)`, "info");
838
+ }
839
+ }
840
+ catch {
841
+ // rehydration is best-effort
842
+ }
843
+ ctx.ui.notify(`Recipe: ${launchState.resolved.manifest.name}@${launchState.resolved.manifest.version} (${basename(launchState.resolved.recipeDir)})`, "info");
844
+ });
845
+ pi.on("session_shutdown", async () => {
846
+ await closeAllChildRuns();
847
+ clearMcpCatalogPreload(env);
848
+ await stopMcpDaemon(env);
849
+ });
850
+ pi.on("agent_start", (_event, ctx) => {
851
+ if (!sessionConfigurationError)
852
+ return;
853
+ // setModel(false) leaves Pi's previously active model selected. Abort as
854
+ // soon as the agent loop starts so that model can never receive a recipe
855
+ // prompt after recipe configuration failed.
856
+ ctx.abort();
857
+ });
858
+ pi.on("resources_discover", (event) => {
859
+ const launchState = safeLoadState(pi, event.cwd);
860
+ if (!launchState)
861
+ return {};
862
+ return {
863
+ skillPaths: launchState.resolved.skillPaths,
864
+ promptPaths: launchState.resolved.promptPaths,
865
+ };
866
+ });
867
+ pi.on("before_agent_start", (event, ctx) => {
868
+ const launchState = safeLoadState(pi, ctx.cwd, ctx);
869
+ if (!launchState)
870
+ return {};
871
+ return {
872
+ systemPrompt: launchState.resolved.systemPromptOverride(event.systemPrompt),
873
+ };
874
+ });
875
+ };
876
+ }
877
+ /** @deprecated Use `createRecipesExtension`. */
878
+ export const createPiRecipesExtension = createRecipesExtension;
879
+ export default createRecipesExtension();
880
+ //# sourceMappingURL=pi-extension.js.map