pi-extensible-workflows 3.2.0 → 3.4.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 (47) hide show
  1. package/README.md +2 -31
  2. package/dist/src/agent-execution.d.ts +67 -9
  3. package/dist/src/agent-execution.js +385 -141
  4. package/dist/src/bundles.d.ts +53 -0
  5. package/dist/src/bundles.js +457 -0
  6. package/dist/src/cli.d.ts +3 -1
  7. package/dist/src/cli.js +156 -22
  8. package/dist/src/doctor-cleanup.js +68 -31
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.d.ts +1 -1
  11. package/dist/src/execution.js +29 -13
  12. package/dist/src/host.d.ts +12 -9
  13. package/dist/src/host.js +525 -195
  14. package/dist/src/index.d.ts +5 -4
  15. package/dist/src/index.js +3 -2
  16. package/dist/src/persistence.d.ts +43 -8
  17. package/dist/src/persistence.js +54 -0
  18. package/dist/src/registry.d.ts +3 -2
  19. package/dist/src/registry.js +16 -4
  20. package/dist/src/session-inspector.d.ts +12 -2
  21. package/dist/src/session-inspector.js +50 -24
  22. package/dist/src/types.d.ts +141 -60
  23. package/dist/src/types.js +1 -0
  24. package/dist/src/validation.js +28 -7
  25. package/dist/src/workflow-artifacts.d.ts +1 -0
  26. package/dist/src/workflow-artifacts.js +1 -0
  27. package/dist/src/workflow-evals.d.ts +7 -1
  28. package/dist/src/workflow-evals.js +23 -3
  29. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  30. package/evals/cases/recovery-failed-run.yaml +14 -0
  31. package/package.json +1 -1
  32. package/skills/pi-extensible-workflows/SKILL.md +13 -5
  33. package/src/agent-execution.ts +302 -107
  34. package/src/bundles.ts +471 -0
  35. package/src/cli.ts +130 -22
  36. package/src/doctor-cleanup.ts +38 -4
  37. package/src/eval-capture-extension.ts +15 -1
  38. package/src/execution.ts +27 -15
  39. package/src/host.ts +454 -175
  40. package/src/index.ts +5 -4
  41. package/src/persistence.ts +58 -4
  42. package/src/registry.ts +14 -5
  43. package/src/session-inspector.ts +49 -26
  44. package/src/types.ts +55 -30
  45. package/src/validation.ts +19 -6
  46. package/src/workflow-artifacts.ts +1 -0
  47. package/src/workflow-evals.ts +24 -3
