pi-workflow-engine 1.1.0 → 1.3.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 (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -85
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +5 -2
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +8 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +4 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
  8. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
  9. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  10. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  11. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  12. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  13. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  14. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +112 -31
  15. package/.pi/extensions/pi-workflow-engine/src/engine.ts +75 -18
  16. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -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/progress-types.ts +1 -1
  21. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  22. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  23. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
  24. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  25. package/.pi/extensions/pi-workflow-engine/src/types.ts +54 -9
  26. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  27. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  28. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  29. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  32. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +82 -23
  33. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  34. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  35. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  47. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +59 -22
  48. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +7 -4
  49. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +7 -4
  50. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +7 -4
  51. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  52. package/README.md +11 -3
  53. package/USAGE.md +124 -23
  54. package/package.json +13 -4
  55. package/skills/workflow-authoring/SKILL.md +162 -0
@@ -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,29 +75,31 @@ 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
85
  /** System prompt section pi wraps as `<dynamax>`; adding or dropping it appends a section patch instead of rewriting the prompt. */
72
86
  export const DYNAMAX_SECTION = "dynamax";
73
87
 
74
- /** Only the opt-in itself: the authoring rules already reach the model as the workflow tool's guidelines. */
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
+ */
75
92
  export const DYNAMAX_REMINDER =
76
93
  "The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " +
77
94
  "Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines.";
78
95
 
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.";
102
+
79
103
  export function createDynamaxState(): DynamaxState {
80
104
  return { sticky: false, oneShotPending: false, turnActive: false };
81
105
  }
@@ -97,6 +121,46 @@ export function hasDynamaxToken(text: string): boolean {
97
121
  return DYNAMAX_TOKEN_PATTERN.test(text);
98
122
  }
99
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 tool run's budget. Only the user sets one: without a turn pool this adds none, and the pool caps
151
+ * every run in its turn at the remainder, clamped to the run budget range. A pool with less than `min`
152
+ * left is spent.
153
+ */
154
+ export function resolveTurnBudget(
155
+ pool: DynamaxTurnBudget | undefined,
156
+ range: { readonly min: number; readonly max: number },
157
+ ): DynamaxTurnBudgetResolution {
158
+ if (pool === undefined) return { kind: "budget", budget: undefined };
159
+ const remaining = pool.remaining();
160
+ if (remaining < range.min) return { kind: "spent", total: pool.total };
161
+ return { kind: "budget", budget: Math.min(range.max, Math.floor(remaining)) };
162
+ }
163
+
100
164
  export function markDynamaxOneShot(state: DynamaxState): void {
101
165
  state.oneShotPending = true;
102
166
  }
@@ -136,12 +200,21 @@ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
136
200
  /**
137
201
  * Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every
138
202
  * run, so an inactive run simply leaves it out and pi records the removal as a patch.
203
+ *
204
+ * Sticky mode covers every run, including one a custom message started (`sendMessage` with
205
+ * `triggerTurn`, such as a background completion). Only a user prompt consumes the one-shot: the
206
+ * opt-in belongs to the request the user typed it in.
139
207
  */
140
- export function applyDynamaxPromptSection(sections: Record<string, string>, state: DynamaxState): boolean {
141
- if (!state.sticky && !state.oneShotPending) return false;
142
- state.oneShotPending = false;
208
+ export function applyDynamaxPromptSection(
209
+ sections: Record<string, string>,
210
+ state: DynamaxState,
211
+ messageType: BeforeAgentStartEvent["messageType"] = "user",
212
+ ): boolean {
213
+ const oneShot = messageType === "user" && state.oneShotPending;
214
+ if (!state.sticky && !oneShot) return false;
215
+ if (oneShot) state.oneShotPending = false;
143
216
  state.turnActive = true;
144
- sections[DYNAMAX_SECTION] = DYNAMAX_REMINDER;
217
+ sections[DYNAMAX_SECTION] = state.sticky ? DYNAMAX_STICKY_REMINDER : DYNAMAX_REMINDER;
145
218
  return true;
146
219
  }
147
220
 
@@ -230,23 +303,30 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
230
303
  });
