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

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 +123 -32
  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 +242 -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 +463 -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,24 +1,47 @@
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 });
43
+ /** Forgets a session's last run (its observation, report and run id), so the next run starts fresh. */
44
+ const clearRun = (s) => { s.observation = undefined; s.report = undefined; s.runId = undefined; };
22
45
  /** Configure the native extension without granting configuration to workflow authors. */
23
46
  export function createAgentRunExtension(options = {}) {
24
47
  if (options.hostTools !== undefined && typeof options.hostTools !== 'function')
@@ -29,13 +52,15 @@ export function createAgentRunExtension(options = {}) {
29
52
  throw new Error('onToolAttempt must be a host callback');
30
53
  if (options.onWorkflowEvent !== undefined && typeof options.onWorkflowEvent !== 'function')
31
54
  throw new Error('onWorkflowEvent must be a host callback');
55
+ if (options.sop !== undefined && (typeof options.sop !== 'string' || !options.sop.trim()))
56
+ throw new Error('sop must be nonempty text');
32
57
  const limits = { ...nativeLimits, ...options.runtimeLimits };
33
58
  for (const [key, value] of Object.entries(limits)) {
34
59
  if (!(key in nativeLimits) || value !== null && (!Number.isSafeInteger(value) || value < 1 || value > nativeLimits[key])) {
35
60
  throw new Error('runtimeLimits must use known fields with positive bounded integers or explicit null');
36
61
  }
37
62
  }
38
- const configured = { hostTools: options.hostTools, createJudge: options.createJudge, onToolAttempt: options.onToolAttempt, onWorkflowEvent: options.onWorkflowEvent,
63
+ const configured = { hostTools: options.hostTools, createJudge: options.createJudge, onToolAttempt: options.onToolAttempt, onWorkflowEvent: options.onWorkflowEvent, sop: options.sop,
39
64
  runtimeLimits: Object.freeze(limits) };
40
65
  return pi => registerExtension(pi, configured);
41
66
  }
@@ -61,15 +86,17 @@ function registerExtension(pi, configuration) {
61
86
  if (typeof tool.execute !== 'function' || !tool.parameters || typeof tool.parameters !== 'object')
62
87
  throw new Error('Host tools require an executable definition and parameter schema');
63
88
  Compile(tool.parameters);
89
+ if (tool.resultSchema)
90
+ Compile(tool.resultSchema);
64
91
  names.add(tool.name);
65
- return { ...tool, parameters: structuredClone(tool.parameters) };
92
+ return { ...tool, parameters: structuredClone(tool.parameters), ...(tool.resultSchema ? { resultSchema: structuredClone(tool.resultSchema) } : {}) };
66
93
  });
67
94
  }
68
95
  const active = new Set(pi.getActiveTools());
69
96
  const builtins = new Set(pi.getAllTools().filter(t => t.sourceInfo.source === 'builtin').map(t => t.name));
70
97
  const definitions = Object.entries(constructors).filter(([name]) => active.has(name) && builtins.has(name) && (trusted || !mutatingTools.has(name)))
71
98
  .map(([, factory]) => factory(ctx.cwd));
72
- return [demoSearchTool(), ...definitions];
99
+ return [demoSearchTool(), ...supportTriageTools(), ...definitions];
73
100
  };
