@parcha/agentrun-pi 0.1.0-beta.13 → 0.1.0-beta.15

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 (42) hide show
  1. package/README.md +44 -6
  2. package/dist/durable/index.d.ts +2 -1
  3. package/dist/durable/index.js +1 -0
  4. package/dist/durable/node.js +27 -10
  5. package/dist/durable/readers.d.ts +4 -1
  6. package/dist/durable/readers.js +44 -5
  7. package/dist/durable/receipts.d.ts +4 -2
  8. package/dist/durable/receipts.js +3 -2
  9. package/dist/durable/store.js +57 -9
  10. package/dist/durable/task.d.ts +6 -2
  11. package/dist/durable/task.js +18 -2
  12. package/dist/durable/workflow-tool.d.ts +32 -0
  13. package/dist/durable/workflow-tool.js +188 -0
  14. package/dist/extension.js +64 -32
  15. package/dist/host-addendum.js +3 -2
  16. package/dist/pi-host.d.ts +7 -0
  17. package/dist/pi-host.js +22 -0
  18. package/dist/signin/index.d.ts +9 -0
  19. package/dist/signin/index.js +7 -0
  20. package/dist/signin/limit.d.ts +10 -0
  21. package/dist/signin/limit.js +23 -0
  22. package/dist/signin/resolve.d.ts +47 -0
  23. package/dist/signin/resolve.js +163 -0
  24. package/dist/signin/signin.d.ts +44 -0
  25. package/dist/signin/signin.js +126 -0
  26. package/dist/signin/spec.d.ts +116 -0
  27. package/dist/signin/spec.js +181 -0
  28. package/dist/signin/store.d.ts +40 -0
  29. package/dist/signin/store.js +206 -0
  30. package/dist/skills/author/SKILL.md +3 -2
  31. package/dist/skills/author/references/language.md +4 -3
  32. package/dist/skills/author/references/sdk-authoring.md +2 -0
  33. package/dist/workflow-inspector.d.ts +1 -1
  34. package/dist/workflow-inspector.js +2 -2
  35. package/dist/workflow-store.d.ts +6 -0
  36. package/dist/workflow-store.js +6 -0
  37. package/dist/workflow-tool.d.ts +81 -0
  38. package/dist/workflow-tool.js +94 -0
  39. package/dist/workflow-view.js +5 -1
  40. package/dist/workflows.d.ts +1 -0
  41. package/dist/workflows.js +3 -0
  42. package/package.json +18 -6