package/src/bundles.ts ADDED
@@ -0,0 +1,471 @@
1
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { AgentDefinition, WorkflowCatalogFunction } from "./types.js";
6
+
7
+ export interface PortableWorkflowManifest {
8
+ format: "pi-extensible-workflows-bundle";
9
+ version: 1;
10
+ command: string;
11
+ workflow: { name: string; description: string; input: Record<string, unknown>; output: Record<string, unknown> };
12
+ runtime: { pi: string; "pi-extensible-workflows": string };
13
+ requirements: { roles: readonly string[]; aliases: readonly string[]; tools: readonly string[]; commands: readonly string[]; environment: readonly string[] };
14
+ aliasTargets?: Readonly<Record<string, string>>;
15
+ payload?: { extensions?: readonly string[]; skills?: readonly string[]; static?: readonly string[]; dependencies?: readonly string[] };
16
+ }
17
+
18
+ export interface PortableWorkflowBundleResources {
19
+ extensions?: readonly string[];
20
+ skills?: readonly string[];
21
+ static?: readonly string[];
22
+ dependencies?: readonly string[];
23
+ }
24
+
25
+ export interface PortableWorkflowBundleInput {
26
+ destination: string;
27
+ command: string;
28
+ workflow: WorkflowCatalogFunction;
29
+ functionSource: string;
30
+ piVersion?: string;
31
+ engineVersion?: string;
32
+ force?: boolean;
33
+ requirements?: Partial<PortableWorkflowManifest["requirements"]>;
34
+ aliasTargets?: Readonly<Record<string, string>>;
35
+ roles?: Readonly<Record<string, AgentDefinition>>;
36
+ resources?: PortableWorkflowBundleResources;
37
+ }
38
+
39
+ function packageJson(): Record<string, unknown> {
40
+ const directory = dirname(fileURLToPath(import.meta.url));
41
+ for (const path of [join(directory, "../package.json"), join(directory, "../../package.json")]) {
42
+ try { return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>; } catch { /* Try the source and built layouts. */ }
43
+ }
44
+ return {};
45
+ }
46
+
47
+ export function portableEngineVersion(): string {
48
+ const version = packageJson().version;
49
+ return typeof version === "string" && version.trim() ? version.trim() : "unknown";
50
+ }
51
+
52
+ export function portablePiVersion(): string {
53
+ const command = process.platform === "win32" ? "pi.cmd" : "pi";
54
+ const result = spawnSync(command, ["--version"], { encoding: "utf8" });
55
+ if (result.error || result.status !== 0) return "unknown";
56
+ return result.stdout.trim().split(/\r?\n/, 1)[0] ?? "unknown";
57
+ }
58
+
59
+ function shellLauncher(): string {
60
+ return "#!/bin/sh\nset -eu\nROOT=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\nexec node \"$ROOT/payload/runner.mjs\" \"$@\"\n";
61
+ }
62
+
63
+ function windowsLauncher(): string {
64
+ return "@echo off\r\nnode \"%~dp0payload\\runner.mjs\" %*\r\n";
65
+ }
66
+
67
+ function runnerSource(): string {
68
+ return [
69
+ "import { accessSync, constants, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';",
70
+ "import { homedir } from 'node:os';",
71
+ "import { delimiter, dirname, join, sep } from 'node:path';",
72
+ "import { createInterface } from 'node:readline/promises';",
73
+ "import { spawnSync } from 'node:child_process';",
74
+ "import { fileURLToPath, pathToFileURL } from 'node:url';",
75
+ "const bundleRoot = dirname(dirname(fileURLToPath(import.meta.url)));",
76
+ "const manifest = JSON.parse(readFileSync(join(bundleRoot, 'manifest.json'), 'utf8'));",
77
+ "function bundleSkillPaths() { return (manifest.payload?.skills ?? []).map((name) => join(bundleRoot, 'payload', 'skills', name)); }",
78
+ "function run(command, args) {",
79
+ " const result = spawnSync(command, args, { encoding: 'utf8' });",
80
+ " if (result.error) throw result.error;",
81
+ " return { status: result.status, stdout: String(result.stdout ?? ''), stderr: String(result.stderr ?? '') };",
82
+ "}",
83
+ "function piCommand() {",
84
+ " const names = process.platform === 'win32' ? ['pi.cmd', 'pi'] : ['pi'];",
85
+ " for (const entry of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) {",
86
+ " for (const name of names) {",
87
+ " const candidate = join(entry, name);",
88
+ " try { accessSync(candidate, constants.X_OK); return candidate; } catch { /* Continue searching PATH. */ }",
89
+ " }",
90
+ " }",
91
+ " throw new Error('Pi was not found on PATH. Install Pi through npm before running this bundle.');",
92
+ "}",
93
+ "function packageRoot(start) {",
94
+ " let current = dirname(realpathSync(start));",
95
+ " while (true) {",
96
+ " const candidates = [current, join(current, '..', '@earendil-works', 'pi-coding-agent'), join(current, '..', 'pi-coding-agent')];",
97
+ " for (const candidate of candidates) { try { const pkg = JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8')); if (pkg.name === '@earendil-works/pi-coding-agent') return candidate; } catch { /* Continue to the next package candidate. */ } }",
98
+ " const parent = dirname(current);",
99
+ " if (parent === current) return undefined;",
100
+ " current = parent;",
101
+ " }",
102
+ "}",
103
+ "function assertNpmPi(pi) {",
104
+ " const resolved = realpathSync(pi);",
105
+ " if (!resolved.includes(`${sep}node_modules${sep}`) || !packageRoot(pi)) throw new Error('The pi executable is not an npm installation. Install Pi through npm and retry.');",
106
+ "}",
107
+ "function codingAgentIndex(pi) {",
108
+ " const root = packageRoot(pi);",
109
+ " const index = root && join(root, 'dist', 'index.js');",
110
+ " if (!index || !existsSync(index)) throw new Error('The npm Pi installation does not expose its SDK. Reinstall Pi through npm and retry.');",
111
+ " return index;",
112
+ "}",
113
+ "function packageVersion(root) {",
114
+ " try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; } catch { return undefined; }",
115
+ "}",
116
+ "function versionParts(value) {",
117
+ " const match = /(?:^|[^0-9])(\\d+)\\.(\\d+)\\.(\\d+)(?:$|[^0-9])/.exec(String(value));",
118
+ " return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;",
119
+ "}",
120
+ "function compare(left, right) { return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]; }",
121
+ "function rangeClauses(range) {",
122
+ " const clauses = String(range).trim().split(/\\s+/).map((token) => /^(>=|<=|>|<|=|~|\\^)?(\\d+)\\.(\\d+)\\.(\\d+)$/.exec(token));",
123
+ " return clauses.every(Boolean) ? clauses.map((match) => ({ operator: match[1] ?? '=', version: [Number(match[2]), Number(match[3]), Number(match[4])] })) : undefined;",
124
+ "}",
125
+ "function satisfies(value, range) {",
126
+ " if (range === 'unknown') return true;",
127
+ " const actual = versionParts(value);",
128
+ " const clauses = rangeClauses(range);",
129
+ " if (!actual || !clauses) return false;",
130
+ " return clauses.every(({ operator, version }) => {",
131
+ " if (operator === '=') return compare(actual, version) === 0;",
132
+ " if (operator === '>') return compare(actual, version) > 0;",
133
+ " if (operator === '>=') return compare(actual, version) >= 0;",
134
+ " if (operator === '<') return compare(actual, version) < 0;",
135
+ " if (operator === '<=') return compare(actual, version) <= 0;",
136
+ " const upper = operator === '~' ? [version[0], version[1] + 1, 0] : version[0] > 0 ? [version[0] + 1, 0, 0] : version[1] > 0 ? [0, version[1] + 1, 0] : [0, 0, version[2] + 1];",
137
+ " return compare(actual, version) >= 0 && compare(actual, upper) < 0;",
138
+ " });",
139
+ "}",
140
+ "function installationVersion(range) { return String(range).match(/\\d+\\.\\d+\\.\\d+/)?.[0] ?? 'unknown'; }",
141
+ "async function engineCandidates(pi) {",
142
+ " const agent = await import(pathToFileURL(codingAgentIndex(pi)).href);",
143
+ " const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');",
144
+ " const settings = agent.SettingsManager.create(process.cwd(), agentDir, { projectTrusted: false });",
145
+ " const manager = new agent.DefaultPackageManager({ cwd: process.cwd(), agentDir, settingsManager: settings });",
146
+ " const configured = manager.listConfiguredPackages();",
147
+ " const roots = configured.filter((entry) => /^npm:(?:@[^/]+\\/)?pi-extensible-workflows(?:@|$)/.test(entry.source)).map((entry) => entry.installedPath);",
148
+ " roots.push(join(agentDir, 'npm', 'node_modules', 'pi-extensible-workflows'));",
149
+ " return [...new Set(roots.filter((root) => typeof root === 'string' && existsSync(join(root, 'package.json'))))];",
150
+ "}",
151
+ "async function findEngine(pi) {",
152
+ " for (const root of await engineCandidates(pi)) {",
153
+ " const version = packageVersion(root);",
154
+ " if (typeof version === 'string' && satisfies(version, manifest.runtime['pi-extensible-workflows'])) return { root: realpathSync(root), version };",
155
+ " }",
156
+ " return undefined;",
157
+ "}",
158
+ "async function confirmInstall(pi, expected) {",
159
+ " const spec = `npm:pi-extensible-workflows@${installationVersion(expected)}`;",
160
+ " if (!(process.stdin.isTTY && process.stderr.isTTY)) throw new Error(`The compatible pi-extensible-workflows package is missing. Re-run '${manifest.command} setup --yes' to approve: ${pi} install ${spec}`);",
161
+ " const prompt = createInterface({ input: process.stdin, output: process.stderr });",
162
+ " try { const answer = await prompt.question(`Install ${spec} through Pi now? [y/N] `); return /^y(es)?$/i.test(answer.trim()); } finally { prompt.close(); }",
163
+ "}",
164
+ "async function ensureEngine(pi, allowInstall, approve) {",
165
+ " const expected = manifest.runtime['pi-extensible-workflows'];",
166
+ " let engine = await findEngine(pi);",
167
+ " if (engine) return engine;",
168
+ " if (!allowInstall) throw new Error(`Compatible pi-extensible-workflows${expected === 'unknown' ? '' : `@${expected}`} is not installed through Pi. Run '${manifest.command} setup' first; no installation is performed during launch.`);",
169
+ " if (expected === 'unknown') throw new Error('The bundle does not record a compatible pi-extensible-workflows version. Re-export the bundle.');",
170
+ " if (!approve && !(await confirmInstall(pi, expected))) throw new Error('Installation was not approved.');",
171
+ " const spec = `npm:pi-extensible-workflows@${installationVersion(expected)}`;",
172
+ " if (spec.endsWith('@unknown')) throw new Error('The bundle does not record a compatible pi-extensible-workflows version. Re-export the bundle.');",
173
+ " const result = run(pi, ['install', spec]);",
174
+ " if (result.status !== 0) throw new Error(`Pi could not install ${spec}: ${result.stderr.trim() || 'installation failed'}`);",
175
+ " engine = await findEngine(pi);",
176
+ " if (!engine) throw new Error(`Pi installed an incompatible pi-extensible-workflows version; expected ${expected}.`);",
177
+ " return engine;",
178
+ "}",
179
+ "function readJson(path) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; } }",
180
+ "function projectTrusted(agent, agentDir) {",
181
+ " const settings = agent.SettingsManager.create(process.cwd(), agentDir, { projectTrusted: false });",
182
+ " if (!agent.hasTrustRequiringProjectResources(process.cwd())) return true;",
183
+ " const saved = new agent.ProjectTrustStore(agentDir).get(process.cwd());",
184
+ " return saved === true || saved === null && settings.getDefaultProjectTrust() === 'always';",
185
+ "}",
186
+ "function workflowSettings(agent, agentDir) {",
187
+ " const global = readJson(join(agentDir, 'pi-extensible-workflows', 'settings.json'));",
188
+ " const project = projectTrusted(agent, agentDir) ? readJson(join(process.cwd(), '.pi', 'pi-extensible-workflows', 'settings.json')) : {};",
189
+ " return { ...(global.modelAliases ?? {}), ...(project.modelAliases ?? {}) };",
190
+ "}",
191
+ "function concreteModel(value) { return String(value).split(':', 1)[0]; }",
192
+ "function qualifyModel(value, known) {",
193
+ " const concrete = concreteModel(value);",
194
+ " if (known.has(concrete)) return concrete;",
195
+ " const matches = [...known].filter((model) => model.endsWith('/' + concrete));",
196
+ " return matches.length === 1 ? matches[0] : concrete;",
197
+ "}",
198
+ "function resolveAlias(name, targets, settings, known, chain = []) {",
199
+ " const target = targets[name] ?? settings[name] ?? (known.has(name) ? name : undefined);",
200
+ " if (!target) { const matches = [...known].filter((model) => model.endsWith('/' + name)); return matches.length === 1 ? matches[0] : undefined; }",
201
+ " if (chain.includes(name)) throw new Error(`Model alias cycle: ${[...chain, name].join(' -> ')}`);",
202
+ " const concrete = concreteModel(target);",
203
+ " return targets[concrete] || settings[concrete] ? resolveAlias(concrete, targets, settings, known, [...chain, name]) : qualifyModel(concrete, known);",
204
+ "}",
205
+ "async function recipientInventory(pi) {",
206
+ " const agent = await import(pathToFileURL(codingAgentIndex(pi)).href);",
207
+ " const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');",
208
+ " const modelRuntime = await agent.ModelRuntime.create({ authPath: join(agentDir, 'auth.json'), modelsPath: join(agentDir, 'models.json') });",
209
+ " const services = await agent.createAgentSessionServices({ cwd: process.cwd(), agentDir, modelRuntime, resourceLoaderOptions: { additionalSkillPaths: bundleSkillPaths(), noPromptTemplates: true, noThemes: true, noContextFiles: true } });",
210
+ " const knownModels = new Set(services.modelRuntime.getModels().map((model) => `${model.provider}/${model.id}`));",
211
+ " const availableModels = new Set((await services.modelRuntime.getAvailable()).map((model) => `${model.provider}/${model.id}`));",
212
+ " const sdkRoot = packageRoot(pi);",
213
+ " const toolsIndex = sdkRoot && join(sdkRoot, 'dist', 'core', 'tools', 'index.js');",
214
+ " if (!toolsIndex || !existsSync(toolsIndex)) throw new Error('The npm Pi installation does not expose its built-in tool inventory. Reinstall Pi through npm and retry.');",
215
+ " const toolsModule = await import(pathToFileURL(toolsIndex).href);",
216
+ " if (!(toolsModule.allToolNames instanceof Set)) throw new Error('The npm Pi installation does not expose its built-in tool inventory. Reinstall Pi through npm and retry.');",
217
+ " const tools = new Set(toolsModule.allToolNames);",
218
+ " for (const extension of services.resourceLoader.getExtensions().extensions) for (const tool of extension.tools.keys()) tools.add(tool);",
219
+ " return { agent, agentDir, knownModels, availableModels, tools };",
220
+ "}",
221
+ "async function dynamicAliasTargets(api, inventory, settings) {",
222
+ " const targets = {};",
223
+ " const registry = typeof api.loadingRegistry === 'function' ? api.loadingRegistry() : undefined;",
224
+ " const aliases = new Map((registry?.modelAliases?.() ?? []).map((alias) => [alias.name, alias]));",
225
+ " const first = [...inventory.knownModels][0] ?? 'unknown/unknown';",
226
+ " const separator = first.indexOf('/');",
227
+ " const rootModel = { provider: separator < 0 ? '' : first.slice(0, separator), model: separator < 0 ? first : first.slice(separator + 1) };",
228
+ " for (const name of manifest.requirements.aliases) {",
229
+ " if (manifest.aliasTargets?.[name] || settings[name]) continue;",
230
+ " const alias = aliases.get(name);",
231
+ " if (!alias) continue;",
232
+ " const target = await alias.resolve({ cwd: process.cwd(), projectTrusted: projectTrusted(inventory.agent, inventory.agentDir), rootModel, knownModels: new Set(inventory.knownModels), availableModels: new Set(inventory.availableModels), signal: new AbortController().signal });",
233
+ " if (typeof target !== 'string' || !target.trim()) throw new Error(`Model alias resolver returned an invalid target for ${name}.`);",
234
+ " targets[name] = target.trim();",
235
+ " }",
236
+ " return targets;",
237
+ "}",
238
+ "async function checkRequirements(pi, api) {",
239
+ " const inventory = await recipientInventory(pi);",
240
+ " for (const command of manifest.requirements.commands) {",
241
+ " const result = spawnSync(command, ['--version'], { stdio: 'ignore' });",
242
+ " if (result.error || result.status !== 0) throw new Error(`Missing required external command: ${command}`);",
243
+ " }",
244
+ " for (const name of manifest.requirements.environment) if (!process.env[name]) throw new Error(`Missing required environment variable: ${name}`);",
245
+ " for (const tool of manifest.requirements.tools) if (!inventory.tools.has(tool)) throw new Error(`Required Pi tool is unavailable: ${tool}. Enable the tool or install the extension that provides it.`);",
246
+ " const settings = workflowSettings(inventory.agent, inventory.agentDir);",
247
+ " const dynamicTargets = await dynamicAliasTargets(api, inventory, settings);",
248
+ " const targets = { ...dynamicTargets, ...(manifest.aliasTargets ?? {}) };",
249
+ " for (const name of manifest.requirements.aliases) {",
250
+ " const target = resolveAlias(name, targets, settings, inventory.knownModels);",
251
+ " if (!target || !inventory.knownModels.has(target)) throw new Error(`Required model alias is unknown: ${name}${target ? ` (resolved target: ${target})` : ''}. Pi does not recognize this model.`);",
252
+ " if (!inventory.availableModels.has(target)) throw new Error(`Required model alias is unavailable: ${name} -> ${target}. Configure authentication for this model before launching the bundle.`);",
253
+ " }",
254
+ "}",
255
+ "function piVersion(pi) {",
256
+ " const result = run(pi, ['--version']);",
257
+ " return result.status === 0 ? result.stdout.trim().split(/\\r?\\n/, 1)[0] : 'unknown';",
258
+ "}",
259
+ "function assertPiVersion(pi) {",
260
+ " const expected = manifest.runtime.pi;",
261
+ " const actual = piVersion(pi);",
262
+ " if (!satisfies(actual, expected)) throw new Error(`Bundle requires Pi ${expected}; found ${actual}.`);",
263
+ "}",
264
+ "function saveState(pi, engine) {",
265
+ " writeFileSync(join(bundleRoot, 'bundle-state.json'), JSON.stringify({ format: manifest.format, version: manifest.version, pi: piVersion(pi), engine: engine.version, checkedAt: new Date().toISOString() }, null, 2) + '\\n', { mode: 0o600 });",
266
+ "}",
267
+ "function assertSetupState(pi, engine) {",
268
+ " const state = readJson(join(bundleRoot, 'bundle-state.json'));",
269
+ " if (state.format !== manifest.format || state.version !== manifest.version || typeof state.checkedAt !== 'string' || !satisfies(state.pi, manifest.runtime.pi) || !satisfies(state.engine, manifest.runtime['pi-extensible-workflows'])) throw new Error(`Bundle setup is missing or stale. Run '${manifest.command} setup' before launching.`);",
270
+ "}",
271
+ "async function loadPayload(engine) {",
272
+ " const engineIndex = pathToFileURL(join(engine.root, 'dist', 'src', 'index.js')).href;",
273
+ " const api = await import(engineIndex);",
274
+ " globalThis.__pi_bundle_api = api;",
275
+ " const payload = await import(pathToFileURL(join(bundleRoot, 'payload', 'workflow.mjs')).href + '?bundle=' + String(Date.now()));",
276
+ " await payload.register(api.registerWorkflowExtension);",
277
+ " return { api, payload };",
278
+ "}",
279
+ "async function setup(argv) {",
280
+ " if (argv.some((arg) => arg !== '--yes' && arg !== '--help' && arg !== '-h')) throw new Error('Usage: ' + manifest.command + ' setup [--yes]');",
281
+ " if (argv.includes('--help') || argv.includes('-h')) { console.log('Usage: ' + manifest.command + ' setup [--yes]'); return; }",
282
+ " const approve = argv.includes('--yes');",
283
+ " const pi = piCommand();",
284
+ " assertNpmPi(pi);",
285
+ " assertPiVersion(pi);",
286
+ " const engine = await ensureEngine(pi, true, approve);",
287
+ " const { api } = await loadPayload(engine);",
288
+ " await checkRequirements(pi, api);",
289
+ " saveState(pi, engine);",
290
+ " console.log('Bundle setup complete.');",
291
+ " console.log('Pi: ' + piVersion(pi));",
292
+ " console.log('pi-extensible-workflows: ' + engine.version);",
293
+ "}",
294
+ "async function launch(argv) {",
295
+ " const pi = piCommand();",
296
+ " assertNpmPi(pi);",
297
+ " assertPiVersion(pi);",
298
+ " const engine = await ensureEngine(pi, false, false);",
299
+ " assertSetupState(pi, engine);",
300
+ " const { api } = await loadPayload(engine);",
301
+ " await checkRequirements(pi, api);",
302
+ " const cli = await import(pathToFileURL(join(engine.root, 'dist', 'src', 'cli.js')).href);",
303
+ " const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');",
304
+ " return cli.runCli(['run', manifest.workflow.name, ...argv], { cwd: process.cwd(), agentDir, skillPaths: bundleSkillPaths(), stderr: (text) => process.stderr.write(text) });",
305
+ "}",
306
+ "const argv = process.argv.slice(2);",
307
+ "try {",
308
+ " if (argv[0] === 'setup') await setup(argv.slice(1));",
309
+ " else process.exitCode = await launch(argv);",
310
+ "} catch (error) {",
311
+ " console.error('Bundle error: ' + (error instanceof Error ? error.message : String(error)));",
312
+ " process.exitCode = 1;",
313
+ "}",
314
+ ].join("\n") + "\n";
315
+ }
316
+
317
+ export function normalizePortableFunctionSource(functionSource: string): string {
318
+ const source = functionSource.trim();
319
+ if (!source || source.includes("[native code]")) throw new Error("Workflow function source is unavailable");
320
+ if (/^(async\s+)?run\s*\(/.test(source)) return source.replace(/^(async\s+)?run\s*\(/, "$1function run(");
321
+ if (/^\*\s*run\s*\(/.test(source)) return source.replace(/^\*\s*run\s*\(/, "function* run(");
322
+ return source;
323
+ }
324
+
325
+ function workflowModule(workflow: WorkflowCatalogFunction, functionSource: string, withRoles: boolean, aliasTargets: Readonly<Record<string, string>>, extensionModules: readonly string[]): string {
326
+ const aliases = Object.entries(aliasTargets);
327
+ const source = normalizePortableFunctionSource(functionSource);
328
+ return [
329
+ `const run = ${source};`,
330
+ "export async function register(registerWorkflowExtension) {",
331
+ ...extensionModules.map((name, index) => ` const extension${String(index)} = await import(${JSON.stringify(`./extensions/${name}`)});`),
332
+ ...extensionModules.map((_name, index) => ` if (typeof extension${String(index)}.default === "function") await extension${String(index)}.default();`),
333
+ " registerWorkflowExtension({",
334
+ ` version: ${JSON.stringify("1.0.0")},`,
335
+ ` headline: ${JSON.stringify("Portable workflow bundle")},`,
336
+ ` description: ${JSON.stringify(workflow.description)},`,
337
+ ...(withRoles ? [` roleDirectories: [new URL("./roles", import.meta.url)],`] : []),
338
+ ...(aliases.length ? [` modelAliases: { ${aliases.map(([name, target]) => `${JSON.stringify(name)}: { resolve: () => ${JSON.stringify(target)} }`).join(", ")} },`] : []),
339
+ " functions: {",
340
+ ` [${JSON.stringify(workflow.name)}]: {`,
341
+ ` description: ${JSON.stringify(workflow.description)},`,
342
+ ` input: ${JSON.stringify(workflow.input)},`,
343
+ ` output: ${JSON.stringify(workflow.output)},`,
344
+ " run,",
345
+ " },",
346
+ " },",
347
+ " });",
348
+ "}",
349
+ "",
350
+ ].join("\n");
351
+ }
352
+
353
+ function roleMarkdown(role: AgentDefinition): string {
354
+ const metadata = ["---"];
355
+ if (role.description !== undefined) metadata.push(`description: ${JSON.stringify(role.description)}`);
356
+ if (role.model !== undefined) metadata.push(`model: ${JSON.stringify(role.model)}`);
357
+ if (role.thinking !== undefined) metadata.push(`thinking: ${JSON.stringify(role.thinking)}`);
358
+ if (role.tools !== undefined) metadata.push(`tools: ${JSON.stringify(role.tools)}`);
359
+ if (role.overrideSystemPrompt !== undefined) metadata.push(`overrideSystemPrompt: ${String(role.overrideSystemPrompt)}`);
360
+ if (role.disabledAgentResources !== undefined) metadata.push(`disabledAgentResources: ${JSON.stringify(role.disabledAgentResources)}`);
361
+ metadata.push("---");
362
+ return `${metadata.join("\n")}\n${role.prompt ?? ""}\n`;
363
+ }
364
+
365
+ function copyResources(root: string, resources: PortableWorkflowBundleResources | undefined): PortableWorkflowManifest["payload"] {
366
+ if (!resources) return undefined;
367
+ const payload: NonNullable<PortableWorkflowManifest["payload"]> = {};
368
+ const sensitive = (source: string): boolean => {
369
+ const name = basename(source).toLowerCase();
370
+ return ["auth.json", "models.json", ".env", ".npmrc"].includes(name) || name.endsWith(".pem") || name.endsWith(".key");
371
+ };
372
+ const copy = (kind: "extensions" | "skills" | "static" | "dependencies", paths: readonly string[] | undefined): void => {
373
+ if (!paths?.length) return;
374
+ const names: string[] = [];
375
+ for (const source of paths) {
376
+ if (!existsSync(source)) throw new Error(`Bundle resource does not exist: ${source}`);
377
+ if (sensitive(source)) throw new Error(`Bundle resource may contain credentials and cannot be selected: ${source}`);
378
+ let name = basename(source);
379
+ if (kind === "dependencies") {
380
+ try { const packageName = (JSON.parse(readFileSync(join(source, "package.json"), "utf8")) as { name?: unknown }).name; if (typeof packageName === "string" && packageName.trim()) name = packageName; } catch { /* Files can be dependency entry points. */ }
381
+ }
382
+ if (!name || name === "." || name === ".." || name.includes("\\") || name.startsWith("/")) throw new Error(`Invalid bundle resource name: ${name}`);
383
+ if (names.includes(name)) throw new Error(`Duplicate bundle resource name: ${name}`);
384
+ const destination = kind === "dependencies" ? join(root, "payload", "node_modules", ...name.split("/")) : join(root, "payload", kind === "static" ? "resources" : kind, name);
385
+ mkdirSync(dirname(destination), { recursive: true });
386
+ cpSync(source, destination, { recursive: true });
387
+ names.push(name);
388
+ }
389
+ payload[kind] = names;
390
+ };
391
+ copy("extensions", resources.extensions);
392
+ copy("skills", resources.skills);
393
+ copy("static", resources.static);
394
+ copy("dependencies", resources.dependencies);
395
+ return Object.keys(payload).length ? payload : undefined;
396
+ }
397
+
398
+ function extensionPackageShim(paths: readonly string[]): string {
399
+ const bindings = new Map<string, string>();
400
+ for (const path of paths) {
401
+ if (!/\.(?:c|m)?js$/.test(path)) throw new Error(`Selected extension must be a JavaScript module file: ${path}`);
402
+ const source = readFileSync(path, "utf8");
403
+ for (const match of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']pi-extensible-workflows["']/g)) {
404
+ for (const part of (match[1] ?? "").split(",")) {
405
+ const pieces = part.trim().split(/\s+as\s+/);
406
+ const imported = pieces[0]?.trim();
407
+ const local = pieces[1]?.trim() ?? imported;
408
+ if (imported && local && /^[A-Za-z_$][\w$]*$/.test(local)) bindings.set(local, imported);
409
+ }
410
+ }
411
+ }
412
+ return [...bindings.entries()].map(([local, imported]) => `export const ${local} = globalThis.__pi_bundle_api.${imported};`).join("\n") + "\n";
413
+ }
414
+
415
+ export function writePortableWorkflowBundle(input: PortableWorkflowBundleInput): PortableWorkflowManifest {
416
+ const engineVersion = input.engineVersion ?? portableEngineVersion();
417
+ const manifest: PortableWorkflowManifest = {
418
+ format: "pi-extensible-workflows-bundle",
419
+ version: 1,
420
+ command: input.command,
421
+ workflow: { name: input.workflow.name, description: input.workflow.description, input: input.workflow.input, output: input.workflow.output },
422
+ runtime: { pi: input.piVersion?.trim() || "unknown", "pi-extensible-workflows": engineVersion.trim() || "unknown" },
423
+ requirements: {
424
+ roles: input.requirements?.roles ?? Object.keys(input.roles ?? {}),
425
+ aliases: input.requirements?.aliases ?? [],
426
+ tools: input.requirements?.tools ?? [],
427
+ commands: input.requirements?.commands ?? [],
428
+ environment: input.requirements?.environment ?? [],
429
+ },
430
+ ...(input.aliasTargets && Object.keys(input.aliasTargets).length ? { aliasTargets: Object.freeze({ ...input.aliasTargets }) } : {}),
431
+ };
432
+ const parent = dirname(input.destination);
433
+ mkdirSync(parent, { recursive: true });
434
+ if (existsSync(input.destination) && !input.force) throw new Error(`Destination already exists: ${input.destination}; use --force to replace it`);
435
+ const temporary = mkdtempSync(join(parent, ".pi-extensible-workflows-bundle-"));
436
+ try {
437
+ const payload = join(temporary, "payload");
438
+ mkdirSync(payload);
439
+ const roles = input.roles ?? {};
440
+ if (Object.keys(roles).length) {
441
+ const roleDirectory = join(payload, "roles");
442
+ mkdirSync(roleDirectory);
443
+ for (const [name, role] of Object.entries(roles)) {
444
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) throw new Error(`Invalid role name for bundle: ${name}`);
445
+ writeFileSync(join(roleDirectory, `${name}.md`), roleMarkdown(role), { encoding: "utf8", mode: 0o600 });
446
+ }
447
+ }
448
+ const copiedPayload = copyResources(temporary, input.resources);
449
+ if (copiedPayload) manifest.payload = copiedPayload;
450
+ const extensionPaths = input.resources?.extensions ?? [];
451
+ const extensionModules = extensionPaths.map((source) => basename(source));
452
+ if (extensionPaths.length) {
453
+ const packageDirectory = join(payload, "node_modules", "pi-extensible-workflows");
454
+ mkdirSync(packageDirectory, { recursive: true });
455
+ writeFileSync(join(packageDirectory, "package.json"), '{"type":"module","exports":"./index.mjs"}\n', { encoding: "utf8", mode: 0o600 });
456
+ writeFileSync(join(packageDirectory, "index.mjs"), extensionPackageShim(extensionPaths), { encoding: "utf8", mode: 0o600 });
457
+ }
458
+ writeFileSync(join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
459
+ writeFileSync(join(payload, "workflow.mjs"), workflowModule(input.workflow, input.functionSource, Object.keys(roles).length > 0, input.aliasTargets ?? {}, extensionModules), { encoding: "utf8", mode: 0o600 });
460
+ writeFileSync(join(payload, "runner.mjs"), runnerSource(), { encoding: "utf8", mode: 0o700 });
461
+ const launcher = join(temporary, input.command);
462
+ writeFileSync(launcher, shellLauncher(), { encoding: "utf8", mode: 0o755 });
463
+ chmodSync(launcher, 0o755);
464
+ writeFileSync(join(temporary, `${input.command}.cmd`), windowsLauncher(), { encoding: "utf8", mode: 0o644 });
465
+ if (input.force) rmSync(input.destination, { recursive: true, force: true });
466
+ renameSync(temporary, input.destination);
467
+ } finally {
468
+ rmSync(temporary, { recursive: true, force: true });
469
+ }
470
+ return manifest;
471
+ }