74
101
  const state = async (ctx) => {
75
102
  const key = `${ctx.sessionManager.getSessionId()}:${ctx.cwd}`;
@@ -84,17 +111,50 @@ function registerExtension(pi, configuration) {
84
111
  await old.inFlight;
85
112
  }
86
113
  const tools = toolSet(ctx);
87
- current = { key, clearStatus: () => { if (ctx.hasUI) {
114
+ current = { key, sessionFile: () => ctx.sessionManager.getSessionFile(), clearStatus: () => { if (ctx.hasUI) {
88
115
  ctx.ui.setStatus('agentrun', undefined);
89
116
  ctx.ui.setWidget('agentrun', undefined);
90
117
  } }, tools, toolNames: tools.map(t => t.name), input: {}, busy: false, closed: false,
91
118
  service: new WorkflowExtensionService({ allowedTools: tools.map(t => t.name), limits: serviceLimits }) };
119
+ const restored = restoreWorkflowSession(ctx.sessionManager.getBranch());
120
+ if (restored) {
121
+ current.draft = restored.workflow;
122
+ current.input = restored.input;
123
+ current.demo = restored.demo;
124
+ current.savedName = restored.savedName;
125
+ current.report = restored.report;
126
+ current.observation = restored.observation;
127
+ current.runId = restored.runId;
128
+ current.sop = restored.sop;
129
+ }
92
130
  return current;
93
131
  };
132
+ const persist = (s, running = false, closing = false) => {
133
+ if (current !== s || s.closed && !closing || !s.draft)
134
+ return;
135
+ const record = { version: 1, workflow: s.draft,
136
+ input: s.input, running, createdAt: new Date().toISOString(),
137
+ ...(s.savedName ? { savedName: s.savedName } : {}), ...(s.demo ? { demo: s.demo } : {}),
138
+ ...(s.runId ? { runId: s.runId } : {}), ...(s.report ? { report: s.report } : {}),
139
+ ...(s.observation ? { observation: s.observation } : {}), ...(s.sop ? { sop: s.sop } : {}), };
140
+ try {
141
+ const data = structuredClone(record);
142
+ // Never advertise history that this release cannot restore (including
143
+ // non-JSON or oversized receipts). Display/retention limits are not run limits.
144
+ if (!restoreWorkflowSession([{ type: 'custom', customType: 'agentrun:snapshot', data }]))
145
+ throw new Error('Unrestorable workflow receipt');
146
+ pi.appendEntry('agentrun:snapshot', data);
147
+ const file = s.sessionFile();
148
+ 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.';
149
+ }
150
+ catch {
151
+ s.persistenceWarning = 'Run state could not be retained in the Pi session. Keep this session open; named workflow saving is separate.';
152
+ }
153
+ };
94
154
  const readiness = (ctx, s) => ({
95
- skill: pi.getCommands().some(item => item.name === 'skill:agentrun-author' && item.source === 'skill'),
155
+ skill: pi.getCommands().some(item => item.name === `skill:${AUTHOR_SKILL_NAME}` && item.source === 'skill'),
96
156
  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,
157
+ jev: !!configuration.createJudge || !!process.env.TYPESAFE_API_KEY?.trim(), sop: !!configuration.sop || !!s.sop, running: s.busy, workflow: !!s.draft,
98
158
  mode: s.draft ? s.demo ? 'scripted' : 'live' : undefined,
99
159
  });
100
160
  const show = (s, content, details = {}) => {
@@ -103,7 +163,7 @@ function registerExtension(pi, configuration) {
103
163
  pi.sendMessage({ customType: 'agentrun', content: safe(content), display: true, details });
104
164
  };
105
165
  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))
166
+ 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
167
  ? 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
168
  show(s, message);
109
169
  };
@@ -118,30 +178,37 @@ function registerExtension(pi, configuration) {
118
178
  const candidate = workflow === undefined ? s.draft : workflow;
119
179
  if (candidate === undefined)
120
180
  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
181
  // Include active tools available only with operator trust, without granting that trust.
125
182
  // Execution rechecks the actual per-run permissions and captures implementations.
126
183
  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
- }
184
+ const { inspection, tree } = inspectCandidate(checker, candidate, input);
136
185
  // Stage the definition and input together only after nonexecuting checks succeed.
137
186
  const nextDraft = workflow === undefined ? s.draft : structuredClone(workflow);
138
187
  const changed = !s.draft || inspectWorkflow(s.draft).sha256 !== inspection.sha256;
139
188
  const nextInput = input === undefined ? changed ? {} : s.input : structuredClone(input);
140
189
  s.draft = nextDraft;
141
- if (changed)
190
+ if (changed) {
142
191
  s.demo = undefined;
192
+ s.savedName = undefined;
193
+ clearRun(s);
194
+ }
195
+ if (input !== undefined)
196
+ clearRun(s);
143
197
  s.input = nextInput;
144
- return { inspection, tree: formatWorkflowTree(inspection) };
198
+ if (workflow !== undefined || input !== undefined)
199
+ persist(s);
200
+ return { inspection, tree };
201
+ };
202
+ const view = (ctx, s) => {
203
+ const result = workflowView(s.draft, { input: s.input,
204
+ observation: s.observation, report: s.report, running: s.busy, savedName: s.savedName,
205
+ mode: s.demo ? 'scripted · fictional · no model calls' : 'live adapters',
206
+ model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : 'not selected', limits,
207
+ availableTools: toolSet(ctx).map(tool => tool.name),
208
+ });
209
+ if (s.persistenceWarning)
210
+ result.summary.push(s.persistenceWarning);
211
+ return result;
145
212
  };
146
213
  const depsFor = (ctx, s, signal) => {
147
214
  let modelCalls = 0, toolCalls = 0;
@@ -212,17 +279,37 @@ function registerExtension(pi, configuration) {
212
279
  throw new Error('Workflow requested an unavailable tool');
213
280
  let result;
214
281
  try {
215
- result = await tool.execute('agentrun-effect', input, effectSignal, undefined, ctx);
282
+ result = await tool.execute('agentrun-effect', input, effectSignal, undefined, workflowToolContext(ctx));
216
283
  }
217
284
  catch (error) {
218
285
  if (error instanceof ToolInputValidationError)
219
286
  throw new ToolInputValidationError(node.label, tool.name, error.problems);
220
287
  throw error;
221
288
  }
222
- return !configuration.hostTools && node.tool === 'search' ? result.details : { content: result.content, ...(result.details === undefined ? {} : { details: result.details }) };
289
+ 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
290
  },
224
291
  };