@@ -0,0 +1,188 @@
1
+ // The agentrun tool for a pi-durable session. describe, inspect, dry-run, save, load and list do what the coding-agent
2
+ // extension's tool does, through the same module (../workflow-tool.ts); run starts the workflow as one background task
3
+ // of the calling conversation through createWorkflowExtension, from the tool's own invocation, and answers with its
4
+ // outcome, writing the run's progress to the tool's running output as it goes. The draft a conversation inspected is a
5
+ // document of the session, so a reopened session keeps it. Code nodes run only through the host's code executor.
6
+ import { defineDocFamily, defineExtension, defineTool } from "@earendil-works/pi-durable";
7
+ import { Type } from "typebox";
8
+ import { inspectWorkflow, WORKFLOW_NODE_KINDS } from "@parcha/agentrun-dsl";
9
+ import { canonicalHash } from "@parcha/agentrun-dsl/recovery";
10
+ import { extensionStructuralLimits, WorkflowExtensionService } from "../extension-service.js";
11
+ import { modelJson } from "../presentation.js";
12
+ import { WorkflowStore } from "../workflow-store.js";
13
+ import { progressLines, WorkflowObservation } from "../workflow-view.js";
14
+ import { authoringReferences, describeTools, dryRunDraft, inspectCandidate, workflowToolProperties } from "../workflow-tool.js";
15
+ import { toolResult } from "./pi-versions.js";
16
+ import { createWorkflowExtension } from "./task.js";
17
+ /** What a pi-durable session adds to the workflow language, rendered for describe's `authoring.host`. */
18
+ export const DURABLE_HOST_ADDENDUM = Object.freeze({
19
+ name: "pi-durable session",
20
+ nodeKinds: WORKFLOW_NODE_KINDS.filter((kind) => kind !== "artifact"),
21
+ rules: [
22
+ "Call agentrun with action describe first, then inspect the complete workflow with its input, then dry-run it (action dry-run, without a workflow argument), then run without a workflow argument.",
23
+ "A dry run runs the inspected workflow on synthetic values, or the input you give it, with no model, Jev or tool call. Its status is passed, not_run (a check could not run; skipped says why, and it proves nothing) or failed (problems say why). Report not_run as not run, never as passed.",
24
+ "Use exactly the tools describe returns, as a call's tool and as an agent node's tools: the host registers them, and inspect refuses any other, naming it. A call uses via tool with one of them. Shell and executor calls and artifact delivery are unavailable.",
25
+ "Code nodes run only through the host's code executor. Without one, a dry run of a workflow with code is not_run and a run of it is refused.",
26
+ "A run is a pi-durable task of this conversation: it continues after the session reopens, and the run action answers with its outcome once it ends. Running the same draft on the same input again reaches that run: a live one is waited for, a finished one answers with its outcome, and its effects are not made again. To run the same draft on the same input anew, pass again: true; a later run without it reaches the newest one.",
27
+ "Save keeps a named revision of the inspected definition, not its input. Load replaces the draft with a saved revision and checks it against this host's tools. List shows the saved revisions.",
28
+ "The structural limits describe returns bound node count, map concurrency and parallel branches.",
29
+ ],
30
+ });
31
+ const DraftDoc = defineDocFamily({ kind: "agentrun.draft", version: 1, scope: "session", family: true, initial: () => ({ workflow: null, input: {} }) });
32
+ /** How many times `again` asked for a fresh run of one draft and input in a conversation: the newest run's number. */
33
+ const RunsDoc = defineDocFamily({ kind: "agentrun.runs", version: 1, scope: "session", family: true, initial: () => ({ generation: 0 }) });
34
+ const json = (value) => (value === undefined ? null : JSON.parse(JSON.stringify(value)));
35
+ const answer = (value) => toolResult([{ type: "text", text: modelJson(value) }], { details: json(value) });
36
+ /** The tool, and the workflow task its runs are: install `extension` (it carries both) and list the tool's name in the
37
+ * agent definition. `workflow` reads and stops runs from the host's side. */
38
+ export function createWorkflowTool(host) {
39
+ const names = (host.tools ?? []).map((tool) => tool.name);
40
+ const checker = () => new WorkflowExtensionService({ allowedTools: names });
41
+ /** Inspect's checks, after naming any tool the workflow uses (a call's, or an agent node's) that the host did not
42
+ * register: the host decides what a drafted workflow may use. */
43
+ const admit = (candidate, input) => {
44
+ let used = [];
45
+ try {
46
+ used = inspectWorkflow(candidate).requires.tools;
47
+ }
48
+ catch { /* inspect names what is wrong */ }
49
+ const missing = used.filter((name) => !names.includes(name));
50
+ if (missing.length)
51
+ throw new Error(`Cannot inspect workflow: it uses ${missing.length === 1 ? "a tool" : "tools"} this session's host has not registered: ${missing.map((name) => JSON.stringify(name)).join(", ")}. A workflow may call, or give an agent node, only the tools describe lists; a host registers them in createWorkflowTool's tools.`);
52
+ return inspectCandidate(checker(), candidate, input);
53
+ };
54
+ const store = () => new WorkflowStore(host.projectDir);
55
+ // A run's events reach the tool call that started it, while both live in this process.
56
+ const listeners = new Map();
57
+ const listen = (key, listener) => {
58
+ const set = listeners.get(key) ?? new Set();
59
+ listeners.set(key, set.add(listener));
60
+ return () => { set.delete(listener); if (!set.size)
61
+ listeners.delete(key); };
62
+ };
63
+ const given = { ...(host.runCode ? { runCode: host.runCode } : {}), ...(host.sop === undefined ? {} : { sop: host.sop }) };
64
+ const workflow = createWorkflowExtension({
65
+ ...host.task,
66
+ run: async (invocation) => {
67
+ const parts = await host.task.run(invocation);
68
+ // The host's own observer's result goes back to the interpreter, which consumes a rejected one; a progress listener
69
+ // that fails (its call ended, say) changes nothing in the run.
70
+ const tee = (own) => (event) => {
71
+ for (const listener of listeners.get(invocation.key) ?? []) {
72
+ try {
73
+ listener(event);
74
+ }
75
+ catch { /* progress only */ }
76
+ }
77
+ return own?.(event);
78
+ };
79
+ // The executor and SOP text the host gave the tool are the run's, as they are its dry run's.
80
+ return "deps" in parts
81
+ ? { ...parts, deps: { ...parts.deps, ...given, onEvent: tee(parts.deps.onEvent) } }
82
+ : { ...parts, adapters: { ...parts.adapters, ...given, onEvent: tee(parts.adapters.onEvent) } };
83
+ },
84
+ });
85
+ const tool = defineTool({
86
+ name: host.name ?? "agentrun",
87
+ description: "Compose and run AgentRun workflows in this session. Describe lists the tools a workflow may use, as a call's tool or an agent node's tools, and the language; a workflow uses only the tools describe lists, which the host registers. Inspect validates a complete workflow and keeps it as this conversation's draft. Dry-run checks the draft on synthetic values (or the given input) with no model, Jev or tool call. Run starts the draft as a durable task of this conversation and answers with its outcome. Running the same draft on the same input again, after a restart too, reaches the same run and makes no effect twice; again: true starts a fresh run of them, which a later run then reaches. Save, load and list keep named definition revisions.",
88
+ parameters: Type.Object({ ...workflowToolProperties(), again: Type.Optional(Type.Boolean({ description: "Run only: start a fresh run of the same draft and input instead of reaching the one already run." })) }),
89
+ executionMode: "sequential",
90
+ async execute(given, api, context) {
91
+ const args = given;
92
+ const key = String(api.conversationId);
93
+ const held = async () => {
94
+ const draft = await api.snapshot(DraftDoc, key, context);
95
+ return draft && draft.workflow !== null ? draft : undefined;
96
+ };
97
+ const keep = (candidate, input) => api.commit(async (tx) => {
98
+ const draft = await tx.doc(DraftDoc, key, null);
99
+ draft.workflow = json(candidate);
100
+ draft.input = json(input);
101
+ return undefined;
102
+ }, context);
103
+ const needDraft = async (action) => {
104
+ if (args.workflow !== undefined)
105
+ throw new Error(`Inspect the workflow first with action inspect, then ${action} without a workflow argument.`);
106
+ const draft = await held();
107
+ if (!draft)
108
+ throw new Error(`Inspect a workflow first, then ${action} it.`);
109
+ return draft;
110
+ };
111
+ switch (args.action) {
112
+ case "describe":
113
+ return answer({ tools: describeTools(host.tools ?? []), structuralLimits: { ...extensionStructuralLimits }, authoring: authoringReferences(DURABLE_HOST_ADDENDUM) });
114
+ case "inspect": {
115
+ const draft = await held();
116
+ const candidate = args.workflow ?? draft?.workflow;
117
+ if (candidate === undefined)
118
+ throw new Error("No workflow yet. Pass the complete workflow to inspect.");
119
+ // Without input, the input is not checked, as the coding-agent tool's inspect does; the draft keeps none.
120
+ const input = args.input ?? (args.workflow === undefined ? draft?.input : undefined);
121
+ const { tree } = admit(candidate, input);
122
+ await keep(candidate, input ?? {});
123
+ return answer({ preview: tree, workflow: candidate, input: input ?? {} });
124
+ }
125
+ case "dry-run": {
126
+ const draft = await needDraft("dry-run");
127
+ const dryRun = await dryRunDraft(draft.workflow, {
128
+ ...(args.input === undefined ? {} : { input: args.input }), ...(host.sop === undefined ? {} : { sop: host.sop }),
129
+ ...(host.runCode ? { runCode: host.runCode } : {}),
130
+ });
131
+ return answer({ dryRun });
132
+ }
133
+ case "save": {
134
+ const draft = await needDraft("save");
135
+ return answer(await store().save(args.name ?? "", draft.workflow));
136
+ }
137
+ case "list":
138
+ return answer({ workflows: await store().list() });
139
+ case "load": {
140
+ const saved = await store().load(args.name ?? "", args.digest);
141
+ const { tree } = admit(saved.workflow, args.input);
142
+ await keep(saved.workflow, args.input ?? {});
143
+ return answer({ preview: tree, workflow: saved.workflow, input: args.input ?? {}, saved: { name: saved.name, digest: saved.digest } });
144
+ }
145
+ case "run": {
146
+ const draft = await needDraft("run");
147
+ const input = args.input ?? draft.input;
148
+ const { inspection } = admit(draft.workflow, input);
149
+ const code = inspection.nodes.filter((node) => node.kind === "code").map((node) => JSON.stringify(node.label));
150
+ if (code.length && !host.runCode)
151
+ throw new Error(`Code node${code.length === 1 ? "" : "s"} ${code.join(", ")}: this host runs code only through its own executor and gave none, so the workflow does not run here. Nothing ran.`);
152
+ if (inspection.requires.sopSections.length && host.sop === undefined)
153
+ throw new Error(`The workflow needs SOP text for ${inspection.requires.sopSections.map((name) => JSON.stringify(name)).join(", ")}, and this host supplies none. Nothing ran.`);
154
+ // One run per draft and input in a conversation: a run asked for again (after a restart, say) reaches the same
155
+ // task, so its effects happen once. A live run is attached to, and a finished one answers with its outcome.
156
+ // `again` starts the next one; the count is a session document, so a later run, after a restart too, reaches it.
157
+ const base = `agentrun:${api.conversationId}:${inspection.sha256}:${canonicalHash(input)}`;
158
+ const generation = args.again
159
+ ? await api.commit(async (tx) => { const runs = await tx.doc(RunsDoc, base, null); runs.generation += 1; return runs.generation; }, context)
160
+ : (await api.snapshot(RunsDoc, base, context))?.generation ?? 0;
161
+ const runKey = generation ? `${base}:${generation}` : base;
162
+ const observation = new WorkflowObservation();
163
+ let shown = "";
164
+ const stop = listen(runKey, (event) => {
165
+ observation.record(event);
166
+ const text = progressLines(observation.data).join(" · ");
167
+ if (text !== shown) {
168
+ shown = text;
169
+ api.output(`${text}\n`);
170
+ }
171
+ });
172
+ try {
173
+ const run = await workflow.start(api, { key: runKey, workflow: draft.workflow, input }, context, { conversationId: Number(api.conversationId), background: true });
174
+ const outcome = await run.wait(context);
175
+ // Only this call's events are counted: a run that had ended sends none, and one joined after a restart sent some before.
176
+ const progress = run.started === "settled" ? ["The run had ended; its progress is not kept, and its outcome is above."]
177
+ : run.started === "attached" ? ["Steps before this call are not counted.", ...progressLines(observation.data)] : progressLines(observation.data);
178
+ return answer({ run: { key: runKey, taskId: run.taskId, started: run.started }, outcome, progress });
179
+ }
180
+ finally {
181
+ stop();
182
+ }
183
+ }
184
+ }
185
+ },
186
+ });
187
+ return { extension: defineExtension({ name: "agentrun.tool", tools: [tool], tasks: workflow.extension.tasks }), workflow };
188
+ }
package/dist/extension.js CHANGED
@@ -1,13 +1,12 @@
1
- import { Type } from 'typebox';
2
1
  import { randomUUID } from 'node:crypto';
