@signalridge/pi-workflows 1.2.1 → 1.3.2
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/CHANGELOG.md +41 -0
- package/README.md +142 -17
- package/package.json +14 -8
- package/skills/workflow-authoring/SKILL.md +30 -0
- package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
- package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
- package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
- package/skills/workflow-authoring/examples/defensive-json-parsing.js +63 -0
- package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
- package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
- package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
- package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
- package/skills/workflow-authoring/examples/structured-output.js +37 -0
- package/skills/workflow-authoring/examples/tournament.js +81 -0
- package/skills/workflow-authoring/examples/validated-gate.js +63 -0
- package/skills/workflow-authoring/references/capabilities.md +44 -0
- package/skills/workflow-authoring/references/capability-details.md +294 -0
- package/skills/workflow-authoring/references/common-helpers.md +4 -0
- package/skills/workflow-authoring/references/debugging.md +29 -0
- package/skills/workflow-authoring/references/focused-recipes.md +12 -0
- package/skills/workflow-authoring/references/helpers.md +9 -0
- package/skills/workflow-authoring/references/lifecycle.md +37 -0
- package/skills/workflow-authoring/references/pattern-selection.md +15 -0
- package/skills/workflow-authoring/references/quality-helpers.md +8 -0
- package/skills/workflow-authoring/references/registry-ownership.md +17 -0
- package/skills/workflow-authoring/references/retry-helper.md +5 -0
- package/skills/workflow-authoring/references/review.md +43 -0
- package/skills/workflow-authoring/references/runtime.md +29 -0
- package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
- package/skills/workflow-authoring/references/versions.md +13 -0
- package/skills/workflow-patterns/SKILL.md +51 -0
- package/src/arming.ts +69 -0
- package/src/builtins.ts +331 -0
- package/src/capabilities.ts +318 -0
- package/src/code-review-scope.ts +144 -0
- package/src/engine.ts +1158 -2561
- package/src/errors.ts +136 -0
- package/src/index.ts +750 -103
- package/src/journal.ts +594 -1001
- package/src/rpc-client.ts +95 -24
- package/src/runtime.ts +1708 -0
- package/src/saved-workflows.ts +121 -0
- package/src/shared-store.ts +132 -0
- package/src/ui.ts +79 -42
- package/src/workflow-settings.ts +100 -0
- package/src/schema.ts +0 -215
- package/src/state-machine.ts +0 -79
- package/src/synthesis.ts +0 -140
package/src/index.ts
CHANGED
|
@@ -1,67 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
5
|
import { Type } from "@sinclair/typebox";
|
|
5
|
-
import {
|
|
6
|
+
import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMED_DIRECTIVE } from "./arming.js";
|
|
7
|
+
import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
|
|
8
|
+
import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
|
|
9
|
+
import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
|
|
6
10
|
import { JOURNAL_ENTRY_TYPE, type SessionEntryLike } from "./journal.js";
|
|
7
11
|
import { createManagedSpawnClient, PROTOCOL_DIAGNOSTIC, queryChildSessionContextImmediate } from "./rpc-client.js";
|
|
8
|
-
import {
|
|
9
|
-
import { showWorkflowNavigator } from "./ui.js";
|
|
12
|
+
import { listSavedWorkflows, loadSavedWorkflow, saveWorkflow } from "./saved-workflows.js";
|
|
13
|
+
import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
|
|
14
|
+
import { loadWorkflowSettings, saveWorkflowSettings } from "./workflow-settings.js";
|
|
10
15
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const PhaseSchema = Type.Object(
|
|
14
|
-
{
|
|
15
|
-
id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
16
|
-
title: Type.String({ minLength: 1, maxLength: 512 }),
|
|
17
|
-
},
|
|
18
|
-
{ additionalProperties: false },
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
const TaskSchema = Type.Object(
|
|
22
|
-
{
|
|
23
|
-
id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
24
|
-
phase: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
25
|
-
tier: Type.Optional(WorkflowTierSchema),
|
|
26
|
-
subagent_type: Type.String({ minLength: 1, maxLength: 128 }),
|
|
27
|
-
description: Type.String({ minLength: 1, maxLength: 512 }),
|
|
28
|
-
prompt: Type.String({ minLength: 1, maxLength: 100_000 }),
|
|
29
|
-
depends_on: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
|
|
30
|
-
inputs: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
|
|
31
|
-
},
|
|
32
|
-
{ additionalProperties: false },
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
const SynthesisSchema = Type.Object(
|
|
16
|
+
const WorkflowToolSchema = Type.Object(
|
|
36
17
|
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
{ additionalProperties: false },
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
const WorkflowSchema = Type.Object(
|
|
45
|
-
{
|
|
46
|
-
name: Type.String({ minLength: 1, maxLength: 512 }),
|
|
47
|
-
description: Type.Optional(Type.String({ maxLength: 100_000 })),
|
|
48
|
-
tier: Type.Optional(WorkflowTierSchema),
|
|
49
|
-
phases: Type.Optional(Type.Array(PhaseSchema, { maxItems: 32 })),
|
|
50
|
-
tasks: Type.Array(TaskSchema, { minItems: 1, maxItems: 128 }),
|
|
51
|
-
synthesis: Type.Optional(SynthesisSchema),
|
|
18
|
+
script: Type.Optional(Type.String({ maxLength: 200_000 })),
|
|
19
|
+
name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
20
|
+
args: Type.Optional(Type.Any()),
|
|
52
21
|
background: Type.Optional(Type.Boolean()),
|
|
22
|
+
maxAgents: Type.Optional(Type.Number({ minimum: 1 })),
|
|
23
|
+
concurrency: Type.Optional(Type.Number({ minimum: 1 })),
|
|
24
|
+
agentRetries: Type.Optional(Type.Number({ minimum: 0, maximum: 3 })),
|
|
25
|
+
tokenBudget: Type.Optional(Type.Number({ minimum: 1 })),
|
|
26
|
+
agentTimeoutMs: Type.Optional(Type.Number({ minimum: 1 })),
|
|
27
|
+
resumeFromRunId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
53
28
|
},
|
|
54
29
|
{ additionalProperties: false },
|
|
55
30
|
);
|
|
56
31
|
|
|
57
32
|
const ControlSchema = Type.Object(
|
|
58
33
|
{
|
|
59
|
-
action: Type.
|
|
34
|
+
action: Type.Union(
|
|
35
|
+
[
|
|
36
|
+
Type.Literal("list", { description: "List all workflow runs" }),
|
|
37
|
+
Type.Literal("get", { description: "Get details for one run" }),
|
|
38
|
+
Type.Literal("status", { description: "Alias for get — inspect one run" }),
|
|
39
|
+
Type.Literal("pause", { description: "Pause a running workflow" }),
|
|
40
|
+
Type.Literal("resume", { description: "Resume a paused/interrupted workflow" }),
|
|
41
|
+
Type.Literal("stop", { description: "Stop a running workflow" }),
|
|
42
|
+
Type.Literal("rm", { description: "Remove a workflow run and its journal" }),
|
|
43
|
+
],
|
|
44
|
+
{ description: "Control action" },
|
|
45
|
+
),
|
|
60
46
|
run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
47
|
+
// Accept camelCase and legacy workflow_run_id for interop with pi-dynamic-workflows docs
|
|
48
|
+
runId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
49
|
+
workflow_run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
61
50
|
},
|
|
62
51
|
{ additionalProperties: false },
|
|
63
52
|
);
|
|
64
53
|
|
|
54
|
+
function normalizeControlRunId(params: Record<string, unknown>): string | undefined {
|
|
55
|
+
const raw = params.run_id ?? params.runId ?? params.workflow_run_id ?? params.runID ?? params.workflowRunId;
|
|
56
|
+
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
65
59
|
function textResult(text: string, details: unknown = {}) {
|
|
66
60
|
return {
|
|
67
61
|
content: [{ type: "text" as const, text }],
|
|
@@ -69,14 +63,50 @@ function textResult(text: string, details: unknown = {}) {
|
|
|
69
63
|
};
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Run one command for diff discovery. argv array, never a shell string — a
|
|
68
|
+
* branch name or path reaches this from user input.
|
|
69
|
+
*/
|
|
70
|
+
function runCommand(file: string, args: string[]): CommandResult {
|
|
71
|
+
try {
|
|
72
|
+
const stdout = execFileSync(file, args, {
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
75
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
76
|
+
});
|
|
77
|
+
return { ok: true, stdout };
|
|
78
|
+
} catch {
|
|
79
|
+
// Non-zero exit or a missing binary. Either way there is no usable diff;
|
|
80
|
+
// the caller decides whether to degrade or report.
|
|
81
|
+
return { ok: false, stdout: "" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Control subcommands of `/workflows` that take a run id. */
|
|
86
|
+
export type WorkflowControlSubcommand = "stop" | "pause" | "resume" | "rm";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve which run a `/workflows <subcommand>` invocation acts on.
|
|
90
|
+
*
|
|
91
|
+
* `stop` and `rm` are destructive — stopping kills the run's owned agents, and
|
|
92
|
+
* `rm` deletes the run and its journal — so they take no implicit target: a
|
|
93
|
+
* bare `/workflows rm` must never act on whatever `list()` happens to return
|
|
94
|
+
* first. `pause` and `resume` are reversible, so they keep the convenience
|
|
95
|
+
* default for the common single-run case.
|
|
96
|
+
*
|
|
97
|
+
* Returns the empty string when the caller must be asked for an explicit id.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveControlTarget(
|
|
100
|
+
subcommand: WorkflowControlSubcommand,
|
|
101
|
+
explicitRunId: string,
|
|
102
|
+
firstListedRunId: string | undefined,
|
|
103
|
+
): string {
|
|
104
|
+
if (explicitRunId) return explicitRunId;
|
|
105
|
+
if (subcommand === "stop" || subcommand === "rm") return "";
|
|
106
|
+
return firstListedRunId ?? "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatStart(result: ScriptStartResult): string {
|
|
80
110
|
const lines = [
|
|
81
111
|
`Workflow ${result.background ? "started in background" : result.status}: ${result.runId}`,
|
|
82
112
|
`status=${result.status}`,
|
|
@@ -94,16 +124,62 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
94
124
|
let protocolCheck: Promise<void> = Promise.resolve();
|
|
95
125
|
let active = false;
|
|
96
126
|
let unsubscribeTreeSignal: (() => void) | undefined;
|
|
97
|
-
let branchQuiesce: Promise<
|
|
127
|
+
let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
|
|
98
128
|
let lifecycleGeneration = 0;
|
|
99
|
-
let branchGeneration = 0;
|
|
100
129
|
let protocolAbortController: AbortController | undefined;
|
|
130
|
+
let sessionCwd = process.cwd();
|
|
131
|
+
let workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
132
|
+
let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
|
|
133
|
+
/** Per-session delivery endpoint for background-run results (A10 fail-closed). */
|
|
134
|
+
let deliverResult: ((text: string, details?: unknown) => void) | undefined;
|
|
135
|
+
/** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
|
|
136
|
+
const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
|
|
137
|
+
const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
|
|
138
|
+
const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
|
|
139
|
+
const MAX_PENDING_DELIVERIES = 256;
|
|
140
|
+
const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
141
|
+
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
101
142
|
|
|
102
143
|
const currentEngine = (): WorkflowEngine => {
|
|
103
144
|
if (!engine) throw new Error("pi-workflows is not active in this session context");
|
|
104
145
|
return engine;
|
|
105
146
|
};
|
|
106
147
|
|
|
148
|
+
const branchEntries = (): SessionEntryLike[] => {
|
|
149
|
+
const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
|
|
150
|
+
try {
|
|
151
|
+
return (ctx?.getBranch?.() as SessionEntryLike[] | undefined) ?? [];
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
|
|
158
|
+
|
|
159
|
+
const resolveScript = (
|
|
160
|
+
script: string | undefined,
|
|
161
|
+
name: string | undefined,
|
|
162
|
+
args: unknown,
|
|
163
|
+
): { script: string; source: string; toolset?: string } => {
|
|
164
|
+
if (script !== undefined && name !== undefined) {
|
|
165
|
+
throw new Error("Provide either `script` or `name`, not both.");
|
|
166
|
+
}
|
|
167
|
+
if (script !== undefined) return { script, source: "inline" };
|
|
168
|
+
if (name !== undefined) {
|
|
169
|
+
const saved = loadSavedWorkflow(name, sessionCwd);
|
|
170
|
+
if (saved) return { script: saved, source: `saved:${name}` };
|
|
171
|
+
const builtin = BUILTIN_WORKFLOWS[name];
|
|
172
|
+
if (builtin) {
|
|
173
|
+
// Validate named invocations before starting a potentially expensive fleet.
|
|
174
|
+
// Slash commands provide the same shape through their descriptor primaryArg.
|
|
175
|
+
validateBuiltinArgs(name, args);
|
|
176
|
+
return { script: builtin.script, source: `builtin:${name}`, toolset: builtin.toolset };
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Unknown workflow name "${name}". It is neither a saved workflow nor a built-in.`);
|
|
179
|
+
}
|
|
180
|
+
throw new Error("Provide either `script` or `name`.");
|
|
181
|
+
};
|
|
182
|
+
|
|
107
183
|
const registerSurface = (): void => {
|
|
108
184
|
if (active) return;
|
|
109
185
|
active = true;
|
|
@@ -114,16 +190,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
114
190
|
name: "workflow",
|
|
115
191
|
label: "Workflow",
|
|
116
192
|
description:
|
|
117
|
-
"Run a
|
|
118
|
-
promptSnippet: "Run
|
|
193
|
+
"Run a JavaScript workflow script through the pi-subagents managed spawning protocol. The script declares `export const meta = { name, description, phases }` first and uses the runtime globals agent(), parallel(), pipeline(), workflow(), verify(), judgePanel(), loopUntilDry(), completenessCheck(), retry(), gate(), checkpoint(), phase(), log(), args, cwd, process, and budget. Determinism is enforced: Date.now()/Math.random()/new Date() are unavailable.",
|
|
194
|
+
promptSnippet: "Run a JavaScript workflow script",
|
|
119
195
|
promptGuidelines: [
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"Use
|
|
196
|
+
"Provide either a full `script` (raw JavaScript with the meta contract first) or a `name` of a saved/built-in workflow.",
|
|
197
|
+
"Use parallel(() => agent(...), ...) for concurrent agents; pipeline(items, ...stages) for sequential per-item stages.",
|
|
198
|
+
"Use background: true for long runs; retrieve results with workflow_control.",
|
|
199
|
+
"Use resumeFromRunId with an edited script to replay the unchanged prefix from cache and re-run the rest live.",
|
|
200
|
+
"Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings beyond the declared options.",
|
|
123
201
|
],
|
|
124
|
-
parameters:
|
|
202
|
+
parameters: WorkflowToolSchema,
|
|
125
203
|
renderCall(args, theme) {
|
|
126
|
-
|
|
204
|
+
const label = args.name ?? (args.script !== undefined ? "script" : "");
|
|
205
|
+
return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${label}`)), 0, 0);
|
|
127
206
|
},
|
|
128
207
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
129
208
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
@@ -138,7 +217,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
138
217
|
0,
|
|
139
218
|
);
|
|
140
219
|
},
|
|
141
|
-
async execute(_toolCallId, params, signal, _onUpdate,
|
|
220
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
142
221
|
try {
|
|
143
222
|
await protocolCheck;
|
|
144
223
|
} catch (error: unknown) {
|
|
@@ -147,8 +226,54 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
147
226
|
`${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
|
|
148
227
|
);
|
|
149
228
|
}
|
|
150
|
-
const
|
|
151
|
-
|
|
229
|
+
const resolved =
|
|
230
|
+
params.resumeFromRunId && params.script === undefined && params.name === undefined
|
|
231
|
+
? (() => {
|
|
232
|
+
const prior = workflowEngine.getRun(params.resumeFromRunId);
|
|
233
|
+
if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
234
|
+
return { script: prior.script, source: "resume", toolset: prior.toolset };
|
|
235
|
+
})()
|
|
236
|
+
: resolveScript(params.script, params.name, params.args);
|
|
237
|
+
const { script, source, toolset } = resolved;
|
|
238
|
+
const options: ScriptStartOptions = {
|
|
239
|
+
args: params.args,
|
|
240
|
+
background: params.background ?? true,
|
|
241
|
+
maxAgents: params.maxAgents,
|
|
242
|
+
concurrency: params.concurrency,
|
|
243
|
+
agentRetries: params.agentRetries,
|
|
244
|
+
tokenBudget: params.tokenBudget,
|
|
245
|
+
agentTimeoutMs: params.agentTimeoutMs,
|
|
246
|
+
toolset,
|
|
247
|
+
excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
|
|
248
|
+
signal,
|
|
249
|
+
mainModel: ctx?.model?.id,
|
|
250
|
+
// Foreground checkpoint confirmation only when the run is not
|
|
251
|
+
// background — background runs are headless by contract.
|
|
252
|
+
confirm:
|
|
253
|
+
params.background === false && ctx?.hasUI && ctx.mode === "tui"
|
|
254
|
+
? async (promptText, checkpointOptions) => {
|
|
255
|
+
if (checkpointOptions.kind === "input")
|
|
256
|
+
return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
|
|
257
|
+
if (checkpointOptions.kind === "select")
|
|
258
|
+
return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
|
|
259
|
+
return ctx.ui.confirm(promptText, promptText);
|
|
260
|
+
}
|
|
261
|
+
: undefined,
|
|
262
|
+
loadSavedWorkflow: (name) => loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script,
|
|
263
|
+
};
|
|
264
|
+
if (params.resumeFromRunId) {
|
|
265
|
+
const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
|
|
266
|
+
const resumed = await workflowEngine.resume(params.resumeFromRunId, branchEntries(), options, replacement);
|
|
267
|
+
if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
268
|
+
if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
|
|
269
|
+
return textResult(formatStart(resumed), resumed);
|
|
270
|
+
}
|
|
271
|
+
const result = await workflowEngine.start(script, options);
|
|
272
|
+
if (result.background) {
|
|
273
|
+
// The engine resolves the script's meta name for the delivery header.
|
|
274
|
+
void source;
|
|
275
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
276
|
+
}
|
|
152
277
|
return textResult(formatStart(result), result);
|
|
153
278
|
},
|
|
154
279
|
}),
|
|
@@ -158,7 +283,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
158
283
|
defineTool({
|
|
159
284
|
name: "workflow_control",
|
|
160
285
|
label: "Workflow Control",
|
|
161
|
-
description:
|
|
286
|
+
description:
|
|
287
|
+
"List, inspect, pause, resume, stop, or remove workflow runs with bounded machine-readable output.",
|
|
162
288
|
parameters: ControlSchema,
|
|
163
289
|
renderCall(args, theme) {
|
|
164
290
|
return new Text(theme.fg("toolTitle", theme.bold(`Workflow control: ${args.action ?? ""}`)), 0, 0);
|
|
@@ -167,25 +293,385 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
167
293
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
168
294
|
return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
|
|
169
295
|
},
|
|
170
|
-
async execute(_toolCallId, params) {
|
|
171
|
-
const
|
|
172
|
-
|
|
296
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
297
|
+
const rawAction = params.action as string;
|
|
298
|
+
const action = (rawAction === "status" ? "get" : rawAction) as
|
|
299
|
+
| "list"
|
|
300
|
+
| "get"
|
|
301
|
+
| "pause"
|
|
302
|
+
| "resume"
|
|
303
|
+
| "stop"
|
|
304
|
+
| "rm";
|
|
305
|
+
if (!["list", "get", "pause", "resume", "stop", "rm"].includes(action)) {
|
|
173
306
|
throw new Error(`unknown workflow_control action: ${params.action}`);
|
|
174
307
|
}
|
|
175
|
-
const
|
|
308
|
+
const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
|
|
309
|
+
const result = await workflowEngine.control(action, runId);
|
|
176
310
|
return textResult(JSON.stringify(result), result);
|
|
177
311
|
},
|
|
178
312
|
}),
|
|
179
313
|
);
|
|
180
314
|
|
|
315
|
+
// /workflows: navigator + run/status/watch/stop/pause/resume/rm/save subcommands
|
|
181
316
|
pi.registerCommand("workflows", {
|
|
182
|
-
description: "
|
|
317
|
+
description: "Workflow runs: open the navigator, or run/status/watch/stop/pause/resume/rm/save",
|
|
318
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
319
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
320
|
+
const [subcommand, ...rest] = tokens;
|
|
321
|
+
const restArgs = rest.join(" ").trim();
|
|
322
|
+
if (!subcommand) {
|
|
323
|
+
await showWorkflowNavigator(ctx, workflowEngine);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (subcommand === "run") {
|
|
327
|
+
if (!restArgs) {
|
|
328
|
+
ctx.ui.notify("/workflows run <prompt-or-script>", "warning");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const runId = await runWorkflowFromPrompt(ctx, restArgs);
|
|
332
|
+
if (runId) ctx.ui.notify(`Workflow started in background: ${runId}`, "info");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (subcommand === "status") {
|
|
336
|
+
if (restArgs) {
|
|
337
|
+
const run = workflowEngine.getRun(restArgs);
|
|
338
|
+
if (!run) {
|
|
339
|
+
ctx.ui.notify(`Workflow run not found: ${restArgs}`, "warning");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
ctx.ui.notify(`${run.runId} · ${run.status} · ${String(run.meta?.name ?? run.runId)}`, "info");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const runs = workflowEngine.list();
|
|
346
|
+
ctx.ui.notify(
|
|
347
|
+
runs.length === 0
|
|
348
|
+
? "No workflow runs."
|
|
349
|
+
: runs.map((r) => `${r.runId} · ${r.status} · ${String(r.name)}`).join("\n"),
|
|
350
|
+
"info",
|
|
351
|
+
);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (subcommand === "watch") {
|
|
355
|
+
const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
|
|
356
|
+
if (!runId) {
|
|
357
|
+
ctx.ui.notify("No workflow runs to watch.", "warning");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const run = await workflowEngine.waitFor(runId).catch(() => undefined);
|
|
361
|
+
if (run) ctx.ui.notify(`${run.runId} settled as ${run.status}`, "info");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
|
|
365
|
+
const firstListed = workflowEngine.list()[0]?.runId;
|
|
366
|
+
const runId = resolveControlTarget(
|
|
367
|
+
subcommand,
|
|
368
|
+
restArgs,
|
|
369
|
+
firstListed === undefined ? undefined : String(firstListed),
|
|
370
|
+
);
|
|
371
|
+
if (!runId) {
|
|
372
|
+
const destructive = subcommand === "stop" || subcommand === "rm";
|
|
373
|
+
ctx.ui.notify(
|
|
374
|
+
destructive
|
|
375
|
+
? `/workflows ${subcommand} <runId> — a run id is required; /workflows status lists them`
|
|
376
|
+
: `/workflows ${subcommand} <runId>`,
|
|
377
|
+
"warning",
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
await workflowEngine.control(subcommand, runId);
|
|
383
|
+
if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
|
|
384
|
+
ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
|
|
385
|
+
} catch (error: unknown) {
|
|
386
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (subcommand === "save") {
|
|
391
|
+
const name = rest[0] ?? "";
|
|
392
|
+
if (!name) {
|
|
393
|
+
ctx.ui.notify("/workflows save <name> [runId]", "warning");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
|
|
397
|
+
const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
|
|
398
|
+
if (!runId) {
|
|
399
|
+
ctx.ui.notify("No workflow runs to save.", "warning");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
await saveWorkflowFromRun(ctx, name, runId);
|
|
404
|
+
} catch (error: unknown) {
|
|
405
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// /workflows-models: tier configuration is owned by pi-subagents; this
|
|
414
|
+
// command documents the delegation (A8: no second tier config file).
|
|
415
|
+
pi.registerCommand("workflows-models", {
|
|
416
|
+
description: "Workflow tier routing: tiers are configured in pi-subagents /agents → Model tiers",
|
|
183
417
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
184
|
-
|
|
418
|
+
ctx.ui.notify(
|
|
419
|
+
"Workflow tiers (small/medium/large) route through pi-subagents' workflow.tiers settings. " +
|
|
420
|
+
"Use /agents → Model tiers to configure them.",
|
|
421
|
+
"info",
|
|
422
|
+
);
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// /workflows-progress: persistent compact/detailed live-panel settings.
|
|
427
|
+
pi.registerCommand("workflows-progress", {
|
|
428
|
+
description: "Workflow progress panel: compact|detailed|status|max <N>",
|
|
429
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
430
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
431
|
+
const action = tokens[0] ?? "status";
|
|
432
|
+
if (action === "status") {
|
|
433
|
+
ctx.ui.notify(
|
|
434
|
+
`Workflow progress: ${workflowSettings.progressMode}, max agents shown: ${workflowSettings.maxAgentsShown}`,
|
|
435
|
+
"info",
|
|
436
|
+
);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (action === "compact" || action === "detailed") {
|
|
440
|
+
workflowSettings = { ...workflowSettings, progressMode: action };
|
|
441
|
+
} else if (action === "max") {
|
|
442
|
+
const max = Number(tokens[1]);
|
|
443
|
+
if (!Number.isInteger(max) || max < 1 || max > 32) {
|
|
444
|
+
ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
workflowSettings = { ...workflowSettings, maxAgentsShown: max };
|
|
448
|
+
} else {
|
|
449
|
+
ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
saveWorkflowSettings(workflowSettings, sessionCwd);
|
|
454
|
+
refreshWidget();
|
|
455
|
+
ctx.ui.notify(
|
|
456
|
+
`Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
|
|
457
|
+
"info",
|
|
458
|
+
);
|
|
459
|
+
} catch (error: unknown) {
|
|
460
|
+
ctx.ui.notify(
|
|
461
|
+
`Could not save workflow progress settings: ${error instanceof Error ? error.message : String(error)}`,
|
|
462
|
+
"warning",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// /workflows-trigger: keyword arming (A5). Typing the bounded word
|
|
469
|
+
// `workflow`/`workflows` — or a configured synonym — in an ordinary message
|
|
470
|
+
// counts as an explicit opt-in to multi-agent orchestration, and the message
|
|
471
|
+
// is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
|
|
472
|
+
// and it never opens UI. A user asking a question about workflows still gets
|
|
473
|
+
// an ordinary answer.
|
|
474
|
+
let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
|
|
475
|
+
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
476
|
+
const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
|
|
477
|
+
const persistTriggerSettings = (): void => {
|
|
478
|
+
workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
|
|
479
|
+
try {
|
|
480
|
+
saveWorkflowSettings(workflowSettings, sessionCwd);
|
|
481
|
+
} catch {
|
|
482
|
+
/* setting changes remain active in memory */
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
pi.registerCommand("workflows-trigger", {
|
|
486
|
+
description: "Arm the workflow tool on a keyword: set <keyword> | off | on | reset | status",
|
|
487
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
488
|
+
const token = args.trim().split(/\s+/)[0] ?? "";
|
|
489
|
+
if (token === "off") {
|
|
490
|
+
keywordTriggerEnabled = false;
|
|
491
|
+
persistTriggerSettings();
|
|
492
|
+
ctx.ui.notify("Workflow keyword trigger disabled.", "info");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (token === "on") {
|
|
496
|
+
keywordTriggerEnabled = true;
|
|
497
|
+
persistTriggerSettings();
|
|
498
|
+
ctx.ui.notify(`Workflow keyword trigger armed on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}".`, "info");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (token === "reset") {
|
|
502
|
+
triggerKeyword = undefined;
|
|
503
|
+
keywordTriggerEnabled = true;
|
|
504
|
+
persistTriggerSettings();
|
|
505
|
+
ctx.ui.notify(`Workflow keyword trigger reset to default "${DEFAULT_TRIGGER_WORD}".`, "info");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (token === "status") {
|
|
509
|
+
ctx.ui.notify(
|
|
510
|
+
`Workflow keyword trigger: ${keywordTriggerEnabled ? "armed" : "disabled"} on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}"`,
|
|
511
|
+
"info",
|
|
512
|
+
);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const keyword = token === "set" ? (args.trim().split(/\s+/)[1] ?? "") : token;
|
|
516
|
+
if (!TRIGGER_WORD_PATTERN.test(keyword)) {
|
|
517
|
+
ctx.ui.notify("Usage: /workflows-trigger set <one-word-keyword> | off | on | reset | status", "warning");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
triggerKeyword = keyword.toLowerCase();
|
|
521
|
+
keywordTriggerEnabled = true;
|
|
522
|
+
persistTriggerSettings();
|
|
523
|
+
ctx.ui.notify(`Workflow keyword trigger armed on "${keyword}".`, "info");
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Keyword arming input hook (A5). The message is annotated rather than
|
|
528
|
+
// acted on: the model is told it may use the `workflow` tool and may still
|
|
529
|
+
// decline, which is what keeps "how do workflows work?" an ordinary
|
|
530
|
+
// question. Nothing is swallowed and no UI opens.
|
|
531
|
+
pi.on("input", (event) => {
|
|
532
|
+
if (event.source === "extension") return { action: "continue" as const };
|
|
533
|
+
const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(event.text, triggerKeyword);
|
|
534
|
+
const substantive = effortLevel !== "off" && event.text.trim().length >= 16 && !event.text.trim().startsWith("/");
|
|
535
|
+
if (!explicitTrigger && !substantive) return { action: "continue" as const };
|
|
536
|
+
if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
|
|
537
|
+
const effortDirective =
|
|
538
|
+
effortLevel === "ultra"
|
|
539
|
+
? "Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks; choose the large tier where useful."
|
|
540
|
+
: effortLevel === "high"
|
|
541
|
+
? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
|
|
542
|
+
: "";
|
|
543
|
+
return {
|
|
544
|
+
action: "transform" as const,
|
|
545
|
+
text: `${event.text}\n\n${WORKFLOW_ARMED_DIRECTIVE}${effortDirective ? `\n${effortDirective}` : ""}`,
|
|
546
|
+
...(event.images ? { images: event.images } : {}),
|
|
547
|
+
};
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Standing effort mode mirrors the reference: it auto-arms substantive
|
|
551
|
+
// interactive messages but remains guidance, not an inferred spend ceiling.
|
|
552
|
+
pi.registerCommand("effort", {
|
|
553
|
+
description: "Standing workflow effort: off | high | ultra",
|
|
554
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
555
|
+
const level = args.trim().toLowerCase();
|
|
556
|
+
if (level !== "off" && level !== "high" && level !== "ultra") {
|
|
557
|
+
ctx.ui.notify(`Effort is currently "${effortLevel}". Usage: /effort off|high|ultra`, "info");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
effortLevel = level;
|
|
561
|
+
workflowSettings = { ...workflowSettings, effort: effortLevel };
|
|
562
|
+
try {
|
|
563
|
+
saveWorkflowSettings(workflowSettings, sessionCwd);
|
|
564
|
+
} catch {
|
|
565
|
+
/* warning is non-fatal */
|
|
566
|
+
}
|
|
567
|
+
ctx.ui.notify(
|
|
568
|
+
level === "off"
|
|
569
|
+
? "Effort off — messages are no longer auto-armed."
|
|
570
|
+
: `Effort ${level} enabled for substantive messages.`,
|
|
571
|
+
"info",
|
|
572
|
+
);
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
pi.registerCommand("ultracode", {
|
|
576
|
+
description: "Ultracode: toggle exhaustive workflow effort",
|
|
577
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
578
|
+
effortLevel = args.trim().toLowerCase() === "off" ? "off" : "ultra";
|
|
579
|
+
workflowSettings = { ...workflowSettings, effort: effortLevel };
|
|
580
|
+
try {
|
|
581
|
+
saveWorkflowSettings(workflowSettings, sessionCwd);
|
|
582
|
+
} catch {
|
|
583
|
+
/* warning is non-fatal */
|
|
584
|
+
}
|
|
585
|
+
ctx.ui.notify(
|
|
586
|
+
effortLevel === "off"
|
|
587
|
+
? "Ultracode off."
|
|
588
|
+
: "Ultracode on — substantive messages use exhaustive workflow guidance.",
|
|
589
|
+
"info",
|
|
590
|
+
);
|
|
185
591
|
},
|
|
186
592
|
});
|
|
187
593
|
|
|
188
|
-
|
|
594
|
+
// Builtin workflow commands (A6). Saved workflows with the same name take
|
|
595
|
+
// precedence at resolution; the commands themselves still dispatch by name.
|
|
596
|
+
// Names already claimed by another extension are left alone — Pi cannot
|
|
597
|
+
// unregister or replace a command, so clobbering one is permanent.
|
|
598
|
+
const claimedCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
|
|
599
|
+
for (const name of Object.keys(BUILTIN_WORKFLOWS)) {
|
|
600
|
+
if (claimedCommands.has(name)) continue;
|
|
601
|
+
const descriptor = BUILTIN_WORKFLOWS[name];
|
|
602
|
+
pi.registerCommand(name, {
|
|
603
|
+
description: `Run the ${name} workflow`,
|
|
604
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
605
|
+
const text = args.trim();
|
|
606
|
+
// Each script reads its own named input; none reads a generic
|
|
607
|
+
// `prompt`, so the text goes in under `primaryArg`. `code-review`
|
|
608
|
+
// takes a diff rather than something typed, so it resolves one first.
|
|
609
|
+
let scriptArgs: Record<string, unknown> | undefined;
|
|
610
|
+
if (name === "code-review") {
|
|
611
|
+
const scope = resolveCodeReviewScope(text, runCommand);
|
|
612
|
+
for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
|
|
613
|
+
if (!scope.diff.trim()) return;
|
|
614
|
+
scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
|
|
615
|
+
} else {
|
|
616
|
+
scriptArgs = text ? { [descriptor.primaryArg]: text } : undefined;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
validateBuiltinArgs(name, scriptArgs);
|
|
620
|
+
} catch (error: unknown) {
|
|
621
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const options: ScriptStartOptions = {
|
|
625
|
+
args: scriptArgs,
|
|
626
|
+
background: true,
|
|
627
|
+
toolset: descriptor.toolset,
|
|
628
|
+
excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
|
|
629
|
+
loadSavedWorkflow: (savedName) =>
|
|
630
|
+
loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script,
|
|
631
|
+
};
|
|
632
|
+
const result = await workflowEngine.start(descriptor.script, options);
|
|
633
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
634
|
+
ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Live progress widget (A10): renders only while runs are active, then
|
|
640
|
+
// clears itself. The widget is TUI-only; headless sessions skip it.
|
|
641
|
+
const refreshWidget = () => {
|
|
642
|
+
const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
|
|
643
|
+
if (!tui?.setWidget) return;
|
|
644
|
+
const runs = workflowEngine
|
|
645
|
+
.list()
|
|
646
|
+
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
647
|
+
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
648
|
+
const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
|
|
649
|
+
tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
|
|
650
|
+
if (lines.length > 0 && !widgetTimer) {
|
|
651
|
+
widgetTimer = setInterval(() => {
|
|
652
|
+
if (!tui?.setWidget) return;
|
|
653
|
+
const current = workflowEngine
|
|
654
|
+
.list()
|
|
655
|
+
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
656
|
+
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
657
|
+
const next = liveWidgetLines(current, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
|
|
658
|
+
tui.setWidget("pi-workflows", next.length > 0 ? next : undefined);
|
|
659
|
+
if (next.length === 0 && widgetTimer) {
|
|
660
|
+
clearInterval(widgetTimer);
|
|
661
|
+
widgetTimer = undefined;
|
|
662
|
+
}
|
|
663
|
+
}, 2_000);
|
|
664
|
+
} else if (lines.length === 0 && widgetTimer) {
|
|
665
|
+
clearInterval(widgetTimer);
|
|
666
|
+
widgetTimer = undefined;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
// Refresh after any control action and on run settlement.
|
|
670
|
+
const notifyRunChanged = () => refreshWidget();
|
|
671
|
+
workflowEngine.onRunSettled = notifyRunChanged;
|
|
672
|
+
void notifyRunChanged;
|
|
673
|
+
|
|
674
|
+
const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
|
|
189
675
|
if (!branchQuiesce) {
|
|
190
676
|
branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
|
|
191
677
|
if (!result.settled) {
|
|
@@ -211,18 +697,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
211
697
|
try {
|
|
212
698
|
entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
213
699
|
} catch {
|
|
214
|
-
// A reload may invalidate the old lifecycle context before the tree
|
|
215
|
-
// callback is delivered. Do not let stale-context access escape.
|
|
216
700
|
return;
|
|
217
701
|
}
|
|
218
702
|
workflowEngine.suspendLifecycle();
|
|
219
|
-
branchGeneration += 1;
|
|
220
703
|
try {
|
|
221
|
-
workflowEngine.restore(entries
|
|
704
|
+
workflowEngine.restore(entries);
|
|
222
705
|
} catch (error: unknown) {
|
|
223
|
-
// Keep the lifecycle surface alive after a failed recovery append. The
|
|
224
|
-
// engine rolls its candidate state back transactionally; a later tree
|
|
225
|
-
// event can retry the same branch without leaving workflows inert.
|
|
226
706
|
console.warn(
|
|
227
707
|
`[pi-workflows] session tree recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
228
708
|
);
|
|
@@ -233,20 +713,102 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
233
713
|
});
|
|
234
714
|
};
|
|
235
715
|
|
|
716
|
+
const rememberPendingDelivery = (runId: string, text: string, timestamp = Date.now()): void => {
|
|
717
|
+
if (!runId || timestamp + PENDING_DELIVERY_TTL_MS < Date.now()) return;
|
|
718
|
+
pendingDeliveryMarkers.set(runId, { text: text.slice(0, 8_000), timestamp });
|
|
719
|
+
for (const [id, marker] of pendingDeliveryMarkers) {
|
|
720
|
+
if (marker.timestamp + PENDING_DELIVERY_TTL_MS < Date.now()) pendingDeliveryMarkers.delete(id);
|
|
721
|
+
}
|
|
722
|
+
while (pendingDeliveryMarkers.size > MAX_PENDING_DELIVERIES) {
|
|
723
|
+
const oldest = [...pendingDeliveryMarkers.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0]?.[0];
|
|
724
|
+
if (!oldest) break;
|
|
725
|
+
pendingDeliveryMarkers.delete(oldest);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
/** Background-run result delivery: fail-closed, session-bound (A10). */
|
|
730
|
+
const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
|
|
731
|
+
const run = await engine.waitFor(runId).catch(() => undefined);
|
|
732
|
+
if (!run) return;
|
|
733
|
+
const header = `Workflow ${run.runId} ${run.status}`;
|
|
734
|
+
const body = run.error ?? `completed with ${Object.keys(run.callResults).length} agent call(s).`;
|
|
735
|
+
const text = `${header}: ${body}`;
|
|
736
|
+
if (deliverResult) {
|
|
737
|
+
try {
|
|
738
|
+
deliverResult(text);
|
|
739
|
+
} catch {
|
|
740
|
+
// Endpoint suspended or send failed — leave pending for next bind (fail-closed).
|
|
741
|
+
rememberPendingDelivery(runId, text);
|
|
742
|
+
try {
|
|
743
|
+
pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
|
|
744
|
+
} catch {
|
|
745
|
+
// appendEntry is best-effort; in-memory marker still ensures flush on next bind
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
// No bound session endpoint — fail-closed: persist a pending marker and
|
|
750
|
+
// flush it when a session binds, never fall back to shared pi.sendMessage.
|
|
751
|
+
rememberPendingDelivery(runId, text);
|
|
752
|
+
try {
|
|
753
|
+
pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
|
|
754
|
+
} catch {
|
|
755
|
+
// best-effort persistence; in-memory marker still ensures retry
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
const flushPendingDeliveries = (): void => {
|
|
761
|
+
if (!deliverResult || pendingDeliveryMarkers.size === 0) return;
|
|
762
|
+
for (const [runId, pending] of [...pendingDeliveryMarkers]) {
|
|
763
|
+
try {
|
|
764
|
+
deliverResult(pending.text);
|
|
765
|
+
pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, { runId, timestamp: Date.now() });
|
|
766
|
+
pendingDeliveryMarkers.delete(runId);
|
|
767
|
+
} catch {
|
|
768
|
+
// Keep pending until both delivery and its durable acknowledgement succeed.
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
/** Build a script from a natural-language prompt and run it in background. */
|
|
774
|
+
const runWorkflowFromPrompt = async (ctx: ExtensionCommandContext, prompt: string): Promise<string | undefined> => {
|
|
775
|
+
// A bounded, deterministic script that dispatches the user's instruction
|
|
776
|
+
// through a general-purpose agent and returns its answer.
|
|
777
|
+
const script = `export const meta = { name: "ad-hoc", description: "Run a user-requested task with a general-purpose agent" };
|
|
778
|
+
const answer = await agent(${JSON.stringify(prompt)});
|
|
779
|
+
return answer;`;
|
|
780
|
+
try {
|
|
781
|
+
const result = await currentEngine().start(script, { background: true, mainModel: ctx.model?.id });
|
|
782
|
+
void deliverBackgroundResult(currentEngine(), result.runId);
|
|
783
|
+
return result.runId;
|
|
784
|
+
} catch (error: unknown) {
|
|
785
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const saveWorkflowFromRun = async (ctx: ExtensionCommandContext, name: string, runId?: string): Promise<void> => {
|
|
791
|
+
const state = engine?.getState(runId ?? "");
|
|
792
|
+
if (!state) {
|
|
793
|
+
throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
|
|
794
|
+
}
|
|
795
|
+
saveWorkflow(name, state.run.script, sessionCwd);
|
|
796
|
+
ctx.ui.notify(`Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
|
|
797
|
+
};
|
|
798
|
+
|
|
236
799
|
pi.on("session_start", async (_event, ctx) => {
|
|
237
800
|
if (active || engine) return;
|
|
238
801
|
const generation = ++lifecycleGeneration;
|
|
802
|
+
sessionCwd = ctx.cwd ?? process.cwd();
|
|
803
|
+
workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
804
|
+
effortLevel = workflowSettings.effort;
|
|
239
805
|
let initialEntries: SessionEntryLike[];
|
|
240
806
|
try {
|
|
241
807
|
initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
242
808
|
} catch {
|
|
243
|
-
// The lifecycle context may already be invalid during a reload race.
|
|
244
809
|
return;
|
|
245
810
|
}
|
|
246
811
|
|
|
247
|
-
// Dispatch the formal child-context query synchronously. The event bus
|
|
248
|
-
// delivers the reply synchronously, which lets us filter child sessions
|
|
249
|
-
// before registering any Pi tools while keeping startup nonblocking.
|
|
250
812
|
let child: boolean | undefined;
|
|
251
813
|
try {
|
|
252
814
|
child = queryChildSessionContextImmediate(pi.events);
|
|
@@ -256,27 +818,31 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
256
818
|
if (child === true) return;
|
|
257
819
|
|
|
258
820
|
protocolAbortController = new AbortController();
|
|
259
|
-
// Tool-call signals only cancel the foreground wait. Managed RPCs use this
|
|
260
|
-
// session-lifetime controller so a host with no ctx.signal still cancels
|
|
261
|
-
// outstanding requests during session_shutdown.
|
|
262
821
|
const client = createManagedSpawnClient(pi.events, protocolAbortController.signal, protocolAbortController.signal);
|
|
263
822
|
|
|
264
|
-
// Recovery appends can fail transiently (for example while Pi is flushing a
|
|
265
|
-
// branch). Build and restore a disposable candidate, retrying against the
|
|
266
|
-
// latest branch snapshot before exposing the workflow surface. Never leave
|
|
267
|
-
// `engine` assigned while restore is half-complete.
|
|
268
823
|
let recoveredEngine: WorkflowEngine | undefined;
|
|
269
824
|
let recoveryError: unknown;
|
|
270
825
|
for (let attempt = 0; attempt < 3 && !recoveredEngine; attempt += 1) {
|
|
271
|
-
const candidate = new WorkflowEngine(
|
|
272
|
-
|
|
273
|
-
|
|
826
|
+
const candidate = new WorkflowEngine(
|
|
827
|
+
pi.events,
|
|
828
|
+
client,
|
|
829
|
+
{
|
|
830
|
+
append(event) {
|
|
831
|
+
pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
|
|
832
|
+
},
|
|
274
833
|
},
|
|
275
|
-
|
|
834
|
+
() => {
|
|
835
|
+
try {
|
|
836
|
+
return (ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]) ?? [];
|
|
837
|
+
} catch {
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
);
|
|
276
842
|
try {
|
|
277
843
|
const entries =
|
|
278
844
|
attempt === 0 ? initialEntries : (ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
|
|
279
|
-
candidate.restore(entries
|
|
845
|
+
candidate.restore(entries);
|
|
280
846
|
recoveredEngine = candidate;
|
|
281
847
|
} catch (error: unknown) {
|
|
282
848
|
recoveryError = error;
|
|
@@ -294,35 +860,116 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
294
860
|
}
|
|
295
861
|
|
|
296
862
|
engine = recoveredEngine;
|
|
297
|
-
branchGeneration = 0;
|
|
298
863
|
registerSurface();
|
|
299
864
|
|
|
865
|
+
// Bind per-session delivery endpoint (A10): capture the session-bound send
|
|
866
|
+
// at this moment so completions deliver to the originating session, not
|
|
867
|
+
// whichever session happens to be current later. Never fall back to the
|
|
868
|
+
// shared pi.sendMessage — a missing or suspended endpoint leaves the
|
|
869
|
+
// result pending and flushes on the next successful bind (fail-closed).
|
|
870
|
+
try {
|
|
871
|
+
const capturedSend = pi.sendMessage;
|
|
872
|
+
if (typeof capturedSend === "function") {
|
|
873
|
+
deliverResult = (text: string) => {
|
|
874
|
+
// Use the captured function value for session affinity; if the
|
|
875
|
+
// captured function is itself a shared accessor, the flush is still
|
|
876
|
+
// fail-closed (delivery only when bound).
|
|
877
|
+
(capturedSend as unknown as (message: unknown, options: unknown) => unknown).call(
|
|
878
|
+
pi,
|
|
879
|
+
{
|
|
880
|
+
content: text,
|
|
881
|
+
display: true,
|
|
882
|
+
customType: "pi-workflows:delivery",
|
|
883
|
+
},
|
|
884
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
885
|
+
);
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
} catch {
|
|
889
|
+
// binding is best-effort; pending stays buffered
|
|
890
|
+
}
|
|
891
|
+
// Hydrate any pending markers persisted before this bind (e.g. runs that
|
|
892
|
+
// settled before the first session_start) — scan the branch for
|
|
893
|
+
// PENDING_DELIVERY_TYPE entries that have no in-memory counterpart.
|
|
894
|
+
try {
|
|
895
|
+
const acknowledged = new Set<string>();
|
|
896
|
+
for (const entry of initialEntries) {
|
|
897
|
+
const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
898
|
+
if (shaped.type !== "custom" || typeof shaped.customType !== "string") continue;
|
|
899
|
+
const data = shaped.data as Record<string, unknown> | undefined;
|
|
900
|
+
const entryRunId = typeof data?.runId === "string" ? data.runId : undefined;
|
|
901
|
+
if (!entryRunId) continue;
|
|
902
|
+
if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) {
|
|
903
|
+
acknowledged.add(entryRunId);
|
|
904
|
+
} else if (shaped.customType === PENDING_DELIVERY_TYPE) {
|
|
905
|
+
const pendingText = typeof data?.text === "string" ? data.text : undefined;
|
|
906
|
+
const timestamp = typeof data?.timestamp === "number" ? data.timestamp : Date.now();
|
|
907
|
+
if (pendingText && !acknowledged.has(entryRunId)) rememberPendingDelivery(entryRunId, pendingText, timestamp);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
for (const runId of acknowledged) pendingDeliveryMarkers.delete(runId);
|
|
911
|
+
} catch {
|
|
912
|
+
// scan is best-effort
|
|
913
|
+
}
|
|
914
|
+
flushPendingDeliveries();
|
|
915
|
+
|
|
916
|
+
// Saved workflow commands register at session_start (Pi cannot unregister
|
|
917
|
+
// or replace command metadata, so factory-time registration would freeze the
|
|
918
|
+
// source project's description and survive cross-project resumes). Check for
|
|
919
|
+
// name clashes first — never overwrite another extension's command.
|
|
920
|
+
const existingCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
|
|
921
|
+
for (const savedName of listSavedWorkflows(sessionCwd)) {
|
|
922
|
+
if (existingCommands.has(savedName)) continue;
|
|
923
|
+
const script = loadSavedWorkflow(savedName, sessionCwd);
|
|
924
|
+
if (!script) continue;
|
|
925
|
+
pi.registerCommand(savedName, {
|
|
926
|
+
description: `Run the saved workflow ${savedName}`,
|
|
927
|
+
handler: async (args: string, commandCtx: ExtensionCommandContext) => {
|
|
928
|
+
const options: ScriptStartOptions = {
|
|
929
|
+
args: args.trim() ? { prompt: args.trim() } : undefined,
|
|
930
|
+
background: true,
|
|
931
|
+
mainModel: commandCtx?.model?.id,
|
|
932
|
+
loadSavedWorkflow: (name) => loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script,
|
|
933
|
+
};
|
|
934
|
+
const result = await recoveredEngine.start(script, options);
|
|
935
|
+
void deliverBackgroundResult(recoveredEngine, result.runId);
|
|
936
|
+
commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
300
941
|
protocolError = undefined;
|
|
301
942
|
protocolCheck = client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
|
|
302
943
|
void protocolCheck.catch((error: unknown) => {
|
|
303
944
|
if (generation !== lifecycleGeneration) return;
|
|
304
945
|
const detail = error instanceof Error ? error.message : String(error);
|
|
305
|
-
// Missing pi-subagents is a valid independent-package activation state.
|
|
306
|
-
// Keep startup nonblocking and defer the bounded diagnostic to the next
|
|
307
|
-
// workflow tool call instead of logging a spurious startup error.
|
|
308
946
|
protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
|
|
309
947
|
});
|
|
310
948
|
});
|
|
311
949
|
|
|
312
|
-
pi.on("session_shutdown", () => {
|
|
950
|
+
pi.on("session_shutdown", async () => {
|
|
313
951
|
lifecycleGeneration += 1;
|
|
952
|
+
if (widgetTimer) {
|
|
953
|
+
clearInterval(widgetTimer);
|
|
954
|
+
widgetTimer = undefined;
|
|
955
|
+
}
|
|
956
|
+
const closingEngine = engine;
|
|
957
|
+
if (closingEngine) {
|
|
958
|
+
await closingEngine.quiesceForBranchChange().catch(() => undefined);
|
|
959
|
+
}
|
|
314
960
|
protocolAbortController?.abort();
|
|
315
961
|
protocolAbortController = undefined;
|
|
316
962
|
unsubscribeTreeSignal?.();
|
|
317
963
|
unsubscribeTreeSignal = undefined;
|
|
318
|
-
|
|
964
|
+
closingEngine?.dispose();
|
|
319
965
|
engine = undefined;
|
|
320
966
|
active = false;
|
|
321
967
|
branchQuiesce = undefined;
|
|
322
|
-
branchGeneration = 0;
|
|
323
968
|
protocolCheck = Promise.resolve();
|
|
324
969
|
protocolError = undefined;
|
|
970
|
+
deliverResult = undefined;
|
|
325
971
|
});
|
|
326
972
|
}
|
|
327
973
|
|
|
328
|
-
export {
|
|
974
|
+
export type { ScriptStartResult };
|
|
975
|
+
export { WorkflowWaitAbortedError };
|