@parcha/agentrun-pi 0.1.0-beta.2 → 0.1.0-beta.20

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 (114) hide show
  1. package/README.md +104 -27
  2. package/dist/cli.js +15 -4
  3. package/dist/demo-cli.d.ts +2 -0
  4. package/dist/demo-cli.js +25 -0
  5. package/dist/demo.js +1 -0
  6. package/dist/durable/decision-recovery.d.ts +23 -0
  7. package/dist/durable/decision-recovery.js +57 -0
  8. package/dist/durable/decisions.d.ts +113 -0
  9. package/dist/durable/decisions.js +71 -0
  10. package/dist/durable/index.d.ts +20 -0
  11. package/dist/durable/index.js +14 -0
  12. package/dist/durable/node-runner.d.ts +106 -0
  13. package/dist/durable/node-runner.js +253 -0
  14. package/dist/durable/node.d.ts +88 -0
  15. package/dist/durable/node.js +98 -0
  16. package/dist/durable/pi-versions.d.ts +34 -0
  17. package/dist/durable/pi-versions.js +66 -0
  18. package/dist/durable/readers.d.ts +28 -0
  19. package/dist/durable/readers.js +103 -0
  20. package/dist/durable/receipts.d.ts +54 -0
  21. package/dist/durable/receipts.js +92 -0
  22. package/dist/durable/record-file.d.ts +10 -0
  23. package/dist/durable/record-file.js +56 -0
  24. package/dist/durable/record-tool.d.ts +76 -0
  25. package/dist/durable/record-tool.js +123 -0
  26. package/dist/durable/record.d.ts +157 -0
  27. package/dist/durable/record.js +402 -0
  28. package/dist/durable/recording.d.ts +90 -0
  29. package/dist/durable/recording.js +292 -0
  30. package/dist/durable/store.d.ts +23 -0
  31. package/dist/durable/store.js +240 -0
  32. package/dist/durable/task.d.ts +133 -0
  33. package/dist/durable/task.js +411 -0
  34. package/dist/durable/workflow-tool.d.ts +32 -0
  35. package/dist/durable/workflow-tool.js +188 -0
  36. package/dist/extension-service.js +63 -22
  37. package/dist/extension.d.ts +2 -0
  38. package/dist/extension.js +469 -80
  39. package/dist/host-addendum.d.ts +4 -0
  40. package/dist/host-addendum.js +20 -0
  41. package/dist/host-session.js +5 -2
  42. package/dist/index.d.ts +1 -2
  43. package/dist/index.js +1 -2
  44. package/dist/pi-host.d.ts +7 -0
  45. package/dist/pi-host.js +22 -0
  46. package/dist/presentation.js +2 -2
  47. package/dist/run-error.d.ts +6 -0
  48. package/dist/run-error.js +13 -0
  49. package/dist/runner.d.ts +1 -6
  50. package/dist/runner.js +7 -13
  51. package/dist/signin/index.d.ts +9 -0
  52. package/dist/signin/index.js +7 -0
  53. package/dist/signin/limit.d.ts +10 -0
  54. package/dist/signin/limit.js +23 -0
  55. package/dist/signin/resolve.d.ts +47 -0
  56. package/dist/signin/resolve.js +163 -0
  57. package/dist/signin/signin.d.ts +44 -0
  58. package/dist/signin/signin.js +126 -0
  59. package/dist/signin/spec.d.ts +116 -0
  60. package/dist/signin/spec.js +181 -0
  61. package/dist/signin/store.d.ts +40 -0
  62. package/dist/signin/store.js +206 -0
  63. package/dist/skills/author/SKILL.md +138 -0
  64. package/{skills → dist/skills}/author/references/jev-decisions.md +27 -14
  65. package/dist/skills/author/references/language.md +134 -0
  66. package/dist/skills/author/references/sdk-authoring.md +11 -0
  67. package/{skills → dist/skills}/author/references/workflow-format.md +7 -15
  68. package/dist/sop.d.ts +12 -0
  69. package/dist/sop.js +29 -0
  70. package/dist/thinking.d.ts +3 -0
  71. package/dist/thinking.js +7 -0
  72. package/dist/tool-context.d.ts +21 -0
  73. package/dist/tool-context.js +13 -0
  74. package/dist/triage-demo.d.ts +62 -0
  75. package/dist/triage-demo.js +153 -0
  76. package/dist/types.d.ts +7 -0
  77. package/dist/workflow-inspector.d.ts +47 -0
  78. package/dist/workflow-inspector.js +235 -0
  79. package/dist/workflow-session.d.ts +23 -0
  80. package/dist/workflow-session.js +168 -0
  81. package/dist/workflow-store.d.ts +36 -0
  82. package/dist/workflow-store.js +255 -0
  83. package/dist/workflow-tool.d.ts +81 -0
  84. package/dist/workflow-tool.js +94 -0
  85. package/dist/workflow-view.d.ts +64 -0
  86. package/dist/workflow-view.js +273 -0
  87. package/dist/workflows.d.ts +1 -0
  88. package/dist/workflows.js +3 -0
  89. package/package.json +57 -11
  90. package/dist/admission.d.ts +0 -6
  91. package/dist/admission.js +0 -51
  92. package/dist/author.d.ts +0 -21
  93. package/dist/author.js +0 -72
  94. package/dist/benchmark-contract.d.ts +0 -88
  95. package/dist/benchmark-contract.js +0 -50
  96. package/dist/benchmark-freeze.d.ts +0 -9
  97. package/dist/benchmark-freeze.js +0 -9
  98. package/dist/benchmark.d.ts +0 -127
  99. package/dist/benchmark.js +0 -260
  100. package/dist/comparison.d.ts +0 -69
  101. package/dist/comparison.js +0 -229
  102. package/dist/skill-bundle.d.ts +0 -5
  103. package/dist/skill-bundle.js +0 -15
  104. package/skills/author/SKILL.md +0 -102
  105. package/skills/author/references/sdk-authoring.md +0 -16
  106. /package/{skills → dist/skills}/author/examples/evidence-gate.input.json +0 -0
  107. /package/{skills → dist/skills}/author/examples/evidence-gate.json +0 -0
  108. /package/{skills → dist/skills}/author/examples/read-file.input.json +0 -0
  109. /package/{skills → dist/skills}/author/examples/read-file.json +0 -0
  110. /package/{skills → dist/skills}/author/examples/read-source-decision.input.json +0 -0
  111. /package/{skills → dist/skills}/author/examples/read-source-decision.json +0 -0
  112. /package/{skills → dist/skills}/author/examples/release-note.txt +0 -0
  113. /package/{skills → dist/skills}/author/examples/review-release-notes.input.json +0 -0
  114. /package/{skills → dist/skills}/author/examples/review-release-notes.json +0 -0