3
2
  import { existsSync } from 'node:fs';
4
3
  import { Compile } from 'typebox/compile';
5
4
  import { Text } from '@earendil-works/pi-tui';
6
5
  import { createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition, VERSION as PI_VERSION, } from '@earendil-works/pi-coding-agent';
7
- import { formatWorkflowTree, inspectWorkflow, AUTHOR_SKILL_NAME, loadAuthorReference, renderAuthorHostAddendum, } from '@parcha/agentrun-dsl';
6
+ import { formatWorkflowTree, inspectWorkflow, AUTHOR_SKILL_NAME } from '@parcha/agentrun-dsl';
8
7
  import { createJevRunner } from '@parcha/agentrun-jev';
9
8
  import { createPiHostRunner, PI_MODEL_SETUP_MESSAGE } from './host-session.js';
10
- import { PI_HOST_ADDENDUM } from './host-addendum.js';
9
+ import { piHostLine } from './pi-host.js';
11
10
  import { workflowToolContext } from './tool-context.js';
12
11
  import { WorkflowExtensionService, ExtensionServiceError, extensionStructuralLimits } from './extension-service.js';
13
12
  import { demoInput, demoSearchTool, demoWorkflow, scriptedDemoDeps } from './demo.js';
@@ -20,6 +19,7 @@ import { readSopFile, missingSopSections, sopDigest } from './sop.js';
20
19
  import { WorkflowStore, WorkflowStoreError } from './workflow-store.js';
