@jameslovespancakes/pi-plus 1.0.11 → 1.0.12
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/README.md +44 -20
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +6 -3
- package/src/core/accounts/oauth-pool.ts +102 -0
- package/src/core/accounts/registry.ts +8 -25
- package/src/core/accounts/routing.ts +122 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/quota.ts +8 -53
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +7 -9
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +8 -2
- package/src/core/codex/store.ts +12 -9
- package/src/core/config.ts +4 -17
- package/src/core/store.ts +13 -12
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +18 -37
- package/src/domains/subscriptions/accounts.ts +23 -29
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +38 -103
- package/src/domains/subscriptions/providers/anthropic.ts +5 -17
- package/src/domains/subscriptions/providers/codex.ts +171 -4
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
- package/src/domains/workflows/runtime/agent-limits.ts +43 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +203 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +543 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +581 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
|
|
6
|
+
import type { PerfSink } from "./perf.ts";
|
|
7
|
+
import { loadWorkflow, parseWorkflowModule } from "./workflow-module.ts";
|
|
8
|
+
import { BUILTIN_SOURCE_ROOT, BUILTIN_WORKFLOW_DEFINITIONS, BUILTIN_WORKFLOW_FILES } from "./workflows.ts";
|
|
9
|
+
import { captureTreeFingerprint } from "./tree-fingerprint.ts";
|
|
10
|
+
import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS } from "./resume-context.ts";
|
|
11
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
12
|
+
import { workflowDebug } from "./debug.ts";
|
|
13
|
+
|
|
14
|
+
export interface DiscoverWorkflowsOptions {
|
|
15
|
+
readonly refresh?: boolean;
|
|
16
|
+
readonly perf?: PerfSink;
|
|
17
|
+
readonly userWorkflowDir?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DISCOVERY_FINGERPRINT_MAX_BYTES = 32 << 20;
|
|
21
|
+
const DISCOVERY_FINGERPRINT_MAX_FILES = 4096;
|
|
22
|
+
|
|
23
|
+
const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
|
|
24
|
+
/** Best-effort dynamic load of every `*.ts` workflow in a directory. */
|
|
25
|
+
async function loadDir(
|
|
26
|
+
dir: string,
|
|
27
|
+
sourceIdentity: (path: string) => WorkflowSourceIdentity,
|
|
28
|
+
excludeFiles: ReadonlySet<string> = new Set(),
|
|
29
|
+
provenanceRoot?: string,
|
|
30
|
+
): Promise<LoadedWorkflow[]> {
|
|
31
|
+
let entries: string[];
|
|
32
|
+
try {
|
|
33
|
+
entries = await readdir(dir);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const before = provenanceRoot ? await captureDiscoveryFingerprint(provenanceRoot) : undefined;
|
|
39
|
+
const modules: Array<{ readonly path: string; readonly module: Parameters<typeof loadWorkflow>[0] }> = [];
|
|
40
|
+
for (const name of entries) {
|
|
41
|
+
if (excludeFiles.has(name)) continue;
|
|
42
|
+
if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
|
|
43
|
+
try {
|
|
44
|
+
const path = join(dir, name);
|
|
45
|
+
const importUrl = pathToFileURL(path);
|
|
46
|
+
if (before?.kind === "verified") importUrl.searchParams.set("workflowSource", before.fingerprint);
|
|
47
|
+
const loaded: unknown = await import(importUrl.href);
|
|
48
|
+
const parsed = parseWorkflowModule(loaded);
|
|
49
|
+
if ("module" in parsed) modules.push({ path, module: parsed.module });
|
|
50
|
+
else workflowDebug(`skipped ${name}: ${parsed.reason}`);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// Drop-in loading depends on the runtime resolving TS + the bundled typebox.
|
|
53
|
+
// Failures here are non-fatal: the static registry still works.
|
|
54
|
+
workflowDebug(`skipped ${name}: ${unknownErrorMessage(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return modules.map(({ path, module }) => loadWorkflow(module, sourceIdentity(path)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function captureDiscoveryFingerprint(root: string) {
|
|
61
|
+
return await captureTreeFingerprint({
|
|
62
|
+
root,
|
|
63
|
+
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
64
|
+
maxBytes: DISCOVERY_FINGERPRINT_MAX_BYTES,
|
|
65
|
+
maxFiles: DISCOVERY_FINGERPRINT_MAX_FILES,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* All available workflows by name. Static registry wins on name collisions, so the
|
|
71
|
+
* bundled example is always the verified one even if a same-named file is dropped in.
|
|
72
|
+
*/
|
|
73
|
+
export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, LoadedWorkflow>> {
|
|
74
|
+
const userWorkflowDir = options.userWorkflowDir ?? join(getAgentDir(), "workflows");
|
|
75
|
+
const cacheKey = `${repoDir}\0${userWorkflowDir}`;
|
|
76
|
+
const cached = discoveryCache.get(cacheKey);
|
|
77
|
+
if (cached && !options.refresh) {
|
|
78
|
+
options.perf?.counter("discovery.cache_hit");
|
|
79
|
+
return new Map(cached);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const byName = await timed(options.perf, "discovery.total_ms", async () => {
|
|
83
|
+
const next = new Map<string, LoadedWorkflow>();
|
|
84
|
+
const builtinSource = await captureDiscoveryFingerprint(BUILTIN_SOURCE_ROOT);
|
|
85
|
+
for (const definition of BUILTIN_WORKFLOW_DEFINITIONS) {
|
|
86
|
+
const source: WorkflowSourceIdentity =
|
|
87
|
+
builtinSource.kind === "verified"
|
|
88
|
+
? {
|
|
89
|
+
kind: "file",
|
|
90
|
+
path: definition.path,
|
|
91
|
+
root: definition.root,
|
|
92
|
+
fingerprint: builtinSource.fingerprint,
|
|
93
|
+
}
|
|
94
|
+
: {
|
|
95
|
+
kind: "unverifiable",
|
|
96
|
+
reason: `built-in workflow source capture failed: ${builtinSource.reason}`,
|
|
97
|
+
};
|
|
98
|
+
next.set(definition.module.meta.name, loadWorkflow(definition.module, source));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const repoWorkflowDir = join(repoDir, "workflows");
|
|
102
|
+
const [repoDynamic, userDynamic] = await Promise.all([
|
|
103
|
+
timed(options.perf, "discovery.repo_dir_ms", () =>
|
|
104
|
+
loadDir(
|
|
105
|
+
repoWorkflowDir,
|
|
106
|
+
() => ({
|
|
107
|
+
kind: "unverifiable",
|
|
108
|
+
reason: "dynamic workflow module graphs are not loaded from an immutable source snapshot",
|
|
109
|
+
}),
|
|
110
|
+
BUILTIN_WORKFLOW_FILES,
|
|
111
|
+
repoDir,
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
timed(options.perf, "discovery.user_dir_ms", () =>
|
|
115
|
+
loadDir(userWorkflowDir, () => ({
|
|
116
|
+
kind: "unverifiable",
|
|
117
|
+
reason: "dynamic user workflow dependencies do not have a declared trusted source root",
|
|
118
|
+
})),
|
|
119
|
+
),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
for (const mod of repoDynamic) {
|
|
123
|
+
if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
|
|
124
|
+
}
|
|
125
|
+
for (const mod of userDynamic) {
|
|
126
|
+
if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
|
|
127
|
+
}
|
|
128
|
+
return next;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
discoveryCache.set(cacheKey, byName);
|
|
132
|
+
return new Map(byName);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function timed<T>(perf: PerfSink | undefined, name: string, fn: () => Promise<T>): Promise<T> {
|
|
136
|
+
return perf ? await perf.time(name, fn) : await fn();
|
|
137
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Key, type KeyId } from "@earendil-works/pi-tui";
|
|
5
|
+
import { workflowDebug } from "./debug.ts";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT = "ctrl+shift+m" satisfies KeyId;
|
|
8
|
+
export const DEFAULT_REVIEW_RESULTS_SHORTCUT = "ctrl+shift+r" satisfies KeyId;
|
|
9
|
+
|
|
10
|
+
const CONFIG_FILE_NAME = "pi-workflow-engine.json";
|
|
11
|
+
|
|
12
|
+
export interface DynamaxShortcuts {
|
|
13
|
+
inspector: KeyId | null;
|
|
14
|
+
results: KeyId | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DynamaxShortcutConfig {
|
|
18
|
+
inspector?: unknown;
|
|
19
|
+
results?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function dynamaxShortcutsConfigPath(agentDir: string = getAgentDir()): string {
|
|
23
|
+
return join(agentDir, "extensions", CONFIG_FILE_NAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveDynamaxShortcuts(configPath: string = dynamaxShortcutsConfigPath()): DynamaxShortcuts {
|
|
27
|
+
if (!existsSync(configPath)) return defaultDynamaxShortcuts();
|
|
28
|
+
|
|
29
|
+
const config = readShortcutConfig(configPath);
|
|
30
|
+
if (!config) return defaultDynamaxShortcuts();
|
|
31
|
+
|
|
32
|
+
const shortcuts = {
|
|
33
|
+
inspector: shortcutFromConfig("inspector", config.inspector, DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT, configPath),
|
|
34
|
+
results: shortcutFromConfig("results", config.results, DEFAULT_REVIEW_RESULTS_SHORTCUT, configPath),
|
|
35
|
+
};
|
|
36
|
+
if (shortcuts.inspector && shortcuts.results && shortcutIdentity(shortcuts.inspector) === shortcutIdentity(shortcuts.results)) {
|
|
37
|
+
workflowDebug(`duplicate shortcut ${shortcuts.inspector} in ${configPath}; disabling the results shortcut`);
|
|
38
|
+
return { inspector: shortcuts.inspector, results: null };
|
|
39
|
+
}
|
|
40
|
+
return shortcuts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readShortcutConfig(configPath: string): DynamaxShortcutConfig | null {
|
|
44
|
+
let parsed: unknown;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
47
|
+
} catch {
|
|
48
|
+
warnUsingDefaults("Could not read", configPath);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const shortcuts = isRecord(parsed) ? parsed.shortcuts : undefined;
|
|
53
|
+
if (shortcuts === undefined) return {};
|
|
54
|
+
if (!isRecord(shortcuts)) {
|
|
55
|
+
warnUsingDefaults("Invalid", configPath);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return shortcuts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shortcutFromConfig(
|
|
63
|
+
name: keyof DynamaxShortcutConfig,
|
|
64
|
+
configured: unknown,
|
|
65
|
+
fallback: KeyId,
|
|
66
|
+
configPath: string,
|
|
67
|
+
): KeyId | null {
|
|
68
|
+
if (configured === null) return null;
|
|
69
|
+
if (configured === undefined) return fallback;
|
|
70
|
+
if (typeof configured === "string") {
|
|
71
|
+
const normalized = configured.trim();
|
|
72
|
+
if (isKeyId(normalized)) return normalized;
|
|
73
|
+
}
|
|
74
|
+
workflowDebug(`invalid ${name} shortcut in ${configPath}; using ${fallback}`);
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
79
|
+
const NAMED_BASE_KEYS = new Set<string>(Object.values(Key).flatMap((value) => (typeof value === "string" ? [String(value)] : [])));
|
|
80
|
+
|
|
81
|
+
function isKeyId(value: string): value is KeyId {
|
|
82
|
+
return parseKeyId(value) !== undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseKeyId(value: string): { readonly modifiers: readonly string[]; readonly base: string } | undefined {
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
let base = value;
|
|
88
|
+
while (true) {
|
|
89
|
+
const separator = base.indexOf("+");
|
|
90
|
+
if (separator < 0) break;
|
|
91
|
+
const modifier = base.slice(0, separator);
|
|
92
|
+
if (!MODIFIERS.has(modifier) || seen.has(modifier)) break;
|
|
93
|
+
seen.add(modifier);
|
|
94
|
+
base = base.slice(separator + 1);
|
|
95
|
+
}
|
|
96
|
+
return isBaseKey(base) ? { modifiers: [...seen], base } : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isBaseKey(value: string): boolean {
|
|
100
|
+
return /^[a-z0-9]$/.test(value) || NAMED_BASE_KEYS.has(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function shortcutIdentity(shortcut: KeyId): string {
|
|
104
|
+
const parsed = parseKeyId(shortcut);
|
|
105
|
+
if (!parsed) return shortcut;
|
|
106
|
+
let base = parsed.base;
|
|
107
|
+
if (base === "esc") base = "escape";
|
|
108
|
+
else if (base === "return") base = "enter";
|
|
109
|
+
return `${[...parsed.modifiers].sort().join("+")}+${base}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function defaultDynamaxShortcuts(): DynamaxShortcuts {
|
|
113
|
+
return { inspector: DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT, results: DEFAULT_REVIEW_RESULTS_SHORTCUT };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
117
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function warnUsingDefaults(reason: "Could not read" | "Invalid", configPath: string): void {
|
|
121
|
+
workflowDebug(`${reason} config at ${configPath}; using default shortcuts`);
|
|
122
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { EditorComponent, KeyId } from "@earendil-works/pi-tui";
|
|
4
|
+
import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
|
|
5
|
+
import { sessionKey } from "./session-identity.ts";
|
|
6
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
|
+
import {
|
|
8
|
+
decorateDynamaxEditor,
|
|
9
|
+
resolveDynamaxEffect,
|
|
10
|
+
type DynamaxAnimationScheduler,
|
|
11
|
+
type DynamaxEditorDecoration,
|
|
12
|
+
type DynamaxEffect,
|
|
13
|
+
} from "./ui/dynamax-editor-decoration.ts";
|
|
14
|
+
|
|
15
|
+
export interface DynamaxState {
|
|
16
|
+
sticky: boolean;
|
|
17
|
+
oneShotPending: boolean;
|
|
18
|
+
turnActive: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DynamaxRuntime {
|
|
22
|
+
state: DynamaxState;
|
|
23
|
+
runningWorkflow?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
|
|
27
|
+
|
|
28
|
+
export interface DynamaxRegistrationOptions {
|
|
29
|
+
openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
|
|
30
|
+
effect?: DynamaxEffect;
|
|
31
|
+
animationScheduler?: DynamaxAnimationScheduler;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
|
|
35
|
+
export const DYNAMAX_STATUS_KEY = "dynamax";
|
|
36
|
+
|
|
37
|
+
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
38
|
+
|
|
39
|
+
interface DynamaxEditorInstallation {
|
|
40
|
+
factory: EditorFactory;
|
|
41
|
+
previousFactory: EditorFactory | undefined;
|
|
42
|
+
decorations: Set<DynamaxEditorDecoration>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function disposeDynamaxDecorations(decorations: Set<DynamaxEditorDecoration>): void {
|
|
46
|
+
for (const decoration of decorations) decoration.dispose();
|
|
47
|
+
decorations.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ADAPTIVE_WORKFLOW_GUIDANCE = `
|
|
51
|
+
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:
|
|
52
|
+
- run the bounded first-pass agents;
|
|
53
|
+
- give their surviving results to a structured gap-analysis agent so the LLM decides what needs follow-up;
|
|
54
|
+
- put a hard maxItems bound on LLM-authored task arrays and defensively slice them before fan-out;
|
|
55
|
+
- use ordinary TypeScript conditionals or bounded loops to launch follow-up agents only when gaps exist;
|
|
56
|
+
- synthesize the first-pass and follow-up results together.
|
|
57
|
+
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.`;
|
|
58
|
+
|
|
59
|
+
export const DYNAMAX_REMINDER = `
|
|
60
|
+
## dynamax workflow opt-in
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
Inline workflow rules:
|
|
65
|
+
- Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
|
|
66
|
+
- Do not use import statements or dynamic import() in inline workflow scripts.
|
|
67
|
+
- Set profile to "small", "medium", or "big" on each agent() call so routing remains explicit; use model/thinkingLevel only for an intentional override.
|
|
68
|
+
- 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.
|
|
69
|
+
- Provide exactly one of workflow.name or workflow.script.
|
|
70
|
+
|
|
71
|
+
${ADAPTIVE_WORKFLOW_GUIDANCE}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
|
|
75
|
+
|
|
76
|
+
export function createDynamaxState(): DynamaxState {
|
|
77
|
+
return { sticky: false, oneShotPending: false, turnActive: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createDynamaxRuntime(): DynamaxRuntime {
|
|
81
|
+
return { state: createDynamaxState() };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getDynamaxRuntime(store: DynamaxRuntimeStore, ctx: Pick<ExtensionContext, "sessionManager">): DynamaxRuntime {
|
|
85
|
+
const key = sessionKey(ctx);
|
|
86
|
+
const existing = store.get(key);
|
|
87
|
+
if (existing) return existing;
|
|
88
|
+
const runtime = createDynamaxRuntime();
|
|
89
|
+
store.set(key, runtime);
|
|
90
|
+
return runtime;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hasDynamaxToken(text: string): boolean {
|
|
94
|
+
return DYNAMAX_TOKEN_PATTERN.test(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function markDynamaxOneShot(state: DynamaxState): void {
|
|
98
|
+
state.oneShotPending = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function enableDynamaxSticky(state: DynamaxState): void {
|
|
102
|
+
state.sticky = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function clearDynamax(state: DynamaxState): void {
|
|
106
|
+
state.sticky = false;
|
|
107
|
+
state.oneShotPending = false;
|
|
108
|
+
state.turnActive = false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isDynamaxActive(state: DynamaxState): boolean {
|
|
112
|
+
return state.sticky || state.oneShotPending || state.turnActive;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function describeDynamaxState(state: DynamaxState): string {
|
|
116
|
+
const sticky = state.sticky ? "on" : "off";
|
|
117
|
+
const oneShot = state.oneShotPending ? "pending" : "clear";
|
|
118
|
+
const turn = state.turnActive ? "; active for current turn" : "";
|
|
119
|
+
return `sticky ${sticky}; one-shot ${oneShot}${turn}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function dynamaxWidgetLine(state: DynamaxState, shortcut: KeyId | null, runningWorkflow?: string): string {
|
|
123
|
+
const modes = [
|
|
124
|
+
runningWorkflow ? `running ${runningWorkflow}` : undefined,
|
|
125
|
+
state.sticky ? "sticky on" : undefined,
|
|
126
|
+
!state.sticky && state.turnActive ? "active this turn" : undefined,
|
|
127
|
+
state.oneShotPending ? "one-shot pending" : undefined,
|
|
128
|
+
].filter((value): value is string => value !== undefined);
|
|
129
|
+
const mode = modes.join(" + ") || "off";
|
|
130
|
+
const inspectorHint = shortcut ? `${shortcut} board` : "/workflow";
|
|
131
|
+
return `dynamax: ${mode} | ${inspectorHint}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
|
|
135
|
+
if (!state.sticky && !state.oneShotPending) return systemPrompt;
|
|
136
|
+
state.oneShotPending = false;
|
|
137
|
+
state.turnActive = true;
|
|
138
|
+
return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function appendDynamaxContextReminder(messages: AgentMessage[], state: DynamaxState): AgentMessage[] {
|
|
142
|
+
if (!state.sticky) return messages;
|
|
143
|
+
return [...messages, createDynamaxContextMessage()];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function registerDynamax(
|
|
147
|
+
pi: ExtensionAPI,
|
|
148
|
+
shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts(),
|
|
149
|
+
options: DynamaxRegistrationOptions = {},
|
|
150
|
+
): void {
|
|
151
|
+
const runtimes: DynamaxRuntimeStore = new Map();
|
|
152
|
+
const effect = options.effect ?? resolveDynamaxEffect();
|
|
153
|
+
let editorInstallation: DynamaxEditorInstallation | undefined;
|
|
154
|
+
const openInspector =
|
|
155
|
+
options.openInspector ??
|
|
156
|
+
((ctx: ExtensionContext): void => {
|
|
157
|
+
ctx.ui.notify("No workflow inspector is available yet", "warning");
|
|
158
|
+
});
|
|
159
|
+
const installEditor = (ctx: ExtensionContext): void => {
|
|
160
|
+
if (ctx.mode !== "tui" || effect === "off") return;
|
|
161
|
+
const previousFactory = ctx.ui.getEditorComponent();
|
|
162
|
+
if (editorInstallation && editorInstallation.factory === previousFactory) return;
|
|
163
|
+
|
|
164
|
+
const decorations = new Set<DynamaxEditorDecoration>();
|
|
165
|
+
const installation: DynamaxEditorInstallation = {
|
|
166
|
+
previousFactory,
|
|
167
|
+
decorations,
|
|
168
|
+
factory: (tui, theme, keybindings) => {
|
|
169
|
+
const decorate = (editor: EditorComponent): EditorComponent => {
|
|
170
|
+
const decoration = decorateDynamaxEditor(editor, () => tui.requestRender(), {
|
|
171
|
+
effect,
|
|
172
|
+
scheduler: options.animationScheduler,
|
|
173
|
+
isActive: () => editorInstallation === installation && ctx.ui.getEditorComponent() === installation.factory,
|
|
174
|
+
});
|
|
175
|
+
decorations.add(decoration);
|
|
176
|
+
return decoration.editor;
|
|
177
|
+
};
|
|
178
|
+
if (!previousFactory) return decorate(new CustomEditor(tui, theme, keybindings));
|
|
179
|
+
|
|
180
|
+
let editor: EditorComponent;
|
|
181
|
+
try {
|
|
182
|
+
editor = previousFactory(tui, theme, keybindings);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
ctx.ui.notify(
|
|
185
|
+
`Dynamax could not compose the existing custom editor (${unknownErrorMessage(error)}); using pi's stock-compatible CustomEditor so highlighting stays enabled`,
|
|
186
|
+
"warning",
|
|
187
|
+
);
|
|
188
|
+
installation.previousFactory = undefined;
|
|
189
|
+
return decorate(new CustomEditor(tui, theme, keybindings));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
return decorate(editor);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
ctx.ui.notify(
|
|
196
|
+
`Dynamax could not decorate the existing custom editor (${unknownErrorMessage(error)}); using pi's stock-compatible CustomEditor so highlighting stays enabled`,
|
|
197
|
+
"warning",
|
|
198
|
+
);
|
|
199
|
+
return decorate(new CustomEditor(tui, theme, keybindings));
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
ctx.ui.setEditorComponent(installation.factory);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
disposeDynamaxDecorations(decorations);
|
|
208
|
+
ctx.ui.notify(`Dynamax highlighting could not be installed: ${unknownErrorMessage(error)}`, "error");
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
editorInstallation = installation;
|
|
212
|
+
};
|
|
213
|
+
const uninstallEditor = (ctx: ExtensionContext): void => {
|
|
214
|
+
const installation = editorInstallation;
|
|
215
|
+
if (!installation) return;
|
|
216
|
+
editorInstallation = undefined;
|
|
217
|
+
disposeDynamaxDecorations(installation.decorations);
|
|
218
|
+
if (ctx.mode === "tui" && ctx.ui.getEditorComponent() === installation.factory) {
|
|
219
|
+
ctx.ui.setEditorComponent(installation.previousFactory);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
pi.on("session_start", (_event, ctx) => {
|
|
224
|
+
installEditor(ctx);
|
|
225
|
+
updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
229
|
+
uninstallEditor(ctx);
|
|
230
|
+
clearDynamaxSurfaces(ctx);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
pi.on("input", (event, ctx) => {
|
|
234
|
+
if (event.source === "extension") return { action: "continue" };
|
|
235
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
236
|
+
if (hasDynamaxToken(event.text)) {
|
|
237
|
+
markDynamaxOneShot(runtime.state);
|
|
238
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
239
|
+
}
|
|
240
|
+
return { action: "continue" };
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
244
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
245
|
+
const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
|
|
246
|
+
if (systemPrompt === event.systemPrompt) return undefined;
|
|
247
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
248
|
+
return { systemPrompt };
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
252
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
253
|
+
runtime.runningWorkflow = undefined;
|
|
254
|
+
if (!runtime.state.sticky) runtime.state.turnActive = false;
|
|
255
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
259
|
+
if (event.toolName !== "workflow") return undefined;
|
|
260
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
261
|
+
runtime.runningWorkflow = workflowLabel(event.args);
|
|
262
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
263
|
+
return undefined;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
267
|
+
if (event.toolName !== "workflow") return undefined;
|
|
268
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
269
|
+
runtime.runningWorkflow = undefined;
|
|
270
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
271
|
+
return undefined;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
pi.on("context", (event, ctx) => {
|
|
275
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
276
|
+
const messages = appendDynamaxContextReminder(event.messages, runtime.state);
|
|
277
|
+
if (messages === event.messages) return undefined;
|
|
278
|
+
return { messages };
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (shortcuts.inspector) {
|
|
282
|
+
pi.registerShortcut(shortcuts.inspector, {
|
|
283
|
+
description: "Open workflow inspector",
|
|
284
|
+
handler: async (ctx) => {
|
|
285
|
+
await openInspector(ctx);
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
|
|
293
|
+
if (!ctx.hasUI) return;
|
|
294
|
+
if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
|
|
295
|
+
clearDynamaxSurfaces(ctx);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const status = dynamaxWidgetLine(runtime.state, shortcuts.inspector, runtime.runningWorkflow);
|
|
300
|
+
ctx.ui.setStatus(DYNAMAX_STATUS_KEY, status);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">): void {
|
|
304
|
+
if (!ctx.hasUI) return;
|
|
305
|
+
ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function workflowLabel(args: unknown): string {
|
|
309
|
+
if (!isRecord(args)) return "workflow";
|
|
310
|
+
const name = args.name;
|
|
311
|
+
if (typeof name === "string" && name.trim()) return name.trim();
|
|
312
|
+
const script = args.script;
|
|
313
|
+
if (typeof script === "string" && script.trim()) return "inline workflow";
|
|
314
|
+
return "workflow";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function createDynamaxContextMessage(): AgentMessage {
|
|
318
|
+
return {
|
|
319
|
+
role: "custom",
|
|
320
|
+
customType: DYNAMAX_CONTEXT_CUSTOM_TYPE,
|
|
321
|
+
content: DYNAMAX_REMINDER.trim(),
|
|
322
|
+
display: false,
|
|
323
|
+
details: { dynamax: true, sticky: true },
|
|
324
|
+
timestamp: Date.now(),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
329
|
+
return typeof value === "object" && value !== null;
|
|
330
|
+
}
|