231
304
 
232
305
  pi.on("input", (event, ctx) => {
233
- if (event.source !== "extension" && hasDynamaxToken(event.text)) markOneShot(ctx);
306
+ if (event.source === "extension") return { action: "continue" };
307
+ if (hasDynamaxToken(event.text)) markOneShot(ctx);
308
+ const budget = parseBudgetDirective(event.text);
309
+ if (budget !== undefined) getDynamaxRuntime(runtimes, ctx).turnBudget = createDynamaxTurnBudget(budget);
234
310
  return { action: "continue" };
235
311
  });
236
312
 
237
313
  pi.on("before_agent_start", (event, ctx) => {
238
- const runtime = getDynamaxRuntime(runtimes, ctx);
239
314
  const options = event.systemPromptOptions;
240
- if (!applyDynamaxPromptSection(options.sections, runtime.state)) return undefined;
315
+ // Subagent sessions load this extension but withhold the workflow tool, so a `dynamax` in a
316
+ // subagent prompt must neither permit that tool nor consume the one-shot.
317
+ if (!options.selectedTools.includes(WORKFLOW_TOOL_NAME)) return undefined;
318
+ const runtime = getDynamaxRuntime(runtimes, ctx);
319
+ if (!applyDynamaxPromptSection(options.sections, runtime.state, event.messageType)) return undefined;
241
320
  updateDynamaxSurfaces(ctx, runtime);
242
- // An earlier extension forced the whole prompt, and pi renders no sections then; append to its text instead.
243
- if (options.forceSystemPrompt === undefined) return undefined;
244
- return { systemPrompt: `${options.forceSystemPrompt}\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n</${DYNAMAX_SECTION}>` };
321
+ return appendSectionToForcedPrompt(options, DYNAMAX_SECTION);
245
322
  });
246
323
 
247
- pi.on("agent_end", (_event, ctx) => {
324
+ // Not agent_end: pi emits that per agent loop, and one run continues in fresh loops after a
325
+ // provider retry, overflow compaction, or queued follow-up. agent_settled fires once per run.
326
+ pi.on("agent_settled", (_event, ctx) => {
248
327
  const runtime = getDynamaxRuntime(runtimes, ctx);
249
328
  if (!runtime.state.sticky) runtime.state.turnActive = false;
329
+ runtime.turnBudget = undefined;
250
330
  updateDynamaxSurfaces(ctx, runtime);
251
331
  });
252
332
 
@@ -260,15 +340,12 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
260
340
  }
261
341
 
262
342
  pi.registerCommand("workflow:dynamax", {
263
- description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
343
+ description: "Toggle sticky Dynamax orchestration for this session: /workflow:dynamax [on|off|status]",
264
344
  getArgumentCompletions: (argumentPrefix) => completeCurrentArgument(argumentPrefix, DYNAMAX_ACTION_COMPLETIONS),
265
345
  handler: async (args, ctx) => {
266
346
  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
- }
347
+ const typed = args.trim().toLowerCase();
348
+ const action = typed === "" ? (runtime.state.sticky ? "off" : "on") : typed;
272
349
  if (action === "on") {
273
350
  enableDynamaxSticky(runtime.state);
274
351
  updateDynamaxSurfaces(ctx, runtime);
@@ -285,11 +362,15 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
285
362
  ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
286
363
  return;
287
364
  }
288
- ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
365
+ ctx.ui.notify("Usage: /workflow:dynamax [on|off|status] (no argument toggles sticky mode)", "warning");
289
366
  },
290
367
  });
291
368
 
292
- return { markOneShot };
369
+ return {
370
+ markOneShot,
371
+ turnBudget: (ctx) => getDynamaxRuntime(runtimes, ctx).turnBudget,
372
+ isActive: (ctx) => isDynamaxActive(getDynamaxRuntime(runtimes, ctx).state),
373
+ };
293
374
  }
294
375
 
295
376
  export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {