@quintinshaw/pi-dynamic-workflows 1.6.0 → 1.7.1
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 +4 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/saved-commands.d.ts +16 -0
- package/dist/saved-commands.js +72 -0
- package/dist/workflow-commands.d.ts +8 -1
- package/dist/workflow-commands.js +94 -4
- package/extensions/workflow.ts +3 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/saved-commands.ts +71 -0
- package/src/workflow-commands.ts +101 -4
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Ask for a background workflow (the model passes `background: true`) and it runs
|
|
|
52
52
|
|
|
53
53
|
```text
|
|
54
54
|
/workflows # list runs (default)
|
|
55
|
-
/workflows status <id> #
|
|
55
|
+
/workflows status <id> # watch a running run live (status bar), prints result when done
|
|
56
56
|
/workflows stop <id> # abort a running run
|
|
57
57
|
/workflows pause <id> # pause a running run
|
|
58
58
|
/workflows resume <id> # resume an interrupted run (replays cached results)
|
|
@@ -68,6 +68,8 @@ Ask for a background workflow (the model passes `background: true`) and it runs
|
|
|
68
68
|
|
|
69
69
|
`/deep-research` fans out web searches across several angles, fetches the top sources with real `web_search` / `web_fetch` tools, keeps only claims supported by multiple sources, and writes a cited report.
|
|
70
70
|
|
|
71
|
+
Save any run as a reusable command: `/workflows save <name>` writes the most recent run's script to `.pi/workflows/saved/<name>.json`, and it immediately becomes `/<name>` (arguments parsed as `key=value` + positionals into `args`).
|
|
72
|
+
|
|
71
73
|
## Workflow script shape
|
|
72
74
|
|
|
73
75
|
A workflow is plain JavaScript. The first statement must export literal metadata:
|
|
@@ -146,6 +148,7 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
146
148
|
- **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
|
|
147
149
|
- **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
|
|
148
150
|
- **Bundled `/deep-research` & `/adversarial-review`** — `/deep-research` runs real web searches (via built-in `web_search` / `web_fetch` tools), extracts claims, cross-checks them across sources, and reports only what survived; `/adversarial-review` investigates a task then has independent skeptics try to refute each finding, keeping only those that clear an agreement threshold
|
|
151
|
+
- **Saved workflows as `/<name>`** — save a run's script with `/workflows save <name>` and it becomes a reusable slash command; arguments are parsed (`key=value` and positionals) and passed through as `args`
|
|
149
152
|
- **Resume** — each agent result is journaled by a deterministic call index; resuming replays the unchanged prefix from cache (no re-run, no tokens) and runs only new or edited calls live
|
|
150
153
|
- **Worktree isolation** — `isolation: "worktree"` runs an agent in its own git worktree on a throwaway branch, so parallel agents can edit the same files without conflict; the worktree is torn down after (results are not auto-merged), and it falls back to a logged no-op outside a git repo
|
|
151
154
|
- **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
|
|
@@ -156,7 +159,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
156
159
|
|
|
157
160
|
Tracked toward closer parity with Claude Code dynamic workflows:
|
|
158
161
|
|
|
159
|
-
- **Saved workflows** as `/<name>` slash commands
|
|
160
162
|
- **Nested `workflow()`** to compose saved workflows inline
|
|
161
163
|
|
|
162
164
|
## How it works
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type { ModelRoute, ModelRoutingConfig } from "./model-routing.js";
|
|
|
17
17
|
export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
18
18
|
export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persistence.js";
|
|
19
19
|
export { createRunPersistence, generateRunId } from "./run-persistence.js";
|
|
20
|
+
export { parseCommandArgs, registerAllSavedWorkflows, registerSavedWorkflow, } from "./saved-commands.js";
|
|
20
21
|
export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
|
|
21
22
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
22
23
|
export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { isAbortError, isTimeoutError, isWorkflowError, WorkflowError, WorkflowE
|
|
|
9
9
|
export { createWorkflowLogger } from "./logger.js";
|
|
10
10
|
export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
11
11
|
export { createRunPersistence, generateRunId } from "./run-persistence.js";
|
|
12
|
+
export { parseCommandArgs, registerAllSavedWorkflows, registerSavedWorkflow, } from "./saved-commands.js";
|
|
12
13
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
13
14
|
export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
|
|
14
15
|
export { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved workflows as `/<name>` slash commands. Each saved workflow becomes a
|
|
3
|
+
* command that runs its script, passing parsed arguments through as `args`.
|
|
4
|
+
*/
|
|
5
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
|
7
|
+
/**
|
|
8
|
+
* Parse a command argument string into an `args` object for the script.
|
|
9
|
+
* Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
|
|
10
|
+
* Declared parameter defaults fill in missing keys.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseCommandArgs(raw: string, parameters?: SavedWorkflow["parameters"]): Record<string, unknown>;
|
|
13
|
+
/** Register one saved workflow as a `/<name>` command (idempotent). */
|
|
14
|
+
export declare function registerSavedWorkflow(pi: ExtensionAPI, cwd: string, wf: SavedWorkflow): void;
|
|
15
|
+
/** Register every saved workflow found in storage. */
|
|
16
|
+
export declare function registerAllSavedWorkflows(pi: ExtensionAPI, cwd: string, storage: WorkflowStorage): void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved workflows as `/<name>` slash commands. Each saved workflow becomes a
|
|
3
|
+
* command that runs its script, passing parsed arguments through as `args`.
|
|
4
|
+
*/
|
|
5
|
+
import { createCodingTools } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { runWorkflow } from "./workflow.js";
|
|
7
|
+
function isRegistered(pi, name) {
|
|
8
|
+
try {
|
|
9
|
+
return (pi.getCommands?.() ?? []).some((c) => c.name === name);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function reportText(result) {
|
|
16
|
+
const r = result.result;
|
|
17
|
+
if (r && typeof r.report === "string" && r.report.trim())
|
|
18
|
+
return r.report;
|
|
19
|
+
return JSON.stringify(result.result, null, 2);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse a command argument string into an `args` object for the script.
|
|
23
|
+
* Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
|
|
24
|
+
* Declared parameter defaults fill in missing keys.
|
|
25
|
+
*/
|
|
26
|
+
export function parseCommandArgs(raw, parameters) {
|
|
27
|
+
const out = {};
|
|
28
|
+
const positional = [];
|
|
29
|
+
for (const tok of raw.trim().split(/\s+/).filter(Boolean)) {
|
|
30
|
+
const eq = tok.indexOf("=");
|
|
31
|
+
if (eq > 0)
|
|
32
|
+
out[tok.slice(0, eq)] = tok.slice(eq + 1);
|
|
33
|
+
else
|
|
34
|
+
positional.push(tok);
|
|
35
|
+
}
|
|
36
|
+
out._ = positional.join(" ");
|
|
37
|
+
out._raw = raw.trim();
|
|
38
|
+
for (const [key, spec] of Object.entries(parameters ?? {})) {
|
|
39
|
+
if (out[key] === undefined && spec.default !== undefined)
|
|
40
|
+
out[key] = spec.default;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** Register one saved workflow as a `/<name>` command (idempotent). */
|
|
45
|
+
export function registerSavedWorkflow(pi, cwd, wf) {
|
|
46
|
+
if (isRegistered(pi, wf.name))
|
|
47
|
+
return;
|
|
48
|
+
pi.registerCommand(wf.name, {
|
|
49
|
+
description: wf.description || `Saved workflow: ${wf.name}`,
|
|
50
|
+
async handler(args, ctx) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await runWorkflow(wf.script, {
|
|
53
|
+
cwd,
|
|
54
|
+
args: parseCommandArgs(args, wf.parameters),
|
|
55
|
+
tools: createCodingTools(cwd),
|
|
56
|
+
onPhase: (title) => ctx.ui.setStatus(`wf:${wf.name}`, `${wf.name}: ${title}`),
|
|
57
|
+
});
|
|
58
|
+
ctx.ui.setStatus(`wf:${wf.name}`, undefined);
|
|
59
|
+
await pi.sendMessage({ customType: `workflow:${wf.name}`, content: reportText(result), display: true });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
ctx.ui.setStatus(`wf:${wf.name}`, undefined);
|
|
63
|
+
ctx.ui.notify(`/${wf.name} failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Register every saved workflow found in storage. */
|
|
69
|
+
export function registerAllSavedWorkflows(pi, cwd, storage) {
|
|
70
|
+
for (const wf of storage.list())
|
|
71
|
+
registerSavedWorkflow(pi, cwd, wf);
|
|
72
|
+
}
|
|
@@ -4,5 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { WorkflowManager } from "./workflow-manager.js";
|
|
7
|
+
import type { WorkflowStorage } from "./workflow-saved.js";
|
|
8
|
+
export interface WorkflowCommandOptions {
|
|
9
|
+
/** Saved-workflow storage, enabling `/workflows save`. */
|
|
10
|
+
storage?: WorkflowStorage;
|
|
11
|
+
/** Working directory for saved workflows registered via `save`. */
|
|
12
|
+
cwd?: string;
|
|
13
|
+
}
|
|
7
14
|
/** Register the `/workflows` command against the shared manager. Idempotent. */
|
|
8
|
-
export declare function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void;
|
|
15
|
+
export declare function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager, opts?: WorkflowCommandOptions): void;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* `/workflows` slash command: list, inspect, and control background workflow runs.
|
|
3
3
|
* Shares the extension's single WorkflowManager so background runs are reachable.
|
|
4
4
|
*/
|
|
5
|
-
import { renderWorkflowText } from "./display.js";
|
|
5
|
+
import { recomputeWorkflowSnapshot, renderWorkflowText } from "./display.js";
|
|
6
|
+
import { registerSavedWorkflow } from "./saved-commands.js";
|
|
6
7
|
const STATUS_ICON = {
|
|
7
8
|
pending: "·",
|
|
8
9
|
running: "◆",
|
|
@@ -11,7 +12,7 @@ const STATUS_ICON = {
|
|
|
11
12
|
failed: "✗",
|
|
12
13
|
aborted: "⊘",
|
|
13
14
|
};
|
|
14
|
-
const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
|
|
15
|
+
const USAGE = "Usage: /workflows [list] | status <id> | watch <id> | stop <id> | pause <id> | resume <id> | rm <id> | save <name> [runId]";
|
|
15
16
|
function summarizeRun(run) {
|
|
16
17
|
const icon = STATUS_ICON[run.status] ?? "?";
|
|
17
18
|
const done = run.agents.filter((a) => a.status === "done").length;
|
|
@@ -19,6 +20,64 @@ function summarizeRun(run) {
|
|
|
19
20
|
const tokens = run.tokenUsage ? ` · ${run.tokenUsage.total.toLocaleString()} tok` : "";
|
|
20
21
|
return `${icon} ${run.runId} ${run.workflowName} [${run.status}] ${done}/${total} agents${tokens}`;
|
|
21
22
|
}
|
|
23
|
+
function oneLineProgress(snapshot) {
|
|
24
|
+
const total = snapshot.agents.length;
|
|
25
|
+
const done = snapshot.agents.filter((a) => a.status === "done").length;
|
|
26
|
+
const running = snapshot.agents.filter((a) => a.status === "running").length;
|
|
27
|
+
const errs = snapshot.agents.filter((a) => a.status === "error").length;
|
|
28
|
+
const phase = snapshot.currentPhase ? ` · ${snapshot.currentPhase}` : "";
|
|
29
|
+
return `◆ ${snapshot.name}: ${done}/${total} done${running ? `, ${running} running` : ""}${errs ? `, ${errs} err` : ""}${phase}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Subscribe to a running run's events and stream live progress to the status bar,
|
|
33
|
+
* printing the final snapshot when it finishes. Non-blocking: returns true if the
|
|
34
|
+
* run was active and is now being watched, false otherwise. Listeners clean up on
|
|
35
|
+
* completion so nothing leaks.
|
|
36
|
+
*/
|
|
37
|
+
function watchRun(manager, pi, ctx, id) {
|
|
38
|
+
const active = manager.getRun(id);
|
|
39
|
+
if (!active || active.status !== "running")
|
|
40
|
+
return false;
|
|
41
|
+
const key = `wf:${id}`;
|
|
42
|
+
const update = () => {
|
|
43
|
+
const run = manager.getRun(id);
|
|
44
|
+
if (run)
|
|
45
|
+
ctx.ui.setStatus(key, oneLineProgress(run.snapshot));
|
|
46
|
+
};
|
|
47
|
+
const onEvent = (e) => {
|
|
48
|
+
if (!e || e.runId === id)
|
|
49
|
+
update();
|
|
50
|
+
};
|
|
51
|
+
let settled = false;
|
|
52
|
+
const progressEvents = ["agentStart", "agentEnd", "phase", "log"];
|
|
53
|
+
const finalEvents = ["complete", "error", "stopped", "paused"];
|
|
54
|
+
const finish = (e) => {
|
|
55
|
+
if (e && e.runId !== id)
|
|
56
|
+
return;
|
|
57
|
+
if (settled)
|
|
58
|
+
return;
|
|
59
|
+
settled = true;
|
|
60
|
+
for (const ev of progressEvents)
|
|
61
|
+
manager.off(ev, onEvent);
|
|
62
|
+
for (const ev of finalEvents)
|
|
63
|
+
manager.off(ev, finish);
|
|
64
|
+
ctx.ui.setStatus(key, undefined);
|
|
65
|
+
const run = manager.getRun(id);
|
|
66
|
+
if (run) {
|
|
67
|
+
void pi.sendMessage({
|
|
68
|
+
customType: "workflows",
|
|
69
|
+
content: renderWorkflowText(recomputeWorkflowSnapshot(run.snapshot), true),
|
|
70
|
+
display: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
for (const ev of progressEvents)
|
|
75
|
+
manager.on(ev, onEvent);
|
|
76
|
+
for (const ev of finalEvents)
|
|
77
|
+
manager.on(ev, finish);
|
|
78
|
+
update();
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
22
81
|
function renderPersistedStatus(run) {
|
|
23
82
|
const lines = [`${STATUS_ICON[run.status] ?? "?"} ${run.workflowName} (${run.runId}) — ${run.status}`];
|
|
24
83
|
if (run.currentPhase)
|
|
@@ -34,7 +93,7 @@ function renderPersistedStatus(run) {
|
|
|
34
93
|
return lines.join("\n");
|
|
35
94
|
}
|
|
36
95
|
/** Register the `/workflows` command against the shared manager. Idempotent. */
|
|
37
|
-
export function registerWorkflowCommands(pi, manager) {
|
|
96
|
+
export function registerWorkflowCommands(pi, manager, opts = {}) {
|
|
38
97
|
try {
|
|
39
98
|
const taken = (pi.getCommands?.() ?? []).some((c) => c.name === "workflows");
|
|
40
99
|
if (taken)
|
|
@@ -60,14 +119,21 @@ export function registerWorkflowCommands(pi, manager) {
|
|
|
60
119
|
await print(["Workflow runs:", ...runs.map(summarizeRun), "", USAGE].join("\n"));
|
|
61
120
|
return;
|
|
62
121
|
}
|
|
122
|
+
case "watch":
|
|
63
123
|
case "status": {
|
|
64
124
|
if (!id) {
|
|
65
125
|
ctx.ui.notify(USAGE, "warning");
|
|
66
126
|
return;
|
|
67
127
|
}
|
|
128
|
+
// A running run streams live progress to the status bar and prints the
|
|
129
|
+
// final snapshot when it finishes — no need to re-run the command.
|
|
130
|
+
if (watchRun(manager, pi, ctx, id)) {
|
|
131
|
+
ctx.ui.notify(`Watching ${id} — live progress in the status bar; result prints when it finishes.`, "info");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
68
134
|
const live = manager.getSnapshot(id);
|
|
69
135
|
if (live) {
|
|
70
|
-
await print(renderWorkflowText(live, false));
|
|
136
|
+
await print(renderWorkflowText(recomputeWorkflowSnapshot(live), false));
|
|
71
137
|
return;
|
|
72
138
|
}
|
|
73
139
|
const run = manager.listRuns().find((r) => r.runId === id);
|
|
@@ -103,6 +169,30 @@ export function registerWorkflowCommands(pi, manager) {
|
|
|
103
169
|
ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
|
|
104
170
|
return;
|
|
105
171
|
}
|
|
172
|
+
case "save": {
|
|
173
|
+
const name = id;
|
|
174
|
+
if (!name)
|
|
175
|
+
return ctx.ui.notify("Usage: /workflows save <name> [runId]", "warning");
|
|
176
|
+
if (!opts.storage)
|
|
177
|
+
return ctx.ui.notify("Saving is not available (no storage configured)", "error");
|
|
178
|
+
const runs = manager.listRuns();
|
|
179
|
+
const runIdArg = parts[2];
|
|
180
|
+
// Pick the named run, else the most recent run that still has its script.
|
|
181
|
+
const run = runIdArg ? runs.find((r) => r.runId === runIdArg) : runs.find((r) => r.script);
|
|
182
|
+
if (!run?.script) {
|
|
183
|
+
ctx.ui.notify(runIdArg ? `No run ${runIdArg} with a script` : "No saved run to save", "error");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const saved = opts.storage.save({
|
|
187
|
+
name,
|
|
188
|
+
description: run.workflowName,
|
|
189
|
+
script: run.script,
|
|
190
|
+
location: "project",
|
|
191
|
+
});
|
|
192
|
+
registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved);
|
|
193
|
+
ctx.ui.notify(`Saved /${name} (from ${run.runId})`, "info");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
106
196
|
default:
|
|
107
197
|
ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
|
|
108
198
|
}
|
package/extensions/workflow.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import {
|
|
3
3
|
createWorkflowStorage,
|
|
4
4
|
createWorkflowTool,
|
|
5
|
+
registerAllSavedWorkflows,
|
|
5
6
|
registerBuiltinWorkflows,
|
|
6
7
|
registerWorkflowCommands,
|
|
7
8
|
WorkflowManager,
|
|
@@ -16,8 +17,9 @@ export default function extension(pi: ExtensionAPI) {
|
|
|
16
17
|
|
|
17
18
|
const workflowTool = createWorkflowTool({ cwd, manager, storage });
|
|
18
19
|
pi.registerTool(workflowTool);
|
|
19
|
-
registerWorkflowCommands(pi, manager);
|
|
20
|
+
registerWorkflowCommands(pi, manager, { storage, cwd });
|
|
20
21
|
registerBuiltinWorkflows(pi, { cwd });
|
|
22
|
+
registerAllSavedWorkflows(pi, cwd, storage);
|
|
21
23
|
|
|
22
24
|
pi.on("session_start", () => {
|
|
23
25
|
const active = pi.getActiveTools();
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -38,6 +38,11 @@ export type { ModelRoute, ModelRoutingConfig } from "./model-routing.js";
|
|
|
38
38
|
export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
39
39
|
export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persistence.js";
|
|
40
40
|
export { createRunPersistence, generateRunId } from "./run-persistence.js";
|
|
41
|
+
export {
|
|
42
|
+
parseCommandArgs,
|
|
43
|
+
registerAllSavedWorkflows,
|
|
44
|
+
registerSavedWorkflow,
|
|
45
|
+
} from "./saved-commands.js";
|
|
41
46
|
export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
|
|
42
47
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
43
48
|
export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved workflows as `/<name>` slash commands. Each saved workflow becomes a
|
|
3
|
+
* command that runs its script, passing parsed arguments through as `args`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createCodingTools, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
8
|
+
import type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
|
9
|
+
|
|
10
|
+
function isRegistered(pi: ExtensionAPI, name: string): boolean {
|
|
11
|
+
try {
|
|
12
|
+
return (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === name);
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function reportText(result: WorkflowRunResult): string {
|
|
19
|
+
const r = result.result as { report?: unknown } | undefined;
|
|
20
|
+
if (r && typeof r.report === "string" && r.report.trim()) return r.report;
|
|
21
|
+
return JSON.stringify(result.result, null, 2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a command argument string into an `args` object for the script.
|
|
26
|
+
* Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
|
|
27
|
+
* Declared parameter defaults fill in missing keys.
|
|
28
|
+
*/
|
|
29
|
+
export function parseCommandArgs(raw: string, parameters?: SavedWorkflow["parameters"]): Record<string, unknown> {
|
|
30
|
+
const out: Record<string, unknown> = {};
|
|
31
|
+
const positional: string[] = [];
|
|
32
|
+
for (const tok of raw.trim().split(/\s+/).filter(Boolean)) {
|
|
33
|
+
const eq = tok.indexOf("=");
|
|
34
|
+
if (eq > 0) out[tok.slice(0, eq)] = tok.slice(eq + 1);
|
|
35
|
+
else positional.push(tok);
|
|
36
|
+
}
|
|
37
|
+
out._ = positional.join(" ");
|
|
38
|
+
out._raw = raw.trim();
|
|
39
|
+
for (const [key, spec] of Object.entries(parameters ?? {})) {
|
|
40
|
+
if (out[key] === undefined && spec.default !== undefined) out[key] = spec.default;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Register one saved workflow as a `/<name>` command (idempotent). */
|
|
46
|
+
export function registerSavedWorkflow(pi: ExtensionAPI, cwd: string, wf: SavedWorkflow): void {
|
|
47
|
+
if (isRegistered(pi, wf.name)) return;
|
|
48
|
+
pi.registerCommand(wf.name, {
|
|
49
|
+
description: wf.description || `Saved workflow: ${wf.name}`,
|
|
50
|
+
async handler(args: string, ctx: ExtensionCommandContext) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await runWorkflow(wf.script, {
|
|
53
|
+
cwd,
|
|
54
|
+
args: parseCommandArgs(args, wf.parameters),
|
|
55
|
+
tools: createCodingTools(cwd),
|
|
56
|
+
onPhase: (title) => ctx.ui.setStatus(`wf:${wf.name}`, `${wf.name}: ${title}`),
|
|
57
|
+
});
|
|
58
|
+
ctx.ui.setStatus(`wf:${wf.name}`, undefined);
|
|
59
|
+
await pi.sendMessage({ customType: `workflow:${wf.name}`, content: reportText(result), display: true });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
ctx.ui.setStatus(`wf:${wf.name}`, undefined);
|
|
62
|
+
ctx.ui.notify(`/${wf.name} failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Register every saved workflow found in storage. */
|
|
69
|
+
export function registerAllSavedWorkflows(pi: ExtensionAPI, cwd: string, storage: WorkflowStorage): void {
|
|
70
|
+
for (const wf of storage.list()) registerSavedWorkflow(pi, cwd, wf);
|
|
71
|
+
}
|
package/src/workflow-commands.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { renderWorkflowText } from "./display.js";
|
|
7
|
+
import { recomputeWorkflowSnapshot, renderWorkflowText, type WorkflowSnapshot } from "./display.js";
|
|
8
8
|
import type { PersistedRunState } from "./run-persistence.js";
|
|
9
|
+
import { registerSavedWorkflow } from "./saved-commands.js";
|
|
9
10
|
import type { WorkflowManager } from "./workflow-manager.js";
|
|
11
|
+
import type { WorkflowStorage } from "./workflow-saved.js";
|
|
10
12
|
|
|
11
13
|
const STATUS_ICON: Record<string, string> = {
|
|
12
14
|
pending: "·",
|
|
@@ -17,7 +19,8 @@ const STATUS_ICON: Record<string, string> = {
|
|
|
17
19
|
aborted: "⊘",
|
|
18
20
|
};
|
|
19
21
|
|
|
20
|
-
const USAGE =
|
|
22
|
+
const USAGE =
|
|
23
|
+
"Usage: /workflows [list] | status <id> | watch <id> | stop <id> | pause <id> | resume <id> | rm <id> | save <name> [runId]";
|
|
21
24
|
|
|
22
25
|
function summarizeRun(run: PersistedRunState): string {
|
|
23
26
|
const icon = STATUS_ICON[run.status] ?? "?";
|
|
@@ -27,6 +30,60 @@ function summarizeRun(run: PersistedRunState): string {
|
|
|
27
30
|
return `${icon} ${run.runId} ${run.workflowName} [${run.status}] ${done}/${total} agents${tokens}`;
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
function oneLineProgress(snapshot: WorkflowSnapshot): string {
|
|
34
|
+
const total = snapshot.agents.length;
|
|
35
|
+
const done = snapshot.agents.filter((a) => a.status === "done").length;
|
|
36
|
+
const running = snapshot.agents.filter((a) => a.status === "running").length;
|
|
37
|
+
const errs = snapshot.agents.filter((a) => a.status === "error").length;
|
|
38
|
+
const phase = snapshot.currentPhase ? ` · ${snapshot.currentPhase}` : "";
|
|
39
|
+
return `◆ ${snapshot.name}: ${done}/${total} done${running ? `, ${running} running` : ""}${
|
|
40
|
+
errs ? `, ${errs} err` : ""
|
|
41
|
+
}${phase}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Subscribe to a running run's events and stream live progress to the status bar,
|
|
46
|
+
* printing the final snapshot when it finishes. Non-blocking: returns true if the
|
|
47
|
+
* run was active and is now being watched, false otherwise. Listeners clean up on
|
|
48
|
+
* completion so nothing leaks.
|
|
49
|
+
*/
|
|
50
|
+
function watchRun(manager: WorkflowManager, pi: ExtensionAPI, ctx: ExtensionCommandContext, id: string): boolean {
|
|
51
|
+
const active = manager.getRun(id);
|
|
52
|
+
if (!active || active.status !== "running") return false;
|
|
53
|
+
|
|
54
|
+
const key = `wf:${id}`;
|
|
55
|
+
const update = () => {
|
|
56
|
+
const run = manager.getRun(id);
|
|
57
|
+
if (run) ctx.ui.setStatus(key, oneLineProgress(run.snapshot));
|
|
58
|
+
};
|
|
59
|
+
const onEvent = (e: { runId?: string }) => {
|
|
60
|
+
if (!e || e.runId === id) update();
|
|
61
|
+
};
|
|
62
|
+
let settled = false;
|
|
63
|
+
const progressEvents = ["agentStart", "agentEnd", "phase", "log"];
|
|
64
|
+
const finalEvents = ["complete", "error", "stopped", "paused"];
|
|
65
|
+
const finish = (e: { runId?: string }) => {
|
|
66
|
+
if (e && e.runId !== id) return;
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
for (const ev of progressEvents) manager.off(ev, onEvent);
|
|
70
|
+
for (const ev of finalEvents) manager.off(ev, finish);
|
|
71
|
+
ctx.ui.setStatus(key, undefined);
|
|
72
|
+
const run = manager.getRun(id);
|
|
73
|
+
if (run) {
|
|
74
|
+
void pi.sendMessage({
|
|
75
|
+
customType: "workflows",
|
|
76
|
+
content: renderWorkflowText(recomputeWorkflowSnapshot(run.snapshot), true),
|
|
77
|
+
display: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
for (const ev of progressEvents) manager.on(ev, onEvent);
|
|
82
|
+
for (const ev of finalEvents) manager.on(ev, finish);
|
|
83
|
+
update();
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
30
87
|
function renderPersistedStatus(run: PersistedRunState): string {
|
|
31
88
|
const lines = [`${STATUS_ICON[run.status] ?? "?"} ${run.workflowName} (${run.runId}) — ${run.status}`];
|
|
32
89
|
if (run.currentPhase) lines.push(` phase: ${run.currentPhase}`);
|
|
@@ -40,8 +97,19 @@ function renderPersistedStatus(run: PersistedRunState): string {
|
|
|
40
97
|
return lines.join("\n");
|
|
41
98
|
}
|
|
42
99
|
|
|
100
|
+
export interface WorkflowCommandOptions {
|
|
101
|
+
/** Saved-workflow storage, enabling `/workflows save`. */
|
|
102
|
+
storage?: WorkflowStorage;
|
|
103
|
+
/** Working directory for saved workflows registered via `save`. */
|
|
104
|
+
cwd?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
43
107
|
/** Register the `/workflows` command against the shared manager. Idempotent. */
|
|
44
|
-
export function registerWorkflowCommands(
|
|
108
|
+
export function registerWorkflowCommands(
|
|
109
|
+
pi: ExtensionAPI,
|
|
110
|
+
manager: WorkflowManager,
|
|
111
|
+
opts: WorkflowCommandOptions = {},
|
|
112
|
+
): void {
|
|
45
113
|
try {
|
|
46
114
|
const taken = (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === "workflows");
|
|
47
115
|
if (taken) return;
|
|
@@ -67,14 +135,21 @@ export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowMana
|
|
|
67
135
|
await print(["Workflow runs:", ...runs.map(summarizeRun), "", USAGE].join("\n"));
|
|
68
136
|
return;
|
|
69
137
|
}
|
|
138
|
+
case "watch":
|
|
70
139
|
case "status": {
|
|
71
140
|
if (!id) {
|
|
72
141
|
ctx.ui.notify(USAGE, "warning");
|
|
73
142
|
return;
|
|
74
143
|
}
|
|
144
|
+
// A running run streams live progress to the status bar and prints the
|
|
145
|
+
// final snapshot when it finishes — no need to re-run the command.
|
|
146
|
+
if (watchRun(manager, pi, ctx, id)) {
|
|
147
|
+
ctx.ui.notify(`Watching ${id} — live progress in the status bar; result prints when it finishes.`, "info");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
75
150
|
const live = manager.getSnapshot(id);
|
|
76
151
|
if (live) {
|
|
77
|
-
await print(renderWorkflowText(live, false));
|
|
152
|
+
await print(renderWorkflowText(recomputeWorkflowSnapshot(live), false));
|
|
78
153
|
return;
|
|
79
154
|
}
|
|
80
155
|
const run = manager.listRuns().find((r) => r.runId === id);
|
|
@@ -109,6 +184,28 @@ export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowMana
|
|
|
109
184
|
ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
|
|
110
185
|
return;
|
|
111
186
|
}
|
|
187
|
+
case "save": {
|
|
188
|
+
const name = id;
|
|
189
|
+
if (!name) return ctx.ui.notify("Usage: /workflows save <name> [runId]", "warning");
|
|
190
|
+
if (!opts.storage) return ctx.ui.notify("Saving is not available (no storage configured)", "error");
|
|
191
|
+
const runs = manager.listRuns();
|
|
192
|
+
const runIdArg = parts[2];
|
|
193
|
+
// Pick the named run, else the most recent run that still has its script.
|
|
194
|
+
const run = runIdArg ? runs.find((r) => r.runId === runIdArg) : runs.find((r) => r.script);
|
|
195
|
+
if (!run?.script) {
|
|
196
|
+
ctx.ui.notify(runIdArg ? `No run ${runIdArg} with a script` : "No saved run to save", "error");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const saved = opts.storage.save({
|
|
200
|
+
name,
|
|
201
|
+
description: run.workflowName,
|
|
202
|
+
script: run.script,
|
|
203
|
+
location: "project",
|
|
204
|
+
});
|
|
205
|
+
registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved);
|
|
206
|
+
ctx.ui.notify(`Saved /${name} (from ${run.runId})`, "info");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
112
209
|
default:
|
|
113
210
|
ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
|
|
114
211
|
}
|