pi-workflow-engine 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +236 -94
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
  4. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  5. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
  7. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  8. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  9. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  10. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  11. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  12. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  13. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
  14. package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
  15. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  16. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  17. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  18. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  19. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  20. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  21. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  22. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  23. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  24. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  25. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
  26. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  27. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
  28. package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
  29. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  32. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  33. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  34. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  35. package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  47. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  48. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  49. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  50. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  51. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  52. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  53. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  54. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  55. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  56. package/README.md +12 -4
  57. package/USAGE.md +167 -23
  58. package/package.json +13 -4
  59. package/skills/workflow-authoring/SKILL.md +160 -0
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { isMissingPathError } from "./filesystem-error.ts";
6
+ import { unknownErrorMessage } from "./unknown-error.ts";
7
+
8
+ /** A JSON config that lives once per user and once per project, the project file overriding the user one. */
9
+ export interface ConfigFilePaths {
10
+ readonly user: string;
11
+ readonly project: string;
12
+ }
13
+
14
+ export function configFilePaths(fileName: string, cwd: string, agentDir: string = getAgentDir()): ConfigFilePaths {
15
+ return {
16
+ user: join(agentDir, fileName),
17
+ project: join(cwd, ".pi", fileName),
18
+ };
19
+ }
20
+
21
+ /** The parsed JSON at `configPath`, or undefined when there is no file. `label` names the config in errors. */
22
+ export function readJsonConfigFile(configPath: string, label: string): unknown {
23
+ let contents: string;
24
+ try {
25
+ contents = readFileSync(configPath, "utf8");
26
+ } catch (error) {
27
+ if (isMissingPathError(error)) return undefined;
28
+ throw new Error(`Could not read ${label} at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`);
29
+ }
30
+ try {
31
+ return JSON.parse(contents);
32
+ } catch (error) {
33
+ throw new Error(`Invalid JSON in ${label} at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
34
+ }
35
+ }
36
+
37
+ /** Replace `configPath` atomically, so a concurrent reader sees the old file or the new one. */
38
+ export function writeJsonConfigFile(configPath: string, value: unknown, label: string): void {
39
+ const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
40
+ try {
41
+ mkdirSync(dirname(configPath), { recursive: true });
42
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
43
+ renameSync(temporaryPath, configPath);
44
+ } catch (error) {
45
+ try {
46
+ unlinkSync(temporaryPath);
47
+ } catch {
48
+ // Best effort: preserve the original write error.
49
+ }
50
+ throw new Error(`Could not write ${label} at ${configPath}: ${unknownErrorMessage(error)}.`);
51
+ }
52
+ }
@@ -4,18 +4,75 @@ import { pathToFileURL } from "node:url";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
+ import { compileInlineWorkflow, inlineScriptFingerprint } from "./inline-workflow.ts";
7
8
  import { loadWorkflow, parseWorkflowModule } from "./workflow-module.ts";
8
9
  import { BUILTIN_SOURCE_ROOT, BUILTIN_WORKFLOW_DEFINITIONS, BUILTIN_WORKFLOW_FILES } from "./workflows.ts";
9
10
  import { captureSourceTreeFingerprint, type FingerprintCapture } from "./tree-fingerprint.ts";
10
11
  import { unknownErrorMessage } from "./unknown-error.ts";
12
+ import { readBoundedScriptFile } from "./workflow-script-file.ts";
11
13
 
12
14
  export interface DiscoverWorkflowsOptions {
13
15
  readonly refresh?: boolean;
14
16
  readonly perf?: PerfSink;
15
17
  readonly userWorkflowDir?: string;
18
+ /**
19
+ * Project root whose `.pi/workflows/*.js` saved scripts load right after the built-ins. Callers omit
20
+ * it for an untrusted project, which then contributes no workflows.
21
+ */
22
+ readonly projectDir?: string;
23
+ }
24
+
25
+ /**
26
+ * Built-ins and `.ts` drop-ins hash source trees and import modules, so they load once per directory pair
27
+ * until a refresh. Saved scripts are not cached: see `discoverWorkflows`.
28
+ */
29
+ interface CachedModules {
30
+ readonly builtins: readonly LoadedWorkflow[];
31
+ readonly repoDynamic: readonly LoadedWorkflow[];
32
+ readonly userDynamic: readonly LoadedWorkflow[];
33
+ }
34
+
35
+ const moduleCache = new Map<string, CachedModules>();
36
+
37
+ /** Saved scripts are re-read on every discovery, so each distinct skip is reported once, not on every lookup. */
38
+ const reportedScriptSkips = new Set<string>();
39
+
40
+ function isWorkflowFileCandidate(name: string, extension: string): boolean {
41
+ return name.endsWith(extension) && !name.startsWith("_") && !name.startsWith(".");
42
+ }
43
+
44
+ /**
45
+ * Load every `*.js` saved workflow in the inline script format from a directory. Unlike `.ts` drop-ins
46
+ * they are compiled with the injected `Type`, so they share pi's typebox, and a content fingerprint is
47
+ * their whole identity, so they replay on resume.
48
+ */
49
+ async function loadScriptDir(dir: string): Promise<LoadedWorkflow[]> {
50
+ let entries: string[];
51
+ try {
52
+ entries = await readdir(dir);
53
+ } catch {
54
+ return [];
55
+ }
56
+ const workflows: LoadedWorkflow[] = [];
57
+ for (const name of entries.filter((entry) => isWorkflowFileCandidate(entry, ".js")).sort()) {
58
+ const path = join(dir, name);
59
+ try {
60
+ // Discovery runs on every lookup, so a linked FIFO or huge file must not stall it.
61
+ const source = await readBoundedScriptFile(path, path);
62
+ const compiled = compileInlineWorkflow(source);
63
+ workflows.push(loadWorkflow(compiled, { kind: "file", path, root: dir, fingerprint: inlineScriptFingerprint(source), selfContained: true }));
64
+ } catch (error) {
65
+ const note = `[workflow-engine] skipped ${name}: ${unknownErrorMessage(error)}`;
66
+ const key = `${path}\0${note}`;
67
+ if (!reportedScriptSkips.has(key)) {
68
+ reportedScriptSkips.add(key);
69
+ console.error(note);
70
+ }
71
+ }
72
+ }
73
+ return workflows;
16
74
  }
17
75
 
18
- const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
19
76
  /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
20
77
  async function loadDir(
21
78
  dir: string,
@@ -29,9 +86,7 @@ async function loadDir(
29
86
  } catch {
30
87
  return [];
31
88
  }
32
- const candidates = entries.filter(
33
- (name) => !excludeFiles.has(name) && name.endsWith(".ts") && !name.startsWith("_") && !name.startsWith("."),
34
- );
89
+ const candidates = entries.filter((name) => !excludeFiles.has(name) && isWorkflowFileCandidate(name, ".ts"));
35
90
  // The shipped workflows/ dir holds only excluded built-ins; skip the tree hash when nothing will be imported.
36
91
  if (candidates.length === 0) return [];
37
92
 
@@ -56,72 +111,86 @@ async function loadDir(
56
111
  }
57
112
 
58
113
  /**
59
- * All available workflows by name. Static registry wins on name collisions, so the
60
- * bundled example is always the verified one even if a same-named file is dropped in.
114
+ * All available workflows by name. The first source to define a name wins: the static registry, so
115
+ * the bundled example is always the verified one even if a same-named file is dropped in, then the
116
+ * project's saved scripts, the extension's `.ts` drop-ins, the user's saved scripts, and the user's
117
+ * `.ts` drop-ins.
118
+ *
119
+ * Saved scripts are read and compiled on every call (small files, no import), so one written or edited
120
+ * mid-session resolves by name with its current content; built-ins and `.ts` drop-ins come from the cache
121
+ * unless `refresh` is set.
61
122
  */
62
123
  export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, LoadedWorkflow>> {
63
124
  const userWorkflowDir = options.userWorkflowDir ?? join(getAgentDir(), "workflows");
125
+ const projectWorkflowDir = options.projectDir === undefined ? undefined : join(options.projectDir, ".pi", "workflows");
126
+ return await timed(options.perf, "discovery.total_ms", async () => {
127
+ const [modules, projectScripts, userScripts] = await Promise.all([
128
+ cachedModules(repoDir, userWorkflowDir, options),
129
+ projectWorkflowDir === undefined
130
+ ? []
131
+ : timed(options.perf, "discovery.project_scripts_ms", () => loadScriptDir(projectWorkflowDir)),
132
+ timed(options.perf, "discovery.user_scripts_ms", () => loadScriptDir(userWorkflowDir)),
133
+ ]);
134
+ const byName = new Map<string, LoadedWorkflow>();
135
+ for (const mod of [...modules.builtins, ...projectScripts, ...modules.repoDynamic, ...userScripts, ...modules.userDynamic]) {
136
+ if (!byName.has(mod.meta.name)) byName.set(mod.meta.name, mod);
137
+ }
138
+ return byName;
139
+ });
140
+ }
141
+
142
+ async function cachedModules(repoDir: string, userWorkflowDir: string, options: DiscoverWorkflowsOptions): Promise<CachedModules> {
64
143
  const cacheKey = `${repoDir}\0${userWorkflowDir}`;
65
- const cached = discoveryCache.get(cacheKey);
144
+ const cached = moduleCache.get(cacheKey);
66
145
  if (cached && !options.refresh) {
67
146
  options.perf?.counter("discovery.cache_hit");
68
- return new Map(cached);
147
+ return cached;
69
148
  }
70
149
 
71
- const byName = await timed(options.perf, "discovery.total_ms", async () => {
72
- const next = new Map<string, LoadedWorkflow>();
73
- const builtinSource = await captureSourceTreeFingerprint(BUILTIN_SOURCE_ROOT);
74
- for (const definition of BUILTIN_WORKFLOW_DEFINITIONS) {
75
- const source: WorkflowSourceIdentity =
76
- builtinSource.kind === "verified"
77
- ? {
78
- kind: "file",
79
- path: definition.path,
80
- root: BUILTIN_SOURCE_ROOT,
81
- fingerprint: builtinSource.fingerprint,
82
- }
83
- : {
84
- kind: "unverifiable",
85
- reason: `built-in workflow source capture failed: ${builtinSource.reason}`,
86
- };
87
- next.set(definition.module.meta.name, loadWorkflow(definition.module, source));
88
- }
89
-
90
- const repoWorkflowDir = join(repoDir, "workflows");
91
- // The shipped extension dir is both the built-in root and the drop-in root; hash that tree once.
92
- const repoProvenance =
93
- resolve(repoDir) === resolve(BUILTIN_SOURCE_ROOT) ? async () => builtinSource : () => captureSourceTreeFingerprint(repoDir);
94
- const [repoDynamic, userDynamic] = await Promise.all([
95
- timed(options.perf, "discovery.repo_dir_ms", () =>
96
- loadDir(
97
- repoWorkflowDir,
98
- () => ({
150
+ const builtinSource = await captureSourceTreeFingerprint(BUILTIN_SOURCE_ROOT);
151
+ const builtins = BUILTIN_WORKFLOW_DEFINITIONS.map((definition) => {
152
+ const source: WorkflowSourceIdentity =
153
+ builtinSource.kind === "verified"
154
+ ? {
155
+ kind: "file",
156
+ path: definition.path,
157
+ root: BUILTIN_SOURCE_ROOT,
158
+ fingerprint: builtinSource.fingerprint,
159
+ }
160
+ : {
99
161
  kind: "unverifiable",
100
- reason: "dynamic workflow module graphs are not loaded from an immutable source snapshot",
101
- }),
102
- BUILTIN_WORKFLOW_FILES,
103
- repoProvenance,
104
- ),
105
- ),
106
- timed(options.perf, "discovery.user_dir_ms", () =>
107
- loadDir(userWorkflowDir, () => ({
162
+ reason: `built-in workflow source capture failed: ${builtinSource.reason}`,
163
+ };
164
+ return loadWorkflow(definition.module, source);
165
+ });
166
+
167
+ const repoWorkflowDir = join(repoDir, "workflows");
168
+ // The shipped extension dir is both the built-in root and the drop-in root; hash that tree once.
169
+ const repoProvenance =
170
+ resolve(repoDir) === resolve(BUILTIN_SOURCE_ROOT) ? async () => builtinSource : () => captureSourceTreeFingerprint(repoDir);
171
+ const [repoDynamic, userDynamic] = await Promise.all([
172
+ timed(options.perf, "discovery.repo_dir_ms", () =>
173
+ loadDir(
174
+ repoWorkflowDir,
175
+ () => ({
108
176
  kind: "unverifiable",
109
- reason: "dynamic user workflow dependencies do not have a declared trusted source root",
110
- })),
177
+ reason: "dynamic workflow module graphs are not loaded from an immutable source snapshot",
178
+ }),
179
+ BUILTIN_WORKFLOW_FILES,
180
+ repoProvenance,
111
181
  ),
112
- ]);
113
-
114
- for (const mod of repoDynamic) {
115
- if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
116
- }
117
- for (const mod of userDynamic) {
118
- if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
119
- }
120
- return next;
121
- });
182
+ ),
183
+ timed(options.perf, "discovery.user_dir_ms", () =>
184
+ loadDir(userWorkflowDir, () => ({
185
+ kind: "unverifiable",
186
+ reason: "dynamic user workflow dependencies do not have a declared trusted source root",
187
+ })),
188
+ ),
189
+ ]);
122
190
 
123
- discoveryCache.set(cacheKey, byName);
124
- return new Map(byName);
191
+ const modules: CachedModules = { builtins, repoDynamic, userDynamic };
192
+ moduleCache.set(cacheKey, modules);
193
+ return modules;
125
194
  }
126
195
 
127
196
  async function timed<T>(perf: PerfSink | undefined, name: string, fn: () => Promise<T>): Promise<T> {
@@ -1,8 +1,10 @@
1
- import { CustomEditor, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import { CustomEditor, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { EditorComponent } from "@earendil-works/pi-tui";
3
3
  import { completeCurrentArgument } from "./command-completions.ts";
4
4
  import type { DynamaxShortcuts } from "./dynamax-shortcuts.ts";
5
+ import { appendSectionToForcedPrompt } from "./prompt-sections.ts";
5
6
  import { sessionKey } from "./session-identity.ts";
7
+ import { WORKFLOW_TOOL_NAME } from "./types.ts";
6
8
  import { unknownErrorMessage } from "./unknown-error.ts";
7
9
  import { dot, GLYPH } from "./ui/kit.ts";
8
10
  import { setWorkflowInspectorShortcut } from "./ui/workflow-widget.ts";
@@ -20,8 +22,19 @@ export interface DynamaxState {
20
22
  turnActive: boolean;
21
23
  }
22
24
 
25
+ /** The output-token pool a `+500k`-style directive in the user's prompt sets for that turn's workflow runs. */
26
+ export interface DynamaxTurnBudget {
27
+ readonly total: number;
28
+ /** Tokens not yet spent by this turn's synchronous runs; never negative. */
29
+ remaining(): number;
30
+ /** Count a finished synchronous run's output tokens against the pool. */
31
+ spend(outputTokens: number): void;
32
+ }
33
+
23
34
  export interface DynamaxRuntime {
24
35
  state: DynamaxState;
36
+ /** Set by a budget directive in a user prompt, cleared when that run settles. */
37
+ turnBudget?: DynamaxTurnBudget;
25
38
  }
26
39
 
27
40
  export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
@@ -35,9 +48,18 @@ export interface DynamaxRegistrationOptions {
35
48
  export interface DynamaxHandle {
36
49
  /** Opt the next agent run in. The `input` hook ignores prompts this extension sends (pi tags them `source: "extension"`). */
37
50
  markOneShot(ctx: ExtensionContext): void;
51
+ /** The current turn's output-token pool, when the user's prompt set one. */
52
+ turnBudget(ctx: ExtensionContext): DynamaxTurnBudget | undefined;
53
+ /** Whether Dynamax covers this session now: sticky, a pending one-shot, or a one-shot turn in progress. */
54
+ isActive(ctx: ExtensionContext): boolean;
38
55
  }
39
56
 
40
57
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
58
+ /** `+500k`, `+1.5m`, `+2M`, `+250000`: after the start or whitespace, before the end, whitespace, or punctuation. */
59
+ export const BUDGET_DIRECTIVE_PATTERN = /(?:^|\s)\+(\d+(?:\.\d+)?)([km]?)(?=$|\s|[.,;:!?)\]}"'])/i;
60
+ /** Smaller values are ignored, so a `+1` or `+2` in ordinary prose never caps a run at a few tokens. */
61
+ export const BUDGET_DIRECTIVE_MIN_TOKENS = 1_000;
62
+ const BUDGET_DIRECTIVE_MULTIPLIERS: Readonly<Record<string, number>> = { "": 1, k: 1_000, m: 1_000_000 };
41
63
  export const DYNAMAX_STATUS_KEY = "dynamax";
42
64
 
43
65
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
@@ -53,35 +75,30 @@ function disposeDynamaxDecorations(decorations: Set<DynamaxEditorDecoration>): v
53
75
  decorations.clear();
54
76
  }
55
77
 
78
+ /** With no argument the command toggles sticky mode, so these name only the explicit forms. */
56
79
  const DYNAMAX_ACTION_COMPLETIONS = [
57
- { value: "on", description: "Keep Dynamax enabled for this session" },
80
+ { value: "on", description: "Make workflow orchestration the default for this session" },
58
81
  { value: "off", description: "Disable sticky and pending Dynamax modes" },
59
82
  { value: "status", description: "Show the current Dynamax state" },
60
83
  ] as const;
61
84
 
62
- export const ADAPTIVE_WORKFLOW_GUIDANCE = `
63
- Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out when it is sufficient. When the first pass may expose gaps, conflicts, weak claims, or missing evidence:
64
- - run the bounded first-pass agents;
65
- - give their surviving results to a structured gap-analysis agent so the LLM decides what needs follow-up;
66
- - put a hard maxItems bound on LLM-authored task arrays and defensively slice them before fan-out;
67
- - use ordinary TypeScript conditionals or bounded loops to launch follow-up agents only when gaps exist;
68
- - synthesize the first-pass and follow-up results together.
69
- Do not generate a second pass when the first pass is sufficient, and do not invent iteration, quorum, graph, reduction, or retry primitives for this pattern.`;
70
-
71
- export const DYNAMAX_REMINDER = `
72
- ## dynamax workflow opt-in
85
+ /** System prompt section pi wraps as `<dynamax>`; adding or dropping it appends a section patch instead of rewriting the prompt. */
86
+ export const DYNAMAX_SECTION = "dynamax";
73
87
 
74
- The user has opted into dynamax multi-agent orchestration. The workflow tool is permitted for this task. You may either run an existing named workflow or author a new inline workflow script when that best serves the user's request.
75
-
76
- Inline workflow rules:
77
- - Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
78
- - Do not use import statements or dynamic import() in inline workflow scripts.
79
- - Set profile to "small", "medium", or "big" on each agent() call so routing remains explicit; use model/thinkingLevel only for an intentional override.
80
- - Subagents receive no skills by default. Add \`skills: ["skill-name"]\` per agent only when that stage should load that skill; grant the smallest useful set.
81
- - Provide exactly one of workflow.name or workflow.script.
88
+ /**
89
+ * Only the opt-in itself: the workflow tool's guidelines send the model to the workflow-authoring
90
+ * skill, which carries the authoring rules and patterns.
91
+ */
92
+ export const DYNAMAX_REMINDER =
93
+ "The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " +
94
+ "Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines.";
82
95
 
83
- ${ADAPTIVE_WORKFLOW_GUIDANCE}
84
- `;
96
+ /** Sticky mode is a standing default for the session, not a one-request permission. */
97
+ export const DYNAMAX_STICKY_REMINDER =
98
+ "Dynamax is on for this session. Orchestrate every substantive task with the workflow tool by default: " +
99
+ "scout inline first to find the work list, then run one single-phase workflow per phase (understand, design, implement, review) " +
100
+ "and read each result before choosing the next. Lean toward adversarial verification of findings. " +
101
+ "Work without a workflow only for conversational turns and trivial or already-verified edits.";
85
102
 
86
103
  export function createDynamaxState(): DynamaxState {
87
104
  return { sticky: false, oneShotPending: false, turnActive: false };
@@ -104,6 +121,48 @@ export function hasDynamaxToken(text: string): boolean {
104
121
  return DYNAMAX_TOKEN_PATTERN.test(text);
105
122
  }
106
123
 
124
+ /** The output tokens the first budget directive in `text` asks for, if any. */
125
+ export function parseBudgetDirective(text: string): number | undefined {
126
+ const match = BUDGET_DIRECTIVE_PATTERN.exec(text);
127
+ if (!match) return undefined;
128
+ const multiplier = BUDGET_DIRECTIVE_MULTIPLIERS[(match[2] ?? "").toLowerCase()] ?? 1;
129
+ // Round, not floor: 2.3 * 1000 is 2299.999... in floating point.
130
+ const tokens = Math.round(Number(match[1]) * multiplier);
131
+ return Number.isSafeInteger(tokens) && tokens >= BUDGET_DIRECTIVE_MIN_TOKENS ? tokens : undefined;
132
+ }
133
+
134
+ export function createDynamaxTurnBudget(total: number): DynamaxTurnBudget {
135
+ let spent = 0;
136
+ return {
137
+ total,
138
+ remaining: () => Math.max(0, total - spent),
139
+ spend(outputTokens) {
140
+ if (Number.isFinite(outputTokens) && outputTokens > 0) spent += outputTokens;
141
+ },
142
+ };
143
+ }
144
+
145
+ export type DynamaxTurnBudgetResolution =
146
+ | { readonly kind: "budget"; readonly budget: number | undefined }
147
+ | { readonly kind: "spent"; readonly total: number };
148
+
149
+ /**
150
+ * A run's budget. Without a turn pool it is the explicit one, if any. The user's pool caps every run in
151
+ * its turn: the remainder, clamped to the run budget range, is the budget, and an explicit one can only
152
+ * lower it. A pool with less than `min` left is spent.
153
+ */
154
+ export function resolveTurnBudget(
155
+ explicit: number | undefined,
156
+ pool: DynamaxTurnBudget | undefined,
157
+ range: { readonly min: number; readonly max: number },
158
+ ): DynamaxTurnBudgetResolution {
159
+ if (pool === undefined) return { kind: "budget", budget: explicit };
160
+ const remaining = pool.remaining();
161
+ if (remaining < range.min) return { kind: "spent", total: pool.total };
162
+ const cap = Math.min(range.max, Math.floor(remaining));
163
+ return { kind: "budget", budget: explicit === undefined ? cap : Math.min(explicit, cap) };
164
+ }
165
+
107
166
  export function markDynamaxOneShot(state: DynamaxState): void {
108
167
  state.oneShotPending = true;
109
168
  }
@@ -140,11 +199,25 @@ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
140
199
  return mode ? `${label}${dot(theme)}${theme.fg("muted", mode)}` : label;
141
200
  }
142
201
 
143
- export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
144
- if (!state.sticky && !state.oneShotPending) return systemPrompt;
145
- state.oneShotPending = false;
202
+ /**
203
+ * Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every
204
+ * run, so an inactive run simply leaves it out and pi records the removal as a patch.
205
+ *
206
+ * Sticky mode covers every run, including one a custom message started (`sendMessage` with
207
+ * `triggerTurn`, such as a background completion). Only a user prompt consumes the one-shot: the
208
+ * opt-in belongs to the request the user typed it in.
209
+ */
210
+ export function applyDynamaxPromptSection(
211
+ sections: Record<string, string>,
212
+ state: DynamaxState,
213
+ messageType: BeforeAgentStartEvent["messageType"] = "user",
214
+ ): boolean {
215
+ const oneShot = messageType === "user" && state.oneShotPending;
216
+ if (!state.sticky && !oneShot) return false;
217
+ if (oneShot) state.oneShotPending = false;
146
218
  state.turnActive = true;
147
- return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
219
+ sections[DYNAMAX_SECTION] = state.sticky ? DYNAMAX_STICKY_REMINDER : DYNAMAX_REMINDER;
220
+ return true;
148
221
  }
149
222
 
150
223
  export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, options: DynamaxRegistrationOptions): DynamaxHandle {
@@ -232,21 +305,30 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
232
305
  });
233
306
 
234
307
  pi.on("input", (event, ctx) => {
235
- if (event.source !== "extension" && hasDynamaxToken(event.text)) markOneShot(ctx);
308
+ if (event.source === "extension") return { action: "continue" };
309
+ if (hasDynamaxToken(event.text)) markOneShot(ctx);
310
+ const budget = parseBudgetDirective(event.text);
311
+ if (budget !== undefined) getDynamaxRuntime(runtimes, ctx).turnBudget = createDynamaxTurnBudget(budget);
236
312
  return { action: "continue" };
237
313
  });
238
314
 
239
315
  pi.on("before_agent_start", (event, ctx) => {
316
+ const options = event.systemPromptOptions;
317
+ // Subagent sessions load this extension but withhold the workflow tool, so a `dynamax` in a
318
+ // subagent prompt must neither permit that tool nor consume the one-shot.
319
+ if (!options.selectedTools.includes(WORKFLOW_TOOL_NAME)) return undefined;
240
320
  const runtime = getDynamaxRuntime(runtimes, ctx);
241
- const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
242
- if (systemPrompt === event.systemPrompt) return undefined;
321
+ if (!applyDynamaxPromptSection(options.sections, runtime.state, event.messageType)) return undefined;
243
322
  updateDynamaxSurfaces(ctx, runtime);
244
- return { systemPrompt };
323
+ return appendSectionToForcedPrompt(options, DYNAMAX_SECTION);
245
324
  });
246
325
 
247
- pi.on("agent_end", (_event, ctx) => {
326
+ // Not agent_end: pi emits that per agent loop, and one run continues in fresh loops after a
327
+ // provider retry, overflow compaction, or queued follow-up. agent_settled fires once per run.
328
+ pi.on("agent_settled", (_event, ctx) => {
248
329
  const runtime = getDynamaxRuntime(runtimes, ctx);
249
330
  if (!runtime.state.sticky) runtime.state.turnActive = false;
331
+ runtime.turnBudget = undefined;
250
332
  updateDynamaxSurfaces(ctx, runtime);
251
333
  });
252
334
 
@@ -260,15 +342,12 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
260
342
  }
261
343
 
262
344
  pi.registerCommand("workflow:dynamax", {
263
- description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
345
+ description: "Toggle sticky Dynamax orchestration for this session: /workflow:dynamax [on|off|status]",
264
346
  getArgumentCompletions: (argumentPrefix) => completeCurrentArgument(argumentPrefix, DYNAMAX_ACTION_COMPLETIONS),
265
347
  handler: async (args, ctx) => {
266
348
  const runtime = getDynamaxRuntime(runtimes, ctx);
267
- const action = args.trim().toLowerCase();
268
- if (action === "") {
269
- ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
270
- return;
271
- }
349
+ const typed = args.trim().toLowerCase();
350
+ const action = typed === "" ? (runtime.state.sticky ? "off" : "on") : typed;
272
351
  if (action === "on") {
273
352
  enableDynamaxSticky(runtime.state);
274
353
  updateDynamaxSurfaces(ctx, runtime);
@@ -285,11 +364,15 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
285
364
  ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
286
365
  return;
287
366
  }
288
- ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
367
+ ctx.ui.notify("Usage: /workflow:dynamax [on|off|status] (no argument toggles sticky mode)", "warning");
289
368
  },
290
369
  });
291
370
 
292
- return { markOneShot };
371
+ return {
372
+ markOneShot,
373
+ turnBudget: (ctx) => getDynamaxRuntime(runtimes, ctx).turnBudget,
374
+ isActive: (ctx) => isDynamaxActive(getDynamaxRuntime(runtimes, ctx).state),
375
+ };
293
376
  }
294
377
 
295
378
  export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {