@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 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> # show a run's progress
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
+ }
@@ -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 = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
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(pi: ExtensionAPI, manager: WorkflowManager): void {
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
  }