package/dist/extension.js CHANGED
@@ -1,22 +1,43 @@
1
- import { Type } from 'typebox';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
2
3
  import { Compile } from 'typebox/compile';
3
4
  import { Text } from '@earendil-works/pi-tui';
4
5
  import { createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition, VERSION as PI_VERSION, } from '@earendil-works/pi-coding-agent';
5
- import { formatWorkflowTree, inspectWorkflow } from '@parcha/agentrun-dsl';
6
+ import { formatWorkflowTree, inspectWorkflow, AUTHOR_SKILL_NAME } from '@parcha/agentrun-dsl';
6
7
  import { createJevRunner } from '@parcha/agentrun-jev';
7
8
  import { createPiHostRunner, PI_MODEL_SETUP_MESSAGE } from './host-session.js';
8
- import { WorkflowExtensionService, extensionStructuralLimits } from './extension-service.js';
9
+ import { piHostLine } from './pi-host.js';
10
+ import { workflowToolContext } from './tool-context.js';
11
+ import { WorkflowExtensionService, ExtensionServiceError, extensionStructuralLimits } from './extension-service.js';
9
12
  import { demoInput, demoSearchTool, demoWorkflow, scriptedDemoDeps } from './demo.js';
10
13
  import { cleanText as safe, formatRunReport, modelJson } from './presentation.js';
11
- import { loadPiJevGuide, loadPiWorkflowGuide } from './skill-bundle.js';
12
14
  import { ToolInputValidationError, toolInputProblems } from './tool-input-error.js';
15
+ import { mkdir, readFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { RecoveryError, fileStore, openRecovery, withRecovery } from '@parcha/agentrun-dsl/recovery';
18
+ import { readSopFile, missingSopSections, sopDigest } from './sop.js';
19
+ import { WorkflowStore, WorkflowStoreError } from './workflow-store.js';
20
+ import { WorkflowObservation, workflowView, formatWorkflowView, progressLines, readable } from './workflow-view.js';
21
+ import { showWorkflowInspector } from './workflow-inspector.js';
22
+ import { authoringReferences, describeTools, dryRunDraft, formatDryRun, inspectCandidate, workflowToolParameters } from './workflow-tool.js';
23
+ import { supportTriageWorkflow, supportTriageInputs, supportTriageTools, scriptedSupportTriageDeps } from './triage-demo.js';
24
+ import { restoreWorkflowSession, workflowRunHistory } from './workflow-session.js';
13
25
  const constructors = {
14
26
  read: createReadToolDefinition, bash: createBashToolDefinition, edit: createEditToolDefinition,
15
27
  write: createWriteToolDefinition, grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
16
28
  };
17
29
  const mutatingTools = new Set(['bash', 'edit', 'write']);
18
- const help = 'Describe a task: /agentrun <what you want done>\nTry it without keys: /agentrun demo | demo empty\nUse real Pi and Jev calls: /agentrun demo live\nInspect: /agentrun · Rerun: /agentrun run · Setup: /agentrun status\nStop: /agentrun stop (Escape does not cancel slash-started runs)';
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';
19
31
  const textResult = (text, details = {}) => ({ content: [{ type: 'text', text: safe(text) }], details });
32
+ function singleTextInput(workflow) {
33
+ const source = workflow;
34
+ const schema = source?.input?.schemaId && source.schemas?.[source.input.schemaId];
35
+ if (schema?.type !== 'object' || !schema.properties || typeof schema.properties !== 'object')
36
+ return;
37
+ const keys = Object.keys(schema.properties);
38
+ return keys.length === 1 && schema.required?.length === 1 && schema.required[0] === keys[0]
39
+ && schema.properties[keys[0]]?.type === 'string' ? keys[0] : undefined;
40
+ }
20
41
  const nativeLimits = Object.freeze({ deadlineMs: 600_000, modelRequests: 50, judgeCalls: 100, toolAttempts: 100,
21
42
  nodeMaxTurns: 8, nodeMaxSubmissions: 3, nodeTimeoutMs: 120_000 });
22
43
  /** Configure the native extension without granting configuration to workflow authors. */
@@ -29,13 +50,15 @@ export function createAgentRunExtension(options = {}) {
29
50
  throw new Error('onToolAttempt must be a host callback');
30
51
  if (options.onWorkflowEvent !== undefined && typeof options.onWorkflowEvent !== 'function')
31
52
  throw new Error('onWorkflowEvent must be a host callback');
53
+ if (options.sop !== undefined && (typeof options.sop !== 'string' || !options.sop.trim()))
54
+ throw new Error('sop must be nonempty text');
32
55
  const limits = { ...nativeLimits, ...options.runtimeLimits };
33
56
  for (const [key, value] of Object.entries(limits)) {
34
57
  if (!(key in nativeLimits) || value !== null && (!Number.isSafeInteger(value) || value < 1 || value > nativeLimits[key])) {
35
58
  throw new Error('runtimeLimits must use known fields with positive bounded integers or explicit null');
36
59
  }
37
60
  }
38
- const configured = { hostTools: options.hostTools, createJudge: options.createJudge, onToolAttempt: options.onToolAttempt, onWorkflowEvent: options.onWorkflowEvent,
61
+ const configured = { hostTools: options.hostTools, createJudge: options.createJudge, onToolAttempt: options.onToolAttempt, onWorkflowEvent: options.onWorkflowEvent, sop: options.sop,
39
62
  runtimeLimits: Object.freeze(limits) };
40
63
  return pi => registerExtension(pi, configured);
41
64
  }
@@ -61,15 +84,17 @@ function registerExtension(pi, configuration) {
61
84
  if (typeof tool.execute !== 'function' || !tool.parameters || typeof tool.parameters !== 'object')
62
85
  throw new Error('Host tools require an executable definition and parameter schema');
63
86
  Compile(tool.parameters);
87
+ if (tool.resultSchema)
88
+ Compile(tool.resultSchema);
64
89
  names.add(tool.name);
65
- return { ...tool, parameters: structuredClone(tool.parameters) };
90
+ return { ...tool, parameters: structuredClone(tool.parameters), ...(tool.resultSchema ? { resultSchema: structuredClone(tool.resultSchema) } : {}) };
66
91
  });
67
92
  }
68
93
  const active = new Set(pi.getActiveTools());
69
94
  const builtins = new Set(pi.getAllTools().filter(t => t.sourceInfo.source === 'builtin').map(t => t.name));
70
95
  const definitions = Object.entries(constructors).filter(([name]) => active.has(name) && builtins.has(name) && (trusted || !mutatingTools.has(name)))
71
96
  .map(([, factory]) => factory(ctx.cwd));
72
- return [demoSearchTool(), ...definitions];
97
+ return [demoSearchTool(), ...supportTriageTools(), ...definitions];
73
98
  };
74
99
  const state = async (ctx) => {
75
100
  const key = `${ctx.sessionManager.getSessionId()}:${ctx.cwd}`;
@@ -84,17 +109,50 @@ function registerExtension(pi, configuration) {
84
109
  await old.inFlight;
85
110
  }
86
111
  const tools = toolSet(ctx);
87
- current = { key, clearStatus: () => { if (ctx.hasUI) {
112
+ current = { key, sessionFile: () => ctx.sessionManager.getSessionFile(), clearStatus: () => { if (ctx.hasUI) {
88
113
  ctx.ui.setStatus('agentrun', undefined);
89
114
  ctx.ui.setWidget('agentrun', undefined);
90
115
  } }, tools, toolNames: tools.map(t => t.name), input: {}, busy: false, closed: false,
91
116
  service: new WorkflowExtensionService({ allowedTools: tools.map(t => t.name), limits: serviceLimits }) };
117
+ const restored = restoreWorkflowSession(ctx.sessionManager.getBranch());
118
+ if (restored) {
119
+ current.draft = restored.workflow;
120
+ current.input = restored.input;
121
+ current.demo = restored.demo;
122
+ current.savedName = restored.savedName;
123
+ current.report = restored.report;
124
+ current.observation = restored.observation;
125
+ current.runId = restored.runId;
126
+ current.sop = restored.sop;
127
+ }
92
128
  return current;
93
129
  };
130
+ const persist = (s, running = false, closing = false) => {
131
+ if (current !== s || s.closed && !closing || !s.draft)
132
+ return;
133
+ const record = { version: 1, workflow: s.draft,
134
+ input: s.input, running, createdAt: new Date().toISOString(),
135
+ ...(s.savedName ? { savedName: s.savedName } : {}), ...(s.demo ? { demo: s.demo } : {}),
136
+ ...(s.runId ? { runId: s.runId } : {}), ...(s.report ? { report: s.report } : {}),
137
+ ...(s.observation ? { observation: s.observation } : {}), ...(s.sop ? { sop: s.sop } : {}), };
138
+ try {
139
+ const data = structuredClone(record);
140
+ // Never advertise history that this release cannot restore (including
141
+ // non-JSON or oversized receipts). Display/retention limits are not run limits.
142
+ if (!restoreWorkflowSession([{ type: 'custom', customType: 'agentrun:snapshot', data }]))
143
+ throw new Error('Unrestorable workflow receipt');
144
+ pi.appendEntry('agentrun:snapshot', data);
145
+ const file = s.sessionFile();
146
+ s.persistenceWarning = file && existsSync(file) ? undefined : 'Run receipts are in memory only: Pi has not yet persisted this session. Named procedure saves are durable; begin a normal Pi conversation to persist native session history.';
147
+ }
148
+ catch {
149
+ s.persistenceWarning = 'Run state could not be retained in the Pi session. Keep this session open; named workflow saving is separate.';
150
+ }
151
+ };
94
152
  const readiness = (ctx, s) => ({
95
- skill: pi.getCommands().some(item => item.name === 'skill:agentrun-author' && item.source === 'skill'),
153
+ skill: pi.getCommands().some(item => item.name === `skill:${AUTHOR_SKILL_NAME}` && item.source === 'skill'),
96
154
  pi: !!ctx.model && ctx.modelRegistry.getAll().some(model => model.id === ctx.model?.id && model.provider === ctx.model?.provider && model.api === ctx.model?.api),
97
- jev: !!configuration.createJudge || !!process.env.TYPESAFE_API_KEY?.trim(), sop: false, running: s.busy, workflow: !!s.draft,
155
+ jev: !!configuration.createJudge || !!process.env.TYPESAFE_API_KEY?.trim(), sop: !!configuration.sop || !!s.sop, running: s.busy, workflow: !!s.draft,
98
156
  mode: s.draft ? s.demo ? 'scripted' : 'live' : undefined,
99
157
  });
100
158
  const show = (s, content, details = {}) => {
@@ -103,7 +161,7 @@ function registerExtension(pi, configuration) {
103
161
  pi.sendMessage({ customType: 'agentrun', content: safe(content), display: true, details });
104
162
  };
105
163
  const commandError = (s, error) => {
106
- const message = error instanceof Error && (error.name === 'ExtensionServiceError' || /^(No workflow|Inspect a workflow|A workflow|Cannot inspect|Workflow|Code|Executable|Unsupported|Invalid workflow|AgentRun requires|AgentRun authoring|The active Pi)/.test(error.message))
164
+ const message = error instanceof Error && (error instanceof WorkflowStoreError || error.name === 'ExtensionServiceError' || /^(No workflow|Inspect a workflow|A workflow|Cannot inspect|Workflow|Code|Executable|Unsupported|Invalid workflow|AgentRun requires|AgentRun authoring|The active Pi|Saved workflow|SOP|The SOP|Use \/agentrun sop)/.test(error.message))
107
165
  ? error.message.slice(0, 2000) : 'AgentRun could not complete this operation. Use /agentrun status to check setup and /agentrun to inspect the workflow. No fallback was selected.';
108
166
  show(s, message);
109
167
  };
@@ -118,30 +176,42 @@ function registerExtension(pi, configuration) {
118
176
  const candidate = workflow === undefined ? s.draft : workflow;
119
177
  if (candidate === undefined)
120
178
  throw new Error('No workflow yet. Describe a task with /agentrun <task>.');
121
- if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
122
- throw new Error('Cannot inspect workflow: workflow must be a JSON object, not a JSON-encoded string or array. Pass the complete object directly.');
123
- }
124
179
  // Include active tools available only with operator trust, without granting that trust.
125
180
  // Execution rechecks the actual per-run permissions and captures implementations.
126
181
  const checker = new WorkflowExtensionService({ allowedTools: toolSet(ctx, true).map(tool => tool.name), limits: serviceLimits });
127
- let inspection;
128
- try {
129
- inspection = checker.preflight(candidate, input);
130
- if (inspection.requires.sopSections.length)
131
- throw new Error('Native Pi does not supply SOP text in V1; use a configured SDK host. Keep the required sections intact.');
132
- }
133
- catch (error) {
134
- throw new Error(`Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`);
135
- }
182
+ const { inspection, tree } = inspectCandidate(checker, candidate, input);
136
183
  // Stage the definition and input together only after nonexecuting checks succeed.
137
184
  const nextDraft = workflow === undefined ? s.draft : structuredClone(workflow);
138
185
  const changed = !s.draft || inspectWorkflow(s.draft).sha256 !== inspection.sha256;
139
186
  const nextInput = input === undefined ? changed ? {} : s.input : structuredClone(input);
140
187
  s.draft = nextDraft;
141
- if (changed)
188
+ if (changed) {
142
189
  s.demo = undefined;
190
+ s.savedName = undefined;
191
+ s.observation = undefined;
192
+ s.report = undefined;
193
+ s.runId = undefined;
194
+ }
195
+ if (input !== undefined) {
196
+ s.observation = undefined;
197
+ s.report = undefined;
198
+ s.runId = undefined;
199
+ }
143
200
  s.input = nextInput;
144
- return { inspection, tree: formatWorkflowTree(inspection) };
201
+ if (workflow !== undefined || input !== undefined)
202
+ persist(s);
203
+ return { inspection, tree };
204
+ };
205
+ const view = (ctx, s) => {
206
+ const result = workflowView(s.draft, { input: s.input,
207
+ observation: s.observation, report: s.report, running: s.busy, savedName: s.savedName,
208
+ mode: s.demo ? 'scripted · fictional · no model calls' : 'live adapters',
209
+ model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : 'not selected', limits,
210
+ availableTools: toolSet(ctx).map(tool => tool.name),
211
+ });
212
+ if (s.persistenceWarning)
213
+ result.summary.push(s.persistenceWarning);
214
+ return result;
145
215
  };
146
216
  const depsFor = (ctx, s, signal) => {
147
217
  let modelCalls = 0, toolCalls = 0;
@@ -212,17 +282,37 @@ function registerExtension(pi, configuration) {
212
282
  throw new Error('Workflow requested an unavailable tool');
213
283
  let result;
214
284
  try {
215
- result = await tool.execute('agentrun-effect', input, effectSignal, undefined, ctx);
285
+ result = await tool.execute('agentrun-effect', input, effectSignal, undefined, workflowToolContext(ctx));
216
286
  }
217
287
  catch (error) {
218
288
  if (error instanceof ToolInputValidationError)
219
289
  throw new ToolInputValidationError(node.label, tool.name, error.problems);
220
290
  throw error;
221
291
  }
222
- return !configuration.hostTools && node.tool === 'search' ? result.details : { content: result.content, ...(result.details === undefined ? {} : { details: result.details }) };
292
+ return !configuration.hostTools && ['search', 'support_demo_lookup', 'support_demo_handoff'].includes(node.tool ?? '') ? result.details : { content: result.content, ...(result.details === undefined ? {} : { details: result.details }) };
223
293
  },
224
294
  };
225
295
  };
296
+ /** The SOP text a run of a workflow naming `needed` sections uses: the host's, else the file the operator named. */
297
+ const sopFor = async (ctx, s, needed) => {
298
+ if (!needed.length)
299
+ return undefined;
300
+ let sop;
301
+ if (configuration.sop)
302
+ sop = { text: configuration.sop, sha256: sopDigest(configuration.sop) };
303
+ else if (s.sop) {
304
+ const read = await readSopFile(ctx.cwd, s.sop.file);
305
+ if (read.sha256 !== s.sop.sha256)
306
+ throw new Error(`SOP file ${s.sop.file} changed since it was supplied. Run /agentrun sop ${s.sop.file} again to use the new text.`);
307
+ sop = read;
308
+ }
309
+ else
310
+ throw new Error(`Workflow requires SOP text for ${needed.map(name => `"${name}"`).join(', ')}. Supply it with /agentrun sop <file>.`);
311
+ const missing = missingSopSections(sop.text, needed);
312
+ if (missing.length)
313
+ throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}.`);
314
+ return sop;
315
+ };
226
316
  const execute = async (ctx, s, input, options = {}) => {
227
317
  if (s.busy)
228
318
  throw new Error('A workflow is already running. Use /agentrun stop first.');
@@ -233,9 +323,9 @@ function registerExtension(pi, configuration) {
233
323
  s.controller = new AbortController();
234
324
  const signals = [s.controller.signal, options.signal, ctx.signal].filter((v) => !!v);
235
325
  const signal = AbortSignal.any(signals);
326
+ let driver;
236
327
  try {
237
- if (inspectWorkflow(s.draft).requires.sopSections.length)
238
- throw new Error('Workflow requires SOP text. Native Pi does not supply SOP text in V1; use a configured SDK host. Keep the required sections intact.');
328
+ const sop = await sopFor(ctx, s, inspectWorkflow(s.draft).requires.sopSections);
239
329
  signal.throwIfAborted();
240
330
  const available = toolSet(ctx, options.trusted);
241
331
  if (available.map(t => t.name).join(',') !== s.toolNames.join(',')) {
@@ -247,55 +337,129 @@ function registerExtension(pi, configuration) {
247
337
  }
248
338
  // Resolve implementations from the execution context even if names are unchanged.
249
339
  s.tools = available;
340
+ // Validate the actual input before asking for model credentials or executing code probes.
341
+ s.service.preflight(s.draft, input);
250
342
  const prepared = s.service.prepare(s.draft, { allowExecutableCandidates: options.trusted ?? false });
343
+ // Adapter admission is not a run. Failed setup must not leave a running receipt.
344
+ const deps = scripted
345
+ ? s.demo === 'triage' || s.demo === 'triage-failure' ? scriptedSupportTriageDeps({ failLookup: s.demo === 'triage-failure' }) : scriptedDemoDeps(s.demo === 'empty')
346
+ : depsFor(ctx, s, signal);
251
347
  s.input = structuredClone(input);
252
348
  const tree = formatWorkflowTree(inspectWorkflow(s.draft));
253
349
  const label = scripted ? 'Scripted demo — fictional sources; no model calls.' : `Running with Pi · ${limits.deadlineMs === null ? 'no workflow deadline; operator cancellation remains available' : `${limits.deadlineMs / 1000}-second workflow deadline`}. Jev is used only for system one nodes.`;
254
350
  options.update?.(textResult(`${label}\n\n${tree}`));
255
351
  stage(ctx, s, 'agent.run() · starting');
256
- let completed = 0;
257
- const active = new Map();
352
+ const observation = new WorkflowObservation();
353
+ // A run this session left interrupted or failed, over the same draft and input, continues from its journal;
354
+ // anything else starts a new run.
355
+ const runRoot = join(ctx.cwd, '.pi', 'agentrun', 'runs');
356
+ const resumable = !scripted && s.runId !== undefined && ['interrupted', 'failed'].includes(s.report?.status ?? '') && existsSync(join(runRoot, s.runId));
357
+ let runId = resumable ? s.runId : randomUUID();
358
+ if (!scripted) {
359
+ await mkdir(runRoot, { recursive: true });
360
+ const bind = { input: s.input, tools: [...s.toolNames].sort(), ...(sop ? { sop: sop.sha256 } : {}) };
361
+ const open = (id) => openRecovery(fileStore(join(runRoot, id)), s.draft, { key: id, view: prepared.workflow, bind });
362
+ try {
363
+ try {
364
+ driver = await open(runId);
365
+ }
366
+ catch (error) {
367
+ // A run bound to other input, SOP text or tools is a different run: it starts anew, and the old journal stays.
368
+ if (!resumable || !(error instanceof RecoveryError) || error.code !== 'RUN_STORE_BINDING_MISMATCH')
369
+ throw error;
370
+ runId = randomUUID();
371
+ driver = await open(runId);
372
+ }
373
+ }
374
+ catch (error) {
375
+ throw new Error(`Cannot ${resumable ? 'resume' : 'start'} run ${runId}: ${error instanceof Error ? error.message : 'recovery store refused'}`);
376
+ }
377
+ s.pause = () => driver.stop({ action: 'pause', source: 'operator' });
378
+ }
379
+ s.observation = observation.data;
380
+ s.report = undefined;
381
+ s.runId = runId;
382
+ persist(s, true);
258
383
  const ordinal = ++runOrdinal;
259
384
  let sequence = 0;
260
385
  const report = await s.service.run(s.input, {
261
- deps: scripted ? scriptedDemoDeps(s.demo === 'empty') : depsFor(ctx, s, signal), signal,
386
+ deps: observation.observe(driver ? withRecovery(driver, sop ? { ...deps, sop: sop.text } : deps) : sop ? { ...deps, sop: sop.text } : deps),
387
+ signal: driver ? AbortSignal.any([signal, driver.signal]) : signal,
262
388
  ...(configuration.onWorkflowEvent ? { onTraceEvent: (event) => configuration.onWorkflowEvent({ workflowDigest: prepared.digest, runOrdinal: ordinal, sequence: ++sequence, event }) } : {}),
263
389
  onEvent: event => {
264
- if (current !== s || s.closed || !event.label)
390
+ observation.record(event);
391
+ if (current !== s || s.closed)
265
392
  return;
266
- if (event.type === 'node.start')
267
- active.set(event.label, (active.get(event.label) ?? 0) + 1);
268
- else if (event.type === 'node.end') {
269
- const count = (active.get(event.label) ?? 1) - 1;
270
- if (count)
271
- active.set(event.label, count);
272
- else
273
- active.delete(event.label);
274
- completed++;
275
- }
393
+ const progress = progressLines(observation.data);
394
+ if (ctx.mode === 'tui')
395
+ ctx.ui.setWidget('agentrun', [safe(prepared.inspection.name), ...progress.map(safe), '/agentrun inspect · /agentrun stop']);
276
396
  else
277
- return;
278
- const running = [...active].map(([name, count]) => count > 1 ? `${name} ×${count}` : name).join(', ');
279
- const progress = `agent.run() · ${completed} finished${running ? ` · ${running}` : ''}`;
280
- stage(ctx, s, progress);
281
- if (ctx.hasUI)
282
- ctx.ui.setWidget('agentrun', [safe(progress), '/agentrun stop to cancel']);
283
- options.update?.(textResult(`${label}\n\n${tree}\n\n${progress}`));
397
+ options.update?.(textResult(`${label}\n\n${progress.join('\n')}`));
284
398
  },
285
399
  });
400
+ if (driver && report.error?.code === 'FROZEN_EFFECT_UNKNOWN') {
401
+ // The journal names what the driver refused to repeat. Read it once the driver has let go of it.
402
+ await driver.close().catch(() => { });
403
+ const journal = await readFile(join(runRoot, runId, 'journal.json'), 'utf8').then(text => JSON.parse(text), () => undefined);
404
+ const unknown = (journal?.effects ?? []).filter(effect => effect.status === 'unknown').slice(0, 100);
405
+ if (unknown.length)
406
+ report.uncertainEffects = unknown.map(effect => ({ idempotencyKey: effect.id, outcome: 'unknown' }));
407
+ }
408
+ s.report = report;
409
+ persist(s);
286
410
  const summary = formatRunReport(report, scripted);
287
411
  const { events: _events, ...modelReport } = report;
288
412
  const mode = scripted ? 'scripted' : 'live';
289
- return textResult(options.modelResult ? modelJson({ mode, ...modelReport }) : summary, { mode, ...report });
413
+ return textResult(options.modelResult ? modelJson({ mode, ...modelReport }) : summary + (s.persistenceWarning ? `\n${s.persistenceWarning}` : ''), { mode, ...report,
414
+ // Failure diagnostics must not echo rejected raw arguments. The operator
415
+ // can inspect the retained draft explicitly; the error receipt stays sanitized.
416
+ ...(report.error ? {} : { view: workflowView(s.draft, { input: s.input, observation: s.observation, report, mode }) }) });
417
+ }
418
+ catch (error) {
419
+ if (error instanceof ExtensionServiceError && error.code === 'admission' && error.message.includes('code requires allowExecutableCandidates')) {
420
+ throw new ExtensionServiceError(error.code, error.message.replaceAll('code requires allowExecutableCandidates', 'Code requires the user command /agentrun run --trusted for each run. This permits local, unsandboxed execution.'));
421
+ }
422
+ throw error;
290
423
  }
291
424
  finally {
292
425
  s.busy = false;
293
426
  s.controller = undefined;
427
+ s.pause = undefined;
428
+ await driver?.close().catch(() => { });
294
429
  stage(ctx, s);
295
430
  if (current === s && !s.closed && ctx.hasUI)
296
431
  ctx.ui.setWidget('agentrun', undefined);
297
432
  }
298
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
+ };
299
463
  const launch = async (ctx, s, input, trusted = false) => {
300
464
  if (s.busy)
301
465
  throw new Error('A workflow is already running. Use /agentrun stop first.');
@@ -314,33 +478,69 @@ function registerExtension(pi, configuration) {
314
478
  };
315
479
  pi.registerTool({
316
480
  name: 'agentrun', label: 'AgentRun workflow',
317
- description: 'Use the agentrun-author 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
318
482
  ? 'Only the explicit host-configured tools are available, including during trusted runs. Outer permission hooks are not inherited. '
319
- : '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 tool reads fictional demo sources only. ') + 'Scripted demos stay scripted on rerun; use /agentrun demo live to switch. Saving workflows is not part of v1.',
320
- parameters: Type.Object({ action: Type.Union([Type.Literal('describe'), Type.Literal('inspect'), Type.Literal('run')]),
321
- workflow: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
322
- description: 'The complete AgentRun workflow JSON object. Pass the object directly, not a JSON-encoded string. Inspection validates its DSL structure.',
323
- })), input: Type.Optional(Type.Record(Type.String(), Type.Unknown())) }),
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.',
484
+ parameters: workflowToolParameters(),
324
485
  executionMode: 'sequential',
325
486
  renderResult(result, options) {
326
487
  const details = result.details;
488
+ if (details?.dryRun)
489
+ return new Text(safe(formatDryRun(details.dryRun)), 0, 0);
327
490
  const text = details?.status && details.calls ? options?.expanded
328
- ? JSON.stringify(details, null, 2) : formatRunReport(details, details.mode === 'scripted')
329
- : result.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
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')
492
+ : details?.view ? formatWorkflowView(details.view) : result.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
330
493
  return new Text(safe(text), 0, 0);
331
494
  },
332
495
  async execute(_id, args, signal, onUpdate, ctx) {
333
496
  const s = await state(ctx);
497
+ const store = new WorkflowStore(ctx.cwd);
498
+ if (args.action === 'save') {
499
+ if (s.busy)
500
+ throw new Error('A workflow is running. Save after it stops.');
501
+ if (!s.draft)
502
+ throw new Error('No workflow to save. Inspect a workflow first.');
503
+ const saved = await store.save(args.name ?? '', s.draft);
504
+ s.savedName = saved.name;
505
+ persist(s);
506
+ return textResult(`Saved ${saved.name} · ${saved.digest.slice(0, 12)}. Input and execution permission are not saved.\nLoad this revision: /agentrun load ${saved.name} ${saved.digest}${s.demo ? '\nScripted demo adapters are not saved. Loading this definition uses live adapters.' : ''}`, saved);
507
+ }
508
+ if (args.action === 'list') {
509
+ const workflows = await store.list();
510
+ return textResult(modelJson(workflows), { workflows });
511
+ }
512
+ if (args.action === 'load') {
513
+ const saved = await store.load(args.name ?? '', args.digest);
514
+ const result = inspect(ctx, s, saved.workflow, args.input);
515
+ s.input = structuredClone(args.input ?? {});
516
+ s.savedName = saved.name;
517
+ s.demo = undefined;
518
+ s.report = undefined;
519
+ s.observation = undefined;
520
+ s.runId = undefined;
521
+ persist(s);
522
+ const preview = view(ctx, s);
523
+ return textResult(modelJson({ preview: formatWorkflowView(preview), workflow: s.draft, input: s.input }), { ...result, saved, view: preview });
524
+ }
334
525
  if (args.action === 'describe') {
335
- const details = { tools: toolSet(ctx).map(t => ({ name: t.name, description: t.description, parameters: t.parameters })),
336
- limits: { ...limits }, structuralLimits: { ...extensionStructuralLimits }, ...readiness(ctx, s), authoring: { workflow: loadPiWorkflowGuide(), jev: loadPiJevGuide() } };
526
+ const details = { tools: describeTools(toolSet(ctx)),
527
+ limits: { ...limits }, structuralLimits: { ...extensionStructuralLimits }, ...readiness(ctx, s), authoring: authoringReferences() };
337
528
  return textResult(JSON.stringify(details, null, 2), details);
338
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
+ }
339
536
  if (args.action === 'run' && args.workflow !== undefined)
340
537
  throw new Error('Inspect the workflow first with action inspect, then run without a workflow argument.');
341
538
  if (args.action === 'inspect') {
342
539
  const result = inspect(ctx, s, args.workflow, args.input);
343
- return textResult(result.tree, { ...result, tools: toolSet(ctx).map(t => t.name) });
540
+ const preview = view(ctx, s);
541
+ // The author needs the exact definition when editing a restored procedure.
542
+ // Pi's renderer shows the readable view, not this model-facing JSON.
543
+ return textResult(modelJson({ preview: formatWorkflowView(preview), workflow: s.draft, input: s.input }), { ...result, view: preview, tools: toolSet(ctx).map(t => t.name) });
344
544
  }
345
545
  return execute(ctx, s, args.input ?? s.input, { signal, update: onUpdate, modelResult: true });
346
546
  },
@@ -349,26 +549,184 @@ function registerExtension(pi, configuration) {
349
549
  description: 'Describe a task, inspect its workflow, or try /agentrun demo',
350
550
  async handler(args, ctx) {
351
551
  const s = await state(ctx);
352
- const raw = args.trim();
353
- const command = /^(help|status|stop|demo|run)(?:\s|$)/i.test(raw) ? raw.toLowerCase().replace(/\s+/g, ' ') : raw;
354
- const words = command.split(/\s+/);
355
- const commandLike = /^(help|status|stop|demo|run)(?:-|$)/i.test(words[0])
356
- && words.slice(1).every(word => word.startsWith('-') || ['live', 'empty'].includes(word));
552
+ let raw = args.trim();
357
553
  try {
554
+ if ((!raw || raw === 'inspect') && s.draft && ctx.mode === 'tui') {
555
+ const action = await showWorkflowInspector(ctx, () => view(ctx, s));
556
+ if (!action)
557
+ return;
558
+ if (action === 'save') {
559
+ const name = await ctx.ui.input('Save procedure', s.savedName ?? 'support-triage');
560
+ if (!name)
561
+ return;
562
+ raw = `save ${name}`;
563
+ }
564
+ else if (action === 'load') {
565
+ const stored = await new WorkflowStore(ctx.cwd).list();
566
+ const names = [...new Set(stored.map(item => item.name))];
567
+ if (!names.length) {
568
+ show(s, 'No saved procedures yet. Use /agentrun save <name>.');
569
+ return;
570
+ }
571
+ const name = await ctx.ui.select('Load procedure', names);
572
+ if (!name)
573
+ return;
574
+ raw = `load ${name}`;
575
+ }
576
+ else if (action === 'edit') {
577
+ const request = await ctx.ui.input('Edit procedure', 'Describe the change; Pi will preview a candidate before running it.');
578
+ if (!request)
579
+ return;
580
+ pi.sendUserMessage(`/skill:agentrun-author Edit the current procedure: ${request}. Inspect the candidate and explain the change. Do not run it yet.`, { expandPromptTemplates: true });
581
+ return;
582
+ }
583
+ else
584
+ raw = action;
585
+ }
586
+ const command = /^(help|status|stop|demo|triage|run|dry-run|inspect)(?:\s|$)/i.test(raw) ? raw.toLowerCase().replace(/\s+/g, ' ') : raw;
587
+ const words = command.split(/\s+/);
588
+ const commandLike = /^(help|status|stop|demo|run|dry-run)(?:-|$)/i.test(words[0])
589
+ && words.slice(1).every(word => word.startsWith('-') || ['live', 'empty'].includes(word));
358
590
  if (command === 'help' || command === '--help' || command === '-h') {
359
591
  show(s, help);
360
592
  }
361
- else if (!command) {
362
- show(s, s.draft ? `${inspect(ctx, s).tree}\n\n${s.busy ? 'Running. /agentrun stop to cancel.' : s.demo ? 'Replay: /agentrun run (scripted, no model calls). Switch to real models: /agentrun demo live.' : 'Run: /agentrun run. Ask Pi to change this workflow.'}` : help);
593
+ else if (!command || command === 'inspect') {
594
+ show(s, s.draft ? formatWorkflowView(view(ctx, s)) : help);
595
+ }
596
+ else if (/^save(?:\s|$)/.test(command)) {
597
+ if (s.busy)
598
+ throw new Error('A workflow is running. Save after it stops.');
599
+ if (!s.draft)
600
+ throw new Error('No workflow to save. Inspect a workflow first.');
601
+ const saved = await new WorkflowStore(ctx.cwd).save(command.slice(4).trim(), s.draft);
602
+ s.savedName = saved.name;
603
+ persist(s);
604
+ show(s, `Saved ${saved.name} · ${saved.digest.slice(0, 12)}. Input and execution permission are not saved.\nReuse: /agentrun load ${saved.name}\nLoad this revision: /agentrun load ${saved.name} ${saved.digest}${s.demo ? '\nScripted demo adapters are not saved. Loading this definition uses live adapters.' : ''}`, saved);
605
+ }
606
+ else if (/^load(?:\s|$)/.test(command)) {
607
+ const [, name, digest] = command.split(/\s+/);
608
+ const saved = await new WorkflowStore(ctx.cwd).load(name ?? '', digest);
609
+ inspect(ctx, s, saved.workflow);
610
+ s.savedName = saved.name;
611
+ s.input = {};
612
+ s.demo = undefined;
613
+ s.report = undefined;
614
+ s.observation = undefined;
615
+ s.runId = undefined;
616
+ persist(s);
617
+ show(s, `Loaded procedure. Supply new input with /agentrun input. Loading never executes it.\n\n${formatWorkflowView(view(ctx, s))}`);
618
+ }
619
+ else if (command === 'list') {
620
+ const stored = await new WorkflowStore(ctx.cwd).list();
621
+ show(s, stored.length ? stored.map(item => `${item.name} · ${item.workflowName}\n/agentrun load ${item.name} ${item.digest}`).join('\n') : 'No saved procedures yet. Use /agentrun save <name>.', { workflows: stored });
622
+ }
623
+ else if (/^history(?:\s|$)/.test(command)) {
624
+ const history = workflowRunHistory(ctx.sessionManager.getBranch());
625
+ if (!history.length) {
626
+ show(s, 'No retained runs on this Pi session branch yet.');
627
+ return;
628
+ }
629
+ const labels = history.map((run, index) => `${index + 1}. ${inspectWorkflow(run.workflow).name} · ${run.report?.status ?? 'interrupted'} · ${run.createdAt}`);
630
+ let index = Number(command.slice(7).trim()) - 1;
631
+ if (command === 'history' && ctx.mode === 'tui') {
632
+ const selected = await ctx.ui.select('Retained runs on this session branch', labels);
633
+ if (!selected)
634
+ return;
635
+ index = labels.indexOf(selected);
636
+ }
637
+ if (!Number.isInteger(index) || index < 0 || index >= history.length) {
638
+ show(s, `${labels.join('\n')}\n\nInspect: /agentrun history <number>`, { runs: history.map(run => ({ runId: run.runId, createdAt: run.createdAt, digest: run.report?.digest, status: run.report?.status })) });
639
+ return;
640
+ }
641
+ const run = history[index];
642
+ const receipt = workflowView(run.workflow, { input: run.input, observation: run.observation, report: run.report, mode: run.demo ? 'scripted' : 'live adapters', savedName: run.savedName });
643
+ // History is read-only. It never replaces the active draft or replays a run.
644
+ if (ctx.mode === 'tui')
645
+ await showWorkflowInspector(ctx, () => ({ ...receipt, readOnly: true }));
646
+ else
647
+ show(s, formatWorkflowView(receipt), { run });
648
+ }
649
+ else if (/^sop(?:\s|$)/.test(command)) {
650
+ const file = raw.trim().slice(3).trim();
651
+ if (!file) {
652
+ show(s, configuration.sop ? 'SOP text comes from the host.' : s.sop ? `SOP: ${s.sop.file} (${s.sop.sha256.slice(0, 12)}). Supply another with /agentrun sop <file>.` : 'No SOP supplied. Use /agentrun sop <file> for a workflow that names sopSection.');
653
+ return;
654
+ }
655
+ if (s.busy)
656
+ throw new Error('A workflow is running. Stop it before changing its SOP.');
657
+ if (configuration.sop)
658
+ throw new Error('SOP text comes from the host; /agentrun sop is not used.');
659
+ const read = await readSopFile(ctx.cwd, file);
660
+ const needed = s.draft ? inspectWorkflow(s.draft).requires.sopSections : [];
661
+ const missing = missingSopSections(read.text, needed);
662
+ if (missing.length)
663
+ throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}. Nothing was changed.`);
664
+ s.sop = { file: read.file, sha256: read.sha256 };
665
+ persist(s);
666
+ show(s, `SOP set: ${read.file} (${read.text.length} characters). ${needed.length ? `Covers ${needed.map(name => `"${name}"`).join(', ')}.` : 'The current workflow names no section.'}`);
667
+ }
668
+ else if (/^input(?:\s|$)/.test(command)) {
669
+ if (!s.draft)
670
+ throw new Error('No workflow yet. Load or describe a procedure first.');
671
+ if (s.busy)
672
+ throw new Error('A workflow is running. Stop it before changing its input.');
673
+ let supplied = raw.slice(5).trim();
674
+ if (!supplied && ctx.hasUI)
675
+ supplied = await ctx.ui.input('New workflow input', 'Describe the new case in plain language, or paste a JSON object.') ?? '';
676
+ if (!supplied) {
677
+ show(s, 'Use /agentrun input <describe the new case>, or ask Pi to use new input. Nothing was run.');
678
+ return;
679
+ }
680
+ if (supplied.startsWith('{')) {
681
+ let input;
682
+ try {
683
+ input = JSON.parse(supplied);
684
+ }
685
+ catch {
686
+ throw new Error('Workflow input must be valid JSON, or describe it in plain language.');
687
+ }
688
+ if (!input || typeof input !== 'object' || Array.isArray(input))
689
+ throw new Error('Workflow input must be an object.');
690
+ inspect(ctx, s, undefined, input);
691
+ show(s, `Input updated. Nothing was run.\n\n${formatWorkflowView(view(ctx, s))}`);
692
+ }
693
+ else {
694
+ const key = singleTextInput(s.draft);
695
+ if (key) {
696
+ inspect(ctx, s, undefined, { [key]: supplied });
697
+ show(s, `Input updated. Nothing was run.\n\n${formatWorkflowView(view(ctx, s))}`);
698
+ }
699
+ else {
700
+ pi.sendUserMessage(`/skill:agentrun-author Set new input for the current inspected workflow: ${supplied}. Keep the procedure unchanged; inspect with input only. Ask for missing facts instead of inventing them. Do not run yet.`, { expandPromptTemplates: true });
701
+ }
702
+ }
703
+ }
704
+ else if (/^triage(?:\s|$)/.test(command)) {
705
+ const parts = command.split(/\s+/).slice(1);
706
+ const live = parts[0] === 'live';
707
+ const name = parts[live ? 1 : 0] ?? 'billing';
708
+ if (!['billing', 'technical', 'ambiguous', 'failure'].includes(name) || parts.length > (live ? 2 : 1) || live && name === 'failure') {
709
+ show(s, 'Use /agentrun triage [billing|technical|ambiguous|failure], or /agentrun triage live [billing|technical|ambiguous].');
710
+ return;
711
+ }
712
+ const input = structuredClone(supportTriageInputs[name === 'failure' ? 'billing' : name]);
713
+ inspect(ctx, s, supportTriageWorkflow, input);
714
+ s.demo = live ? undefined : name === 'failure' ? 'triage-failure' : 'triage';
715
+ show(s, `Fictional support triage · ${live ? 'real model calls' : 'scripted, no model calls'}. No customer actions.\n${formatWorkflowView(view(ctx, s))}`);
716
+ const result = await execute(ctx, s, input);
717
+ show(s, result.content[0].text, result.details);
363
718
  }
364
719
  else if (command === 'status') {
365
720
  const ready = readiness(ctx, s);
366
- 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 — run the demo or describe a task; /reload clears the previous workflow'}\n\nFor your own workflows:\nPi host: ${PI_VERSION}${PI_VERSION === '0.87.0' ? '' : ' (tested on 0.87.0; use the bundled ./node_modules/.bin/pi)'}\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);
367
722
  }
368
723
  else if (command === 'stop') {
724
+ const dry = s.dryRun;
725
+ dry?.abort();
726
+ s.pause?.();
369
727
  s.controller?.abort();
370
728
  s.service.stop();
371
- 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.');
372
730
  }
373
731
  else if (command === 'demo' || command === 'demo live' || command === 'demo empty') {
374
732
  inspect(ctx, s, demoWorkflow);
@@ -379,18 +737,28 @@ function registerExtension(pi, configuration) {
379
737
  else if (command === 'run' || command === 'run --trusted') {
380
738
  await launch(ctx, s, s.input, command === 'run --trusted');
381
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
+ }
382
750
  else if (commandLike || command.startsWith('-')) {
383
751
  show(s, `Unknown AgentRun command: ${raw}\n\n${help}`);
384
752
  }
385
753
  else {
386
754
  if (s.busy)
387
755
  throw new Error('A workflow is running. Stop it before starting another.');
388
- if (!pi.getCommands().some(item => item.name === 'skill:agentrun-author' && item.source === 'skill')) {
756
+ if (!pi.getCommands().some(item => item.name === `skill:${AUTHOR_SKILL_NAME}` && item.source === 'skill')) {
389
757
  throw new Error('AgentRun authoring skill is unavailable. Enable package skills, then /reload. Run /agentrun status to check discovery.');
390
758
  }
391
759
  if (!readiness(ctx, s).pi)
392
760
  throw new Error(PI_MODEL_SETUP_MESSAGE);
393
- pi.sendUserMessage(`/skill:agentrun-author ${raw}`, { expandPromptTemplates: true });
761
+ pi.sendUserMessage(`/skill:${AUTHOR_SKILL_NAME} ${raw}`, { expandPromptTemplates: true });
394
762
  }
395
763
  }
396
764
  catch (error) {
@@ -398,22 +766,43 @@ function registerExtension(pi, configuration) {
398
766
  }
399
767
  },
400
768
  });
401
- const closeSession = async () => {
769
+ const closeSession = async (retainFinal = false) => {
402
770
  if (!current)
403
771
  return;
404
- const old = current;
772
+ const old = current, wasRunning = old.busy;
405
773
  old.clearStatus();
406
- current = undefined;
407
774
  old.closed = true;
775
+ old.dryRun?.abort();
776
+ old.pause?.();
408
777
  old.controller?.abort();
409
778
  await old.service.dispose();
410
779
  await old.inFlight;
780
+ // Only before-switch/shutdown hooks still own the originating branch. A
781
+ // session_tree event is already on another branch and must never receive this receipt.
782
+ if (retainFinal && wasRunning && old.report)
783
+ persist(old, false, true);
784
+ if (current === old)
785
+ current = undefined;
411
786
  };
412
- pi.on('session_shutdown', closeSession);
413
- pi.on('session_before_switch', closeSession);
414
- pi.on('session_before_fork', closeSession);
787
+ pi.on('session_shutdown', () => closeSession(true));
788
+ pi.on('session_before_switch', () => closeSession(true));
789
+ pi.on('session_before_fork', () => closeSession(true));
415
790
  pi.on('session_start', async (_event, ctx) => {
416
791
  if (current && current.key !== `${ctx.sessionManager.getSessionId()}:${ctx.cwd}`)
417
792
  await closeSession();
793
+ await state(ctx);
794
+ });
795
+ pi.on('session_tree', async (_event, ctx) => { await closeSession(); await state(ctx); });
796
+ pi.registerEntryRenderer('agentrun:snapshot', (entry, options) => {
797
+ if (entry.data?.running)
798
+ return undefined;
799
+ const snapshot = restoreWorkflowSession([entry]);
800
+ if (!snapshot)
801
+ return undefined;
802
+ if (!snapshot.report && !snapshot.running)
803
+ return undefined;
804
+ return new Text(safe(options.expanded ? formatWorkflowView(workflowView(snapshot.workflow, {
805
+ input: snapshot.input, report: snapshot.report, observation: snapshot.observation, mode: snapshot.demo ? 'scripted' : 'live adapters',
806
+ })) : `AgentRun receipt · ${snapshot.report?.status ?? 'interrupted'} · ${inspectWorkflow(snapshot.workflow).name}`), 0, 0);
418
807
  });
419
808
  }