225
292
  };
293
+ /** The SOP text a run of a workflow naming `needed` sections uses: the host's, else the file the operator named. */
294
+ const sopFor = async (ctx, s, needed) => {
295
+ if (!needed.length)
296
+ return undefined;
297
+ let sop;
298
+ if (configuration.sop)
299
+ sop = { text: configuration.sop, sha256: sopDigest(configuration.sop) };
300
+ else if (s.sop) {
301
+ const read = await readSopFile(ctx.cwd, s.sop.file);
302
+ if (read.sha256 !== s.sop.sha256)
303
+ 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.`);
304
+ sop = read;
305
+ }
306
+ else
307
+ throw new Error(`Workflow requires SOP text for ${needed.map(name => `"${name}"`).join(', ')}. Supply it with /agentrun sop <file>.`);
308
+ const missing = missingSopSections(sop.text, needed);
309
+ if (missing.length)
310
+ throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}.`);
311
+ return sop;
312
+ };
226
313
  const execute = async (ctx, s, input, options = {}) => {
227
314
  if (s.busy)
228
315
  throw new Error('A workflow is already running. Use /agentrun stop first.');
@@ -233,9 +320,9 @@ function registerExtension(pi, configuration) {
233
320
  s.controller = new AbortController();
234
321
  const signals = [s.controller.signal, options.signal, ctx.signal].filter((v) => !!v);
235
322
  const signal = AbortSignal.any(signals);
323
+ let driver;
236
324
  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.');
325
+ const sop = await sopFor(ctx, s, inspectWorkflow(s.draft).requires.sopSections);
239
326
  signal.throwIfAborted();
240
327
  const available = toolSet(ctx, options.trusted);
241
328
  if (available.map(t => t.name).join(',') !== s.toolNames.join(',')) {
@@ -247,55 +334,130 @@ function registerExtension(pi, configuration) {
247
334
  }
248
335
  // Resolve implementations from the execution context even if names are unchanged.
249
336
  s.tools = available;
337
+ // Validate the actual input before asking for model credentials or executing code probes.
338
+ s.service.preflight(s.draft, input);
250
339
  const prepared = s.service.prepare(s.draft, { allowExecutableCandidates: options.trusted ?? false });
340
+ // Adapter admission is not a run. Failed setup must not leave a running receipt.
341
+ const deps = scripted
342
+ ? s.demo === 'triage' || s.demo === 'triage-failure' ? scriptedSupportTriageDeps({ failLookup: s.demo === 'triage-failure' }) : scriptedDemoDeps(s.demo === 'empty')
343
+ : depsFor(ctx, s, signal);
251
344
  s.input = structuredClone(input);
252
345
  const tree = formatWorkflowTree(inspectWorkflow(s.draft));
253
346
  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
347
  options.update?.(textResult(`${label}\n\n${tree}`));
255
348
  stage(ctx, s, 'agent.run() · starting');
256
- let completed = 0;
257
- const active = new Map();
349
+ const observation = new WorkflowObservation();
350
+ // A run this session left interrupted or failed, over the same draft and input, continues from its journal;
351
+ // anything else starts a new run.
352
+ const runRoot = join(ctx.cwd, '.pi', 'agentrun', 'runs');
353
+ const resumable = !scripted && s.runId !== undefined && ['interrupted', 'failed'].includes(s.report?.status ?? '') && existsSync(join(runRoot, s.runId));
354
+ let runId = resumable ? s.runId : randomUUID();
355
+ if (!scripted) {
356
+ await mkdir(runRoot, { recursive: true });
357
+ const bind = { input: s.input, tools: [...s.toolNames].sort(), ...(sop ? { sop: sop.sha256 } : {}) };
358
+ const open = (id) => openRecovery(fileStore(join(runRoot, id)), s.draft, { key: id, view: prepared.workflow, bind });
359
+ try {
360
+ try {
361
+ driver = await open(runId);
362
+ }
363
+ catch (error) {
364
+ // A run bound to other input, SOP text or tools is a different run: it starts anew, and the old journal stays.
365
+ if (!resumable || !(error instanceof RecoveryError) || error.code !== 'RUN_STORE_BINDING_MISMATCH')
366
+ throw error;
367
+ runId = randomUUID();
368
+ driver = await open(runId);
369
+ }
370
+ }
371
+ catch (error) {
372
+ throw new Error(`Cannot ${resumable ? 'resume' : 'start'} run ${runId}: ${error instanceof Error ? error.message : 'recovery store refused'}`);
373
+ }
374
+ s.pause = () => driver.stop({ action: 'pause', source: 'operator' });
375
+ }
376
+ s.observation = observation.data;
377
+ s.report = undefined;
378
+ s.runId = runId;
379
+ persist(s, true);
258
380
  const ordinal = ++runOrdinal;
259
381
  let sequence = 0;
382
+ const runDeps = sop ? { ...deps, sop: sop.text } : deps;
260
383
  const report = await s.service.run(s.input, {
261
- deps: scripted ? scriptedDemoDeps(s.demo === 'empty') : depsFor(ctx, s, signal), signal,
384
+ deps: observation.observe(driver ? withRecovery(driver, runDeps) : runDeps),
385
+ signal: driver ? AbortSignal.any([signal, driver.signal]) : signal,
262
386
  ...(configuration.onWorkflowEvent ? { onTraceEvent: (event) => configuration.onWorkflowEvent({ workflowDigest: prepared.digest, runOrdinal: ordinal, sequence: ++sequence, event }) } : {}),
263
387
  onEvent: event => {
264
- if (current !== s || s.closed || !event.label)
388
+ observation.record(event);
389
+ if (current !== s || s.closed)
265
390
  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
- }
391
+ const progress = progressLines(observation.data);
392
+ if (ctx.mode === 'tui')
393
+ ctx.ui.setWidget('agentrun', [safe(prepared.inspection.name), ...progress.map(safe), '/agentrun inspect · /agentrun stop']);
276
394
  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}`));
395
+ options.update?.(textResult(`${label}\n\n${progress.join('\n')}`));
284
396
  },
285
397
  });
398
+ if (driver && report.error?.code === 'FROZEN_EFFECT_UNKNOWN') {
399
+ // The journal names what the driver refused to repeat. Read it once the driver has let go of it.
400
+ await driver.close().catch(() => { });
401
+ const journal = await readFile(join(runRoot, runId, 'journal.json'), 'utf8').then(text => JSON.parse(text), () => undefined);
402
+ const unknown = (journal?.effects ?? []).filter(effect => effect.status === 'unknown').slice(0, 100);
403
+ if (unknown.length)
404
+ report.uncertainEffects = unknown.map(effect => ({ idempotencyKey: effect.id, outcome: 'unknown' }));
405
+ }
406
+ s.report = report;
407
+ persist(s);
286
408
  const summary = formatRunReport(report, scripted);
287
409
  const { events: _events, ...modelReport } = report;
288
410
  const mode = scripted ? 'scripted' : 'live';
289
- return textResult(options.modelResult ? modelJson({ mode, ...modelReport }) : summary, { mode, ...report });
411
+ return textResult(options.modelResult ? modelJson({ mode, ...modelReport }) : summary + (s.persistenceWarning ? `\n${s.persistenceWarning}` : ''), { mode, ...report,
412
+ // Failure diagnostics must not echo rejected raw arguments. The operator
413
+ // can inspect the retained draft explicitly; the error receipt stays sanitized.
414
+ ...(report.error ? {} : { view: workflowView(s.draft, { input: s.input, observation: s.observation, report, mode }) }) });
415
+ }
416
+ catch (error) {
417
+ if (error instanceof ExtensionServiceError && error.code === 'admission' && error.message.includes('code requires allowExecutableCandidates')) {
418
+ 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.'));
419
+ }
420
+ throw error;
290
421
  }
291
422
  finally {
292
423
  s.busy = false;
293
424
  s.controller = undefined;
425
+ s.pause = undefined;
426
+ await driver?.close().catch(() => { });
294
427
  stage(ctx, s);
295
428
  if (current === s && !s.closed && ctx.hasUI)
296
429
  ctx.ui.setWidget('agentrun', undefined);
297
430
  }
298
431
  };
432
+ /** The inspected draft on synthetic values, or `input`: nothing is paid, and the session's run is untouched. Code runs
433
+ * only when the user's command trusts this dry run. */
434
+ const dryRun = async (ctx, s, input, trusted, signal) => {
435
+ if (!s.draft)
436
+ throw new Error('Inspect a workflow first. Try /agentrun demo, or describe a task with /agentrun <task>.');
437
+ const draft = s.draft;
438
+ // /agentrun stop, the tool call's own abort and the session's end each stop it, from before its first await on.
439
+ const controller = new AbortController();
440
+ s.dryRun?.abort();
441
+ s.dryRun = controller;
442
+ const stopped = AbortSignal.any([controller.signal, ...[signal, ctx.signal].filter((v) => !!v)]);
443
+ try {
444
+ let sop, sopUnavailable;
445
+ try {
446
+ sop = await sopFor(ctx, s, inspectWorkflow(draft).requires.sopSections);
447
+ }
448
+ catch (error) {
449
+ sopUnavailable = error instanceof Error ? error.message : String(error);
450
+ }
451
+ if (stopped.aborted || s.closed)
452
+ return { status: 'not_run', ok: true, skipped: ['The dry run was stopped before it started.'] };
453
+ return await dryRunDraft(draft, { ...(input === undefined ? {} : { input }), ...(sop ? { sop: sop.text } : {}), ...(sopUnavailable ? { sopUnavailable } : {}),
454
+ trusted, trustedCommand: '/agentrun dry-run --trusted', signal: stopped });
455
+ }
456
+ finally {
457
+ if (s.dryRun === controller)
458
+ s.dryRun = undefined;
459
+ }
460
+ };
299
461
  const launch = async (ctx, s, input, trusted = false) => {
300
462
  if (s.busy)
301
463
  throw new Error('A workflow is already running. Use /agentrun stop first.');
@@ -314,33 +476,67 @@ function registerExtension(pi, configuration) {
314
476
  };
315
477
  pi.registerTool({
316
478
  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
479
+ 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
480
  ? '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())) }),
481
+ : '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.',
482
+ parameters: workflowToolParameters(),
324
483
  executionMode: 'sequential',
325
484
  renderResult(result, options) {
326
485
  const details = result.details;
486
+ if (details?.dryRun)
487
+ return new Text(safe(formatDryRun(details.dryRun)), 0, 0);
327
488
  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');
489
+ ? 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')
490
+ : details?.view ? formatWorkflowView(details.view) : result.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
330
491
  return new Text(safe(text), 0, 0);
331
492
  },
332
493
  async execute(_id, args, signal, onUpdate, ctx) {
333
494
  const s = await state(ctx);
495
+ const store = new WorkflowStore(ctx.cwd);
496
+ if (args.action === 'save') {
497
+ if (s.busy)
498
+ throw new Error('A workflow is running. Save after it stops.');
499
+ if (!s.draft)
500
+ throw new Error('No workflow to save. Inspect a workflow first.');
501
+ const saved = await store.save(args.name ?? '', s.draft);
502
+ s.savedName = saved.name;
503
+ persist(s);
504
+ 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);
505
+ }
506
+ if (args.action === 'list') {
507
+ const workflows = await store.list();
508
+ return textResult(modelJson(workflows), { workflows });
509
+ }
510
+ if (args.action === 'load') {
511
+ const saved = await store.load(args.name ?? '', args.digest);
512
+ const result = inspect(ctx, s, saved.workflow, args.input);
513
+ s.input = structuredClone(args.input ?? {});
514
+ s.savedName = saved.name;
515
+ s.demo = undefined;
516
+ clearRun(s);
517
+ persist(s);
518
+ const preview = view(ctx, s);
519
+ return textResult(modelJson({ preview: formatWorkflowView(preview), workflow: s.draft, input: s.input }), { ...result, saved, view: preview });
520
+ }
334
521
  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() } };
522
+ const details = { tools: describeTools(toolSet(ctx)),
523
+ limits: { ...limits }, structuralLimits: { ...extensionStructuralLimits }, ...readiness(ctx, s), authoring: authoringReferences() };
337
524
  return textResult(JSON.stringify(details, null, 2), details);
338
525
  }
526
+ if (args.action === 'dry-run') {
527
+ if (args.workflow !== undefined)
528
+ throw new Error('Inspect the workflow first with action inspect, then dry-run without a workflow argument.');
529
+ const report = await dryRun(ctx, s, args.input, false, signal);
530
+ return textResult(modelJson({ dryRun: report }), { dryRun: report });
531
+ }
339
532
  if (args.action === 'run' && args.workflow !== undefined)
340
533
  throw new Error('Inspect the workflow first with action inspect, then run without a workflow argument.');
341
534
  if (args.action === 'inspect') {
342
535
  const result = inspect(ctx, s, args.workflow, args.input);
343
- return textResult(result.tree, { ...result, tools: toolSet(ctx).map(t => t.name) });
536
+ const preview = view(ctx, s);
537
+ // The author needs the exact definition when editing a restored procedure.
538
+ // Pi's renderer shows the readable view, not this model-facing JSON.
539
+ return textResult(modelJson({ preview: formatWorkflowView(preview), workflow: s.draft, input: s.input }), { ...result, view: preview, tools: toolSet(ctx).map(t => t.name) });
344
540
  }
345
541
  return execute(ctx, s, args.input ?? s.input, { signal, update: onUpdate, modelResult: true });
346
542
  },
@@ -349,26 +545,182 @@ function registerExtension(pi, configuration) {
349
545
  description: 'Describe a task, inspect its workflow, or try /agentrun demo',
350
546
  async handler(args, ctx) {
351
547
  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));
548
+ let raw = args.trim();
357
549
  try {
550
+ if ((!raw || raw === 'inspect') && s.draft && ctx.mode === 'tui') {
551
+ const action = await showWorkflowInspector(ctx, () => view(ctx, s));
552
+ if (!action)
553
+ return;
554
+ if (action === 'save') {
555
+ const name = await ctx.ui.input('Save procedure', s.savedName ?? 'support-triage');
556
+ if (!name)
557
+ return;
558
+ raw = `save ${name}`;
559
+ }
560
+ else if (action === 'load') {
561
+ const stored = await new WorkflowStore(ctx.cwd).list();
562
+ const names = [...new Set(stored.map(item => item.name))];
563
+ if (!names.length) {
564
+ show(s, 'No saved procedures yet. Use /agentrun save <name>.');
565
+ return;
566
+ }
567
+ const name = await ctx.ui.select('Load procedure', names);
568
+ if (!name)
569
+ return;
570
+ raw = `load ${name}`;
571
+ }
572
+ else if (action === 'edit') {
573
+ const request = await ctx.ui.input('Edit procedure', 'Describe the change; Pi will preview a candidate before running it.');
574
+ if (!request)
575
+ return;
576
+ pi.sendUserMessage(`/skill:agentrun-author Edit the current procedure: ${request}. Inspect the candidate and explain the change. Do not run it yet.`, { expandPromptTemplates: true });
577
+ return;
578
+ }
579
+ else
580
+ raw = action;
581
+ }
582
+ const command = /^(help|status|stop|demo|triage|run|dry-run|inspect)(?:\s|$)/i.test(raw) ? raw.toLowerCase().replace(/\s+/g, ' ') : raw;
583
+ const words = command.split(/\s+/);
584
+ const commandLike = /^(help|status|stop|demo|run|dry-run)(?:-|$)/i.test(words[0])
585
+ && words.slice(1).every(word => word.startsWith('-') || ['live', 'empty'].includes(word));
358
586
  if (command === 'help' || command === '--help' || command === '-h') {
359
587
  show(s, help);
360
588
  }
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);
589
+ else if (!command || command === 'inspect') {
590
+ show(s, s.draft ? formatWorkflowView(view(ctx, s)) : help);
591
+ }
592
+ else if (/^save(?:\s|$)/.test(command)) {
593
+ if (s.busy)
594
+ throw new Error('A workflow is running. Save after it stops.');
595
+ if (!s.draft)
596
+ throw new Error('No workflow to save. Inspect a workflow first.');
597
+ const saved = await new WorkflowStore(ctx.cwd).save(command.slice(4).trim(), s.draft);
598
+ s.savedName = saved.name;
599
+ persist(s);
600
+ 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);
601
+ }
602
+ else if (/^load(?:\s|$)/.test(command)) {
603
+ const [, name, digest] = command.split(/\s+/);
604
+ const saved = await new WorkflowStore(ctx.cwd).load(name ?? '', digest);
605
+ inspect(ctx, s, saved.workflow);
606
+ s.savedName = saved.name;
607
+ s.input = {};
608
+ s.demo = undefined;
609
+ clearRun(s);
610
+ persist(s);
611
+ show(s, `Loaded procedure. Supply new input with /agentrun input. Loading never executes it.\n\n${formatWorkflowView(view(ctx, s))}`);
612
+ }
613
+ else if (command === 'list') {
614
+ const stored = await new WorkflowStore(ctx.cwd).list();
615
+ 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 });
616
+ }
617
+ else if (/^history(?:\s|$)/.test(command)) {
618
+ const history = workflowRunHistory(ctx.sessionManager.getBranch());
619
+ if (!history.length) {
620
+ show(s, 'No retained runs on this Pi session branch yet.');
621
+ return;
622
+ }
623
+ const labels = history.map((run, index) => `${index + 1}. ${inspectWorkflow(run.workflow).name} · ${run.report?.status ?? 'interrupted'} · ${run.createdAt}`);
624
+ let index = Number(command.slice(7).trim()) - 1;
625
+ if (command === 'history' && ctx.mode === 'tui') {
626
+ const selected = await ctx.ui.select('Retained runs on this session branch', labels);
627
+ if (!selected)
628
+ return;
629
+ index = labels.indexOf(selected);
630
+ }
631
+ if (!Number.isInteger(index) || index < 0 || index >= history.length) {
632
+ 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 })) });
633
+ return;
634
+ }
635
+ const run = history[index];
636
+ const receipt = workflowView(run.workflow, { input: run.input, observation: run.observation, report: run.report, mode: run.demo ? 'scripted' : 'live adapters', savedName: run.savedName });
637
+ // History is read-only. It never replaces the active draft or replays a run.
638
+ if (ctx.mode === 'tui')
639
+ await showWorkflowInspector(ctx, () => ({ ...receipt, readOnly: true }));
640
+ else
641
+ show(s, formatWorkflowView(receipt), { run });
642
+ }
643
+ else if (/^sop(?:\s|$)/.test(command)) {
644
+ const file = raw.trim().slice(3).trim();
645
+ if (!file) {
646
+ 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.');
647
+ return;
648
+ }
649
+ if (s.busy)
650
+ throw new Error('A workflow is running. Stop it before changing its SOP.');
651
+ if (configuration.sop)
652
+ throw new Error('SOP text comes from the host; /agentrun sop is not used.');
653
+ const read = await readSopFile(ctx.cwd, file);
654
+ const needed = s.draft ? inspectWorkflow(s.draft).requires.sopSections : [];
655
+ const missing = missingSopSections(read.text, needed);
656
+ if (missing.length)
657
+ throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}. Nothing was changed.`);
658
+ s.sop = { file: read.file, sha256: read.sha256 };
659
+ persist(s);
660
+ show(s, `SOP set: ${read.file} (${read.text.length} characters). ${needed.length ? `Covers ${needed.map(name => `"${name}"`).join(', ')}.` : 'The current workflow names no section.'}`);
661
+ }
662
+ else if (/^input(?:\s|$)/.test(command)) {
663
+ if (!s.draft)
664
+ throw new Error('No workflow yet. Load or describe a procedure first.');
665
+ if (s.busy)
666
+ throw new Error('A workflow is running. Stop it before changing its input.');
667
+ let supplied = raw.slice(5).trim();
668
+ if (!supplied && ctx.hasUI)
669
+ supplied = await ctx.ui.input('New workflow input', 'Describe the new case in plain language, or paste a JSON object.') ?? '';
670
+ if (!supplied) {
671
+ show(s, 'Use /agentrun input <describe the new case>, or ask Pi to use new input. Nothing was run.');
672
+ return;
673
+ }
674
+ if (supplied.startsWith('{')) {
675
+ let input;
676
+ try {
677
+ input = JSON.parse(supplied);
678
+ }
679
+ catch {
680
+ throw new Error('Workflow input must be valid JSON, or describe it in plain language.');
681
+ }
682
+ if (!input || typeof input !== 'object' || Array.isArray(input))
683
+ throw new Error('Workflow input must be an object.');
684
+ inspect(ctx, s, undefined, input);
685
+ show(s, `Input updated. Nothing was run.\n\n${formatWorkflowView(view(ctx, s))}`);
686
+ }
687
+ else {
688
+ const key = singleTextInput(s.draft);
689
+ if (key) {
690
+ inspect(ctx, s, undefined, { [key]: supplied });
691
+ show(s, `Input updated. Nothing was run.\n\n${formatWorkflowView(view(ctx, s))}`);
692
+ }
693
+ else {
694
+ 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 });
695
+ }
696
+ }
697
+ }
698
+ else if (/^triage(?:\s|$)/.test(command)) {
699
+ const parts = command.split(/\s+/).slice(1);
700
+ const live = parts[0] === 'live';
701
+ const name = parts[live ? 1 : 0] ?? 'billing';
702
+ if (!['billing', 'technical', 'ambiguous', 'failure'].includes(name) || parts.length > (live ? 2 : 1) || live && name === 'failure') {
703
+ show(s, 'Use /agentrun triage [billing|technical|ambiguous|failure], or /agentrun triage live [billing|technical|ambiguous].');
704
+ return;
705
+ }
706
+ const input = structuredClone(supportTriageInputs[name === 'failure' ? 'billing' : name]);
707
+ inspect(ctx, s, supportTriageWorkflow, input);
708
+ s.demo = live ? undefined : name === 'failure' ? 'triage-failure' : 'triage';
709
+ show(s, `Fictional support triage · ${live ? 'real model calls' : 'scripted, no model calls'}. No customer actions.\n${formatWorkflowView(view(ctx, s))}`);
710
+ const result = await execute(ctx, s, input);
711
+ show(s, result.content[0].text, result.details);
363
712
  }