21
20
  import { WorkflowObservation, workflowView, formatWorkflowView, progressLines, readable } from './workflow-view.js';
22
21
  import { showWorkflowInspector } from './workflow-inspector.js';
22
+ import { authoringReferences, describeTools, dryRunDraft, formatDryRun, inspectCandidate, workflowToolParameters } from './workflow-tool.js';
23
23
  import { supportTriageWorkflow, supportTriageInputs, supportTriageTools, scriptedSupportTriageDeps } from './triage-demo.js';
24
24
  import { restoreWorkflowSession, workflowRunHistory } from './workflow-session.js';
25
25
  const constructors = {
@@ -27,8 +27,7 @@ const constructors = {
27
27
  write: createWriteToolDefinition, grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
28
28
  };
29
29
  const mutatingTools = new Set(['bash', 'edit', 'write']);
30
- const TESTED_PI_VERSION = '1.1.0';
31
- const help = 'Describe a task: /agentrun <what you want done>\nTry support triage: /agentrun triage (fictional, no model calls)\nInspect: /agentrun · Change input: /agentrun input\nSave: /agentrun save <name> · Load: /agentrun load <name>\nLibrary: /agentrun list · History: /agentrun history\nRun: /agentrun run · SOP text: /agentrun sop <file> · Setup: /agentrun status\nStop: /agentrun stop (closing the inspector does not stop a run)\nOther examples: /agentrun demo | demo empty | demo live';
30
+ const help = 'Describe a task: /agentrun <what you want done>\nTry support triage: /agentrun triage (fictional, no model calls)\nInspect: /agentrun · Change input: /agentrun input\nSave: /agentrun save <name> · Load: /agentrun load <name>\nLibrary: /agentrun list · History: /agentrun history\nRun: /agentrun run · Dry run: /agentrun dry-run · SOP text: /agentrun sop <file> · Setup: /agentrun status\nStop: /agentrun stop (closing the inspector does not stop a run)\nOther examples: /agentrun demo | demo empty | demo live';
32
31
  const textResult = (text, details = {}) => ({ content: [{ type: 'text', text: safe(text) }], details });
33
32
  function singleTextInput(workflow) {
34
33
  const source = workflow;
@@ -177,19 +176,10 @@ function registerExtension(pi, configuration) {
177
176
  const candidate = workflow === undefined ? s.draft : workflow;
178
177
  if (candidate === undefined)
179
178
  throw new Error('No workflow yet. Describe a task with /agentrun <task>.');
180
- if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
181
- throw new Error('Cannot inspect workflow: workflow must be a JSON object, not a JSON-encoded string or array. Pass the complete object directly.');
182
- }
183
179
  // Include active tools available only with operator trust, without granting that trust.
184
180
  // Execution rechecks the actual per-run permissions and captures implementations.
185
181
  const checker = new WorkflowExtensionService({ allowedTools: toolSet(ctx, true).map(tool => tool.name), limits: serviceLimits });
186
- let inspection;
187
- try {
188
- inspection = checker.preflight(candidate, input);
189
- }
190
- catch (error) {
191
- throw new Error(`Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`);
192
- }
182
+ const { inspection, tree } = inspectCandidate(checker, candidate, input);
193
183
  // Stage the definition and input together only after nonexecuting checks succeed.
194
184
  const nextDraft = workflow === undefined ? s.draft : structuredClone(workflow);
195
185
  const changed = !s.draft || inspectWorkflow(s.draft).sha256 !== inspection.sha256;
@@ -210,7 +200,7 @@ function registerExtension(pi, configuration) {
210
200
  s.input = nextInput;
211
201
  if (workflow !== undefined || input !== undefined)
212
202
  persist(s);
213
- return { inspection, tree: formatWorkflowTree(inspection) };
203
+ return { inspection, tree };
214
204
  };
215
205
  const view = (ctx, s) => {
216
206
  const result = workflowView(s.draft, { input: s.input,
@@ -441,6 +431,35 @@ function registerExtension(pi, configuration) {
441
431
  ctx.ui.setWidget('agentrun', undefined);
442
432
  }
443
433
  };
434
+ /** The inspected draft on synthetic values, or `input`: nothing is paid, and the session's run is untouched. Code runs
435
+ * only when the user's command trusts this dry run. */
436
+ const dryRun = async (ctx, s, input, trusted, signal) => {
437
+ if (!s.draft)
438
+ throw new Error('Inspect a workflow first. Try /agentrun demo, or describe a task with /agentrun <task>.');
439
+ const draft = s.draft;
440
+ // /agentrun stop, the tool call's own abort and the session's end each stop it, from before its first await on.
441
+ const controller = new AbortController();
442
+ s.dryRun?.abort();
443
+ s.dryRun = controller;
444
+ const stopped = AbortSignal.any([controller.signal, ...[signal, ctx.signal].filter((v) => !!v)]);
445
+ try {
446
+ let sop, sopUnavailable;
447
+ try {
448
+ sop = await sopFor(ctx, s, inspectWorkflow(draft).requires.sopSections);
449
+ }
450
+ catch (error) {
451
+ sopUnavailable = error instanceof Error ? error.message : String(error);
452
+ }
453
+ if (stopped.aborted || s.closed)
454
+ return { status: 'not_run', ok: true, skipped: ['The dry run was stopped before it started.'] };
455
+ return await dryRunDraft(draft, { ...(input === undefined ? {} : { input }), ...(sop ? { sop: sop.text } : {}), ...(sopUnavailable ? { sopUnavailable } : {}),
456
+ trusted, trustedCommand: '/agentrun dry-run --trusted', signal: stopped });
457
+ }
458
+ finally {
459
+ if (s.dryRun === controller)
460
+ s.dryRun = undefined;
461
+ }
462
+ };
444
463
  const launch = async (ctx, s, input, trusted = false) => {
445
464
  if (s.busy)
446
465
  throw new Error('A workflow is already running. Use /agentrun stop first.');
@@ -459,18 +478,15 @@ function registerExtension(pi, configuration) {
459
478
  };
460
479
  pi.registerTool({
461
480
  name: 'agentrun', label: 'AgentRun workflow',
462
- description: `Use the ${AUTHOR_SKILL_NAME} skill to compose workflows.` + ' Describe lists available tools and their schemas. Inspect or run an AgentRun DSL workflow in this Pi session. First inspect to show its graph. Agent nodes capture the active Pi model when execution starts. Code needs the user command /agentrun run --trusted for each run. ' + (configuration.hostTools
481
+ description: `Use the ${AUTHOR_SKILL_NAME} skill to compose workflows.` + ' Describe lists available tools and their schemas. Inspect or run an AgentRun DSL workflow in this Pi session. First inspect to show its graph. Dry-run checks the inspected workflow on synthetic values (or the given input) with no model, Jev or tool call; code runs only with the user command /agentrun dry-run --trusted. Agent nodes capture the active Pi model when execution starts. Code needs the user command /agentrun run --trusted for each run. ' + (configuration.hostTools
463
482
  ? 'Only the explicit host-configured tools are available, including during trusted runs. Outer permission hooks are not inherited. '
464
483
  : 'Enabled read-only Pi built-ins are available to declared steps. Shell/write/edit need the trusted command for each run; custom extension tools and permission hooks are not inherited. The search and support_demo tools use fictional supplied sources only. ') + 'Save stores a named procedure revision, not its input or permission. Load preflights current host capabilities. Run always restarts; there is no checkpoint resume. Scripted demos stay scripted on rerun.',
465
- parameters: Type.Object({ action: Type.Union(['describe', 'inspect', 'run', 'save', 'load', 'list'].map(value => Type.Literal(value))),
466
- name: Type.Optional(Type.String({ description: 'Saved procedure name: lowercase letters, digits, hyphens or underscores.' })),
467
- digest: Type.Optional(Type.String({ description: 'Optional exact saved SHA-256 revision to load.' })),
468
- workflow: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
469
- description: 'The complete AgentRun workflow JSON object. Pass the object directly, not a JSON-encoded string. Inspection validates its DSL structure.',
470
- })), input: Type.Optional(Type.Record(Type.String(), Type.Unknown())) }),
484
+ parameters: workflowToolParameters(),
471
485
  executionMode: 'sequential',
472
486
  renderResult(result, options) {
473
487
  const details = result.details;
488
+ if (details?.dryRun)
489
+ return new Text(safe(formatDryRun(details.dryRun)), 0, 0);
474
490
  const text = details?.status && details.calls ? options?.expanded
475
491
  ? details.view ? formatWorkflowView({ ...details.view, output: undefined }) + (details.output === undefined ? '' : `\n\nOutput:\n${readable(details.output, Number.MAX_SAFE_INTEGER)}`) : formatRunReport(details, details.mode === 'scripted') : formatRunReport(details, details.mode === 'scripted')
476
492
  : details?.view ? formatWorkflowView(details.view) : result.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
@@ -507,13 +523,16 @@ function registerExtension(pi, configuration) {
507
523
  return textResult(modelJson({ preview: formatWorkflowView(preview), workflow: s.draft, input: s.input }), { ...result, saved, view: preview });
508
524
  }
509
525
  if (args.action === 'describe') {
510
- const details = { tools: toolSet(ctx).map(t => ({ name: t.name, description: t.description, parameters: t.parameters,
511
- resultSchema: t.resultSchema ?? { type: 'object', properties: { content: { type: 'array', items: { type: 'object' } }, details: {} }, required: ['content'] },
512
- resultContract: t.resultSchema ? 'Host-declared direct workflow call result.' : 'Pi result envelope; tool-specific details are not declared. Do not invent fields inside details.',
513
- })),
514
- limits: { ...limits }, structuralLimits: { ...extensionStructuralLimits }, ...readiness(ctx, s), authoring: { language: loadAuthorReference('language'), workflow: loadAuthorReference('workflow-format'), jev: loadAuthorReference('jev-decisions'), host: renderAuthorHostAddendum(PI_HOST_ADDENDUM) } };
526
+ const details = { tools: describeTools(toolSet(ctx)),
527
+ limits: { ...limits }, structuralLimits: { ...extensionStructuralLimits }, ...readiness(ctx, s), authoring: authoringReferences() };
515
528
  return textResult(JSON.stringify(details, null, 2), details);
516
529
  }
530
+ if (args.action === 'dry-run') {
531
+ if (args.workflow !== undefined)
532
+ throw new Error('Inspect the workflow first with action inspect, then dry-run without a workflow argument.');
533
+ const report = await dryRun(ctx, s, args.input, false, signal);
534
+ return textResult(modelJson({ dryRun: report }), { dryRun: report });
535
+ }
517
536
  if (args.action === 'run' && args.workflow !== undefined)
518
537
  throw new Error('Inspect the workflow first with action inspect, then run without a workflow argument.');
519
538
  if (args.action === 'inspect') {
@@ -564,9 +583,9 @@ function registerExtension(pi, configuration) {
564
583
  else
565
584
  raw = action;
566
585
  }
567
- const command = /^(help|status|stop|demo|triage|run|inspect)(?:\s|$)/i.test(raw) ? raw.toLowerCase().replace(/\s+/g, ' ') : raw;
586
+ const command = /^(help|status|stop|demo|triage|run|dry-run|inspect)(?:\s|$)/i.test(raw) ? raw.toLowerCase().replace(/\s+/g, ' ') : raw;
568
587
  const words = command.split(/\s+/);
569
- const commandLike = /^(help|status|stop|demo|run)(?:-|$)/i.test(words[0])
588
+ const commandLike = /^(help|status|stop|demo|run|dry-run)(?:-|$)/i.test(words[0])
570
589
  && words.slice(1).every(word => word.startsWith('-') || ['live', 'empty'].includes(word));
571
590
  if (command === 'help' || command === '--help' || command === '-h') {
572
591
  show(s, help);
@@ -699,13 +718,15 @@ function registerExtension(pi, configuration) {
699
718
  }
700
719
  else if (command === 'status') {
701
720
  const ready = readiness(ctx, s);
702
- show(s, `agent.run() · ${s.busy ? 'running' : 'idle'}\nDemo: ready — /agentrun demo (no keys or model calls)\nWorkflow: ${s.draft ? `${modelJson(inspectWorkflow(s.draft).name)} · ${s.demo ? 'scripted' : 'live adapters'}` : 'none — load a saved procedure, run the demo, or describe a task'}\n\nFor your own workflows:\nPi host: ${PI_VERSION}${PI_VERSION === TESTED_PI_VERSION ? '' : ` (tested on ${TESTED_PI_VERSION}; install that host: npm install -g @earendil-works/pi-coding-agent@${TESTED_PI_VERSION})`}\nSkill: ${ready.skill ? 'loaded — /agentrun <task>' : 'missing — enable package skills, then /reload'}\nPi: ${ready.pi ? 'selected model available (connection not tested)' : 'no usable active model — /login to connect a provider, then /model to select it'}\nJev: ${ready.jev ? 'configuration present (connection not tested)' : 'not configured — only needed for system one decisions; set TYPESAFE_API_KEY before starting Pi'}`, ready);
721
+ show(s, `agent.run() · ${s.busy ? 'running' : 'idle'}\nDemo: ready — /agentrun demo (no keys or model calls)\nWorkflow: ${s.draft ? `${modelJson(inspectWorkflow(s.draft).name)} · ${s.demo ? 'scripted' : 'live adapters'}` : 'none — load a saved procedure, run the demo, or describe a task'}\n\nFor your own workflows:\n${piHostLine(PI_VERSION)}\nSkill: ${ready.skill ? 'loaded — /agentrun <task>' : 'missing — enable package skills, then /reload'}\nPi: ${ready.pi ? 'selected model available (connection not tested)' : 'no usable active model — /login to connect a provider, then /model to select it'}\nJev: ${ready.jev ? 'configuration present (connection not tested)' : 'not configured — only needed for system one decisions; set TYPESAFE_API_KEY before starting Pi'}`, ready);
703
722
  }
704
723
  else if (command === 'stop') {
724
+ const dry = s.dryRun;
725
+ dry?.abort();
705
726
  s.pause?.();
706
727
  s.controller?.abort();
707
728
  s.service.stop();
708
- show(s, s.busy ? 'Stop requested. Already-started tool effects may still finish.' : 'Nothing is running.');
729
+ show(s, s.busy ? 'Stop requested. Already-started tool effects may still finish.' : dry ? 'Dry run stopped.' : 'Nothing is running.');
709
730
  }
710
731
  else if (command === 'demo' || command === 'demo live' || command === 'demo empty') {
711
732
  inspect(ctx, s, demoWorkflow);
@@ -716,6 +737,16 @@ function registerExtension(pi, configuration) {
716
737
  else if (command === 'run' || command === 'run --trusted') {
717
738
  await launch(ctx, s, s.input, command === 'run --trusted');
718
739
  }
740
+ else if (command === 'dry-run' || command === 'dry-run --trusted') {
741
+ // As with /agentrun run: interactive Pi reads the next command only once this handler returns, so the dry run
742
+ // goes on in the background, where /agentrun stop reaches it. Headless callers await its answer.
743
+ stage(ctx, s, 'agent.run() · dry run · /agentrun stop ends it');
744
+ const pending = dryRun(ctx, s, undefined, command === 'dry-run --trusted')
745
+ .then(report => show(s, formatDryRun(report), { dryRun: report }), error => commandError(s, error))
746
+ .finally(() => stage(ctx, s));
747
+ if (!ctx.hasUI)
748
+ await pending;
749
+ }
719
750
  else if (commandLike || command.startsWith('-')) {
720
751
  show(s, `Unknown AgentRun command: ${raw}\n\n${help}`);
721
752
  }
@@ -741,6 +772,7 @@ function registerExtension(pi, configuration) {
741
772
  const old = current, wasRunning = old.busy;
742
773
  old.clearStatus();
743
774
  old.closed = true;
775
+ old.dryRun?.abort();
744
776
  old.pause?.();
745
777
  old.controller?.abort();
746
778
  await old.service.dispose();
@@ -5,9 +5,10 @@ export const PI_HOST_ADDENDUM = Object.freeze({
5
5
  name: 'Pi extension',
6
6
  nodeKinds: WORKFLOW_NODE_KINDS.filter(kind => kind !== 'artifact'),
7
7
  rules: [
8
- 'Call agentrun with action describe first, then inspect the complete workflow with its input, then run without a workflow argument.',
8
+ 'Call agentrun with action describe first, then inspect the complete workflow with its input, then dry-run it (action dry-run, without a workflow argument), then run without a workflow argument.',
9
+ 'A dry run runs the inspected workflow on synthetic values, or the input you give it, with no model, Jev or tool call. Its status is passed, not_run (a check could not run; skipped says why, and it proves nothing) or failed (problems say why). Report not_run as not run, never as passed.',
9
10
  'Use exactly the tools describe returns. Without host-configured tools these are the active read-only built-ins (read, grep, find, ls), and bash, edit and write exist only in a trusted run; a host that configures its tools offers only those, including in trusted runs. The search tool reads fictional demo sources only.',
10
- 'Code nodes and mutating tools run only after the user issues /agentrun run --trusted for that run. Trusted execution is local and unsandboxed.',
11
+ 'Code nodes and mutating tools run only after the user issues /agentrun run --trusted for that run. Trusted execution is local and unsandboxed. A dry run of a workflow with code nodes is not_run until the user issues /agentrun dry-run --trusted.',
11
12
  'A call uses via tool with a tool describe returns. Shell and executor calls and artifact delivery are unavailable.',
12
13
  'This extension supplies no SOP text: do not run a workflow with sopSection here; it needs an SDK host that supplies the authoritative SOP.',
13
14
  'Custom extension tools and outer permission hooks are not inherited, including in trusted runs.',
@@ -0,0 +1,7 @@
1
+ export declare const SUPPORTED_PI_RANGE: string;
2
+ export declare const NEWEST_TESTED_PI_VERSION = "1.3.0";
3
+ /** Whether Pi `version` is in the supported range. As npm reads the range, a prerelease is not; nor is a version that
4
+ * is not `x.y.z`. */
5
+ export declare function isSupportedPi(version: string): boolean;
6
+ /** The status line for Pi host `version`: the supported range, and for a host outside it the newest tested one. */
7
+ export declare function piHostLine(version: string): string;
@@ -0,0 +1,22 @@
1
+ // The Pi hosts this release supports: the range of its pi-durable and chord peers. `/agentrun status` flags a host
2
+ // outside it, without blocking it, and names the newest version CI runs (the `pi-1-2` job) as the one to install.
3
+ const FLOOR = [1, 0, 4];
4
+ const CEILING = [1, 4, 0];
5
+ export const SUPPORTED_PI_RANGE = `>=${FLOOR.join(".")} <${CEILING.join(".")}`;
6
+ export const NEWEST_TESTED_PI_VERSION = "1.3.0";
7
+ const compare = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]);
8
+ /** Whether Pi `version` is in the supported range. As npm reads the range, a prerelease is not; nor is a version that
9
+ * is not `x.y.z`. */
10
+ export function isSupportedPi(version) {
11
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
12
+ if (!match)
13
+ return false;
14
+ const parsed = match.slice(1).map(Number);
15
+ return compare(parsed, FLOOR) >= 0 && compare(parsed, CEILING) < 0;
16
+ }
17
+ /** The status line for Pi host `version`: the supported range, and for a host outside it the newest tested one. */
18
+ export function piHostLine(version) {
19
+ return isSupportedPi(version)
20
+ ? `Pi host: ${version} (supported: ${SUPPORTED_PI_RANGE})`
21
+ : `Pi host: ${version} (outside the supported range ${SUPPORTED_PI_RANGE}; the newest tested host: npm install -g @earendil-works/pi-coding-agent@${NEWEST_TESTED_PI_VERSION})`;
22
+ }
@@ -0,0 +1,9 @@
1
+ export { checkKeys, checkSetupPrompt, JEV_KEYS } from "./spec.js";
2
+ export type { CheckOptions, Choice, DemoSpec, EyesNeed, Key, KeyReport, ModelNeed, Package, Platform, Program, ReportItem, RunMode, SetupPromptReport } from "./spec.js";
3
+ export { AGENT_NAME, CHATGPT_PROVIDER, consoleInteraction, signIn, SignInError } from "./signin.js";
4
+ export type { ConsoleIo, SignedIn, SignInOptions } from "./signin.js";
5
+ export { CHATGPT_UNSUPPORTED, resolveModel, ResolveError } from "./resolve.js";
6
+ export type { EyesModel, ResolvedModel, ResolveOptions } from "./resolve.js";
7
+ export { USAGE_LIMIT_CODE, UsageLimitError, usageLimitError } from "./limit.js";
8
+ export { agentrunHome, AuthStoreError, FileCredentialStore, fileCredentialStore } from "./store.js";
9
+ export type { FileCredentialStoreOptions } from "./store.js";
@@ -0,0 +1,7 @@
1
+ // Sign in, pick a model, check keys: the shared piece every demo runs first. The only entry of this package that
2
+ // reaches pi-ai, an optional peer; every other entry stays free of it.
3
+ export { checkKeys, checkSetupPrompt, JEV_KEYS } from "./spec.js";
4
+ export { AGENT_NAME, CHATGPT_PROVIDER, consoleInteraction, signIn, SignInError } from "./signin.js";
5
+ export { CHATGPT_UNSUPPORTED, resolveModel, ResolveError } from "./resolve.js";
6
+ export { USAGE_LIMIT_CODE, UsageLimitError, usageLimitError } from "./limit.js";
7
+ export { agentrunHome, AuthStoreError, FileCredentialStore, fileCredentialStore } from "./store.js";
@@ -0,0 +1,10 @@
1
+ export declare const USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded";
2
+ export declare class UsageLimitError extends Error {
3
+ readonly code = "USAGE_LIMIT";
4
+ constructor(options?: {
5
+ cause?: unknown;
6
+ });
7
+ }
8
+ /** The typed limit error when `value` (an error, an assistant message from a failed request, or its text) is the plan's
9
+ * usage limit; undefined otherwise. */
10
+ export declare function usageLimitError(value: unknown): UsageLimitError | undefined;
@@ -0,0 +1,23 @@
1
+ // The ChatGPT plan's usage limit (five hours on Plus, shared with every other app the user signed in to). OpenAI answers
2
+ // a request past it with 429 `subscription_sharing_usage_limit_exceeded`, at the start or mid-stream. It resets after
3
+ // hours, not seconds, so it is never retried: pi-ai sends a request once by default and classes the code as not
4
+ // retryable, and this maps it to one typed error.
5
+ export const USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded";
6
+ export class UsageLimitError extends Error {
7
+ code = "USAGE_LIMIT";
8
+ constructor(options) {
9
+ super("Your ChatGPT plan's usage limit is reached; wait, or set a provider key.", options);
10
+ this.name = "UsageLimitError";
11
+ }
12
+ }
13
+ /** The typed limit error when `value` (an error, an assistant message from a failed request, or its text) is the plan's
14
+ * usage limit; undefined otherwise. */
15
+ export function usageLimitError(value) {
16
+ if (value instanceof UsageLimitError)
17
+ return value;
18
+ const text = typeof value === "string" ? value
19
+ : value instanceof Error ? value.message
20
+ : value && typeof value === "object" && "errorMessage" in value ? String(value.errorMessage ?? "")
21
+ : "";
22
+ return text.includes(USAGE_LIMIT_CODE) ? new UsageLimitError({ cause: value }) : undefined;
23
+ }
@@ -0,0 +1,47 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { type SignInOptions } from "./signin.js";
3
+ import type { DemoSpec } from "./spec.js";
4
+ /** What Sign in with ChatGPT covers: streaming Responses calls with `store: false`, without these. */
5
+ export declare const CHATGPT_UNSUPPORTED: readonly string[];
6
+ export type ResolveOptions = Omit<SignInOptions, "home"> & {
7
+ provider?: string;
8
+ model?: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ home?: string;
11
+ /** An explicit eyes model (`--eyes-model`); `model` defaults to the spec's eyes default. */
12
+ eyes?: {
13
+ provider: string;
14
+ model?: string;
15
+ };
16
+ signal?: AbortSignal;
17
+ };
18
+ type Resolved = {
19
+ model: Model<Api>;
20
+ /** The key valid now. Not enumerable, and left out of `toJSON`. */
21
+ readonly apiKey: string;
22
+ /** A key valid at the time of the call: a sign-in's token is refreshed under the store's lock when it is near expiry. */
23
+ getApiKey(): Promise<string>;
24
+ /** Only streaming Responses calls with `store: false`, without `unsupported`. */
25
+ responsesOnly: boolean;
26
+ unsupported: readonly string[];
27
+ toJSON(): Record<string, unknown>;
28
+ };
29
+ export type ResolvedModel = Resolved & {
30
+ source: "flag" | "chatgpt";
31
+ limit?: string;
32
+ eyes?: EyesModel;
33
+ };
34
+ export type EyesModel = Resolved & {
35
+ source: "flag" | "lead";
36
+ };
37
+ export declare class ResolveError extends Error {
38
+ readonly code: "FLAGS" | "UNKNOWN_PROVIDER" | "UNKNOWN_MODEL" | "MISSING_KEY" | "NEEDS" | "EYES" | "NO_MODEL" | "MODEL_LIST" | "SIGNED_OUT";
39
+ constructor(code: "FLAGS" | "UNKNOWN_PROVIDER" | "UNKNOWN_MODEL" | "MISSING_KEY" | "NEEDS" | "EYES" | "NO_MODEL" | "MODEL_LIST" | "SIGNED_OUT", message: string);
40
+ }
41
+ /**
42
+ * The model this run uses and the key to send, in the order above. The eyes model, when the spec has one, resolves the
43
+ * same way: an explicit eyes model with its provider's key, else the lead's model when it meets the eyes' needs, else
44
+ * a `ResolveError` ("EYES") naming the gap. The ChatGPT plan takes no video input.
45
+ */
46
+ export declare function resolveModel(spec: DemoSpec, options?: ResolveOptions): Promise<ResolvedModel>;
47
+ export {};