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.
- package/.pi/extensions/pi-workflow-engine/index.ts +236 -94
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
- package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +12 -4
- package/USAGE.md +167 -23
- package/package.json +13 -4
- 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.
|
|
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 =
|
|
144
|
+
const cached = moduleCache.get(cacheKey);
|
|
66
145
|
if (cached && !options.refresh) {
|
|
67
146
|
options.perf?.counter("discovery.cache_hit");
|
|
68
|
-
return
|
|
147
|
+
return cached;
|
|
69
148
|
}
|
|
70
149
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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: "
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
323
|
+
return appendSectionToForcedPrompt(options, DYNAMAX_SECTION);
|
|
245
324
|
});
|
|
246
325
|
|
|
247
|
-
|
|
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
|
|
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
|
|
268
|
-
|
|
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 {
|
|
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 {
|