364
713
  else if (command === 'status') {
365
714
  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);
715
+ 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
716
  }
368
717
  else if (command === 'stop') {
718
+ const dry = s.dryRun;
719
+ dry?.abort();
720
+ s.pause?.();
369
721
  s.controller?.abort();
370
722
  s.service.stop();
371
- show(s, s.busy ? 'Stop requested. Already-started tool effects may still finish.' : 'Nothing is running.');
723
+ show(s, s.busy ? 'Stop requested. Already-started tool effects may still finish.' : dry ? 'Dry run stopped.' : 'Nothing is running.');
372
724
  }
373
725
  else if (command === 'demo' || command === 'demo live' || command === 'demo empty') {
374
726
  inspect(ctx, s, demoWorkflow);
@@ -379,18 +731,28 @@ function registerExtension(pi, configuration) {
379
731
  else if (command === 'run' || command === 'run --trusted') {
380
732
  await launch(ctx, s, s.input, command === 'run --trusted');
381
733
  }
734
+ else if (command === 'dry-run' || command === 'dry-run --trusted') {
735
+ // As with /agentrun run: interactive Pi reads the next command only once this handler returns, so the dry run
736
+ // goes on in the background, where /agentrun stop reaches it. Headless callers await its answer.
737
+ stage(ctx, s, 'agent.run() · dry run · /agentrun stop ends it');
738
+ const pending = dryRun(ctx, s, undefined, command === 'dry-run --trusted')
739
+ .then(report => show(s, formatDryRun(report), { dryRun: report }), error => commandError(s, error))
740
+ .finally(() => stage(ctx, s));
741
+ if (!ctx.hasUI)
742
+ await pending;
743
+ }
382
744
  else if (commandLike || command.startsWith('-')) {
383
745
  show(s, `Unknown AgentRun command: ${raw}\n\n${help}`);
384
746
  }
385
747
  else {
386
748
  if (s.busy)
387
749
  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')) {
750
+ if (!pi.getCommands().some(item => item.name === `skill:${AUTHOR_SKILL_NAME}` && item.source === 'skill')) {
389
751
  throw new Error('AgentRun authoring skill is unavailable. Enable package skills, then /reload. Run /agentrun status to check discovery.');
390
752
  }
391
753
  if (!readiness(ctx, s).pi)
392
754
  throw new Error(PI_MODEL_SETUP_MESSAGE);
393
- pi.sendUserMessage(`/skill:agentrun-author ${raw}`, { expandPromptTemplates: true });
755
+ pi.sendUserMessage(`/skill:${AUTHOR_SKILL_NAME} ${raw}`, { expandPromptTemplates: true });
394
756
  }
395
757
  }
396
758
  catch (error) {
@@ -398,22 +760,43 @@ function registerExtension(pi, configuration) {
398
760
  }
399
761
  },
400
762
  });
401
- const closeSession = async () => {
763
+ const closeSession = async (retainFinal = false) => {
402
764
  if (!current)
403
765
  return;
404
- const old = current;
766
+ const old = current, wasRunning = old.busy;
405
767
  old.clearStatus();
406
- current = undefined;
407
768
  old.closed = true;
769
+ old.dryRun?.abort();
770
+ old.pause?.();
408
771
  old.controller?.abort();
409
772
  await old.service.dispose();
410
773
  await old.inFlight;
774
+ // Only before-switch/shutdown hooks still own the originating branch. A
775
+ // session_tree event is already on another branch and must never receive this receipt.
776
+ if (retainFinal && wasRunning && old.report)
777
+ persist(old, false, true);
778
+ if (current === old)
779
+ current = undefined;
411
780
  };
412
- pi.on('session_shutdown', closeSession);
413
- pi.on('session_before_switch', closeSession);
414
- pi.on('session_before_fork', closeSession);
781
+ pi.on('session_shutdown', () => closeSession(true));
782
+ pi.on('session_before_switch', () => closeSession(true));
783
+ pi.on('session_before_fork', () => closeSession(true));
415
784
  pi.on('session_start', async (_event, ctx) => {
416
785
  if (current && current.key !== `${ctx.sessionManager.getSessionId()}:${ctx.cwd}`)
417
786
  await closeSession();
787
+ await state(ctx);
788
+ });
789
+ pi.on('session_tree', async (_event, ctx) => { await closeSession(); await state(ctx); });
790
+ pi.registerEntryRenderer('agentrun:snapshot', (entry, options) => {
791
+ if (entry.data?.running)
792
+ return undefined;
793
+ const snapshot = restoreWorkflowSession([entry]);
794
+ if (!snapshot)
795
+ return undefined;
796
+ if (!snapshot.report && !snapshot.running)
797
+ return undefined;
798
+ return new Text(safe(options.expanded ? formatWorkflowView(workflowView(snapshot.workflow, {
799
+ input: snapshot.input, report: snapshot.report, observation: snapshot.observation, mode: snapshot.demo ? 'scripted' : 'live adapters',
800
+ })) : `AgentRun receipt · ${snapshot.report?.status ?? 'interrupted'} · ${inspectWorkflow(snapshot.workflow).name}`), 0, 0);
418
801
  });
419
802
  }