@yycholla/pi-dynamic-workflows 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +325 -0
- package/assets/readme/hero.png +0 -0
- package/assets/readme/hero.svg +70 -0
- package/assets/readme/package-cover.png +0 -0
- package/assets/readme/package-cover.svg +66 -0
- package/assets/readme/workflow.png +0 -0
- package/assets/readme/workflow.svg +77 -0
- package/dist/accept-workflow-guidance.d.ts +14 -0
- package/dist/accept-workflow-guidance.js +53 -0
- package/dist/adversarial-review.d.ts +30 -0
- package/dist/adversarial-review.js +107 -0
- package/dist/agent-history.d.ts +20 -0
- package/dist/agent-history.js +122 -0
- package/dist/agent-registry.d.ts +83 -0
- package/dist/agent-registry.js +190 -0
- package/dist/agent.d.ts +364 -0
- package/dist/agent.js +714 -0
- package/dist/builtin-commands.d.ts +19 -0
- package/dist/builtin-commands.js +251 -0
- package/dist/builtin-workflows.d.ts +45 -0
- package/dist/builtin-workflows.js +121 -0
- package/dist/code-review.d.ts +26 -0
- package/dist/code-review.js +181 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +44 -0
- package/dist/deep-research.d.ts +30 -0
- package/dist/deep-research.js +124 -0
- package/dist/display.d.ts +134 -0
- package/dist/display.js +248 -0
- package/dist/effort-command.d.ts +28 -0
- package/dist/effort-command.js +68 -0
- package/dist/enums.d.ts +69 -0
- package/dist/enums.js +78 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.js +140 -0
- package/dist/extension-reload.d.ts +37 -0
- package/dist/extension-reload.js +78 -0
- package/dist/fs-persistence.d.ts +63 -0
- package/dist/fs-persistence.js +102 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +35 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +66 -0
- package/dist/model-routing.d.ts +30 -0
- package/dist/model-routing.js +50 -0
- package/dist/model-spec.d.ts +29 -0
- package/dist/model-spec.js +252 -0
- package/dist/model-tier-config.d.ts +133 -0
- package/dist/model-tier-config.js +249 -0
- package/dist/run-persistence.d.ts +180 -0
- package/dist/run-persistence.js +294 -0
- package/dist/saved-commands.d.ts +28 -0
- package/dist/saved-commands.js +100 -0
- package/dist/shared-store.d.ts +98 -0
- package/dist/shared-store.js +212 -0
- package/dist/structured-output.d.ts +19 -0
- package/dist/structured-output.js +30 -0
- package/dist/task-panel.d.ts +61 -0
- package/dist/task-panel.js +422 -0
- package/dist/usage-limit-scheduler.d.ts +145 -0
- package/dist/usage-limit-scheduler.js +368 -0
- package/dist/web-tools.d.ts +20 -0
- package/dist/web-tools.js +120 -0
- package/dist/workflow-authoring-coverage.d.ts +70 -0
- package/dist/workflow-authoring-coverage.js +421 -0
- package/dist/workflow-authoring-reference.d.ts +20 -0
- package/dist/workflow-authoring-reference.js +156 -0
- package/dist/workflow-capability-contract.d.ts +131 -0
- package/dist/workflow-capability-contract.js +604 -0
- package/dist/workflow-commands.d.ts +18 -0
- package/dist/workflow-commands.js +260 -0
- package/dist/workflow-comprehension.d.ts +133 -0
- package/dist/workflow-comprehension.js +1321 -0
- package/dist/workflow-context-measurement.d.ts +72 -0
- package/dist/workflow-context-measurement.js +213 -0
- package/dist/workflow-control-tool.d.ts +30 -0
- package/dist/workflow-control-tool.js +176 -0
- package/dist/workflow-delivery-choice.d.ts +20 -0
- package/dist/workflow-delivery-choice.js +48 -0
- package/dist/workflow-editor.d.ts +93 -0
- package/dist/workflow-editor.js +363 -0
- package/dist/workflow-manager.d.ts +492 -0
- package/dist/workflow-manager.js +1124 -0
- package/dist/workflow-paths.d.ts +22 -0
- package/dist/workflow-paths.js +46 -0
- package/dist/workflow-release-gate.d.ts +39 -0
- package/dist/workflow-release-gate.js +309 -0
- package/dist/workflow-saved.d.ts +38 -0
- package/dist/workflow-saved.js +126 -0
- package/dist/workflow-settings.d.ts +70 -0
- package/dist/workflow-settings.js +131 -0
- package/dist/workflow-tool.d.ts +71 -0
- package/dist/workflow-tool.js +367 -0
- package/dist/workflow-ui.d.ts +182 -0
- package/dist/workflow-ui.js +1587 -0
- package/dist/workflow.d.ts +333 -0
- package/dist/workflow.js +1151 -0
- package/dist/workflows-models-command.d.ts +31 -0
- package/dist/workflows-models-command.js +156 -0
- package/dist/worktree.d.ts +25 -0
- package/dist/worktree.js +61 -0
- package/extensions/workflow.ts +151 -0
- package/package.json +104 -0
- 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/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 +41 -0
- package/skills/workflow-authoring/references/capability-details.md +357 -0
- package/skills/workflow-authoring/references/common-helpers.md +4 -0
- package/skills/workflow-authoring/references/debugging.md +27 -0
- package/skills/workflow-authoring/references/focused-recipes.md +11 -0
- package/skills/workflow-authoring/references/helpers.md +9 -0
- package/skills/workflow-authoring/references/lifecycle.md +33 -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 +15 -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 +27 -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/accept-workflow-guidance.ts +71 -0
- package/src/adversarial-review.ts +120 -0
- package/src/agent-history.ts +157 -0
- package/src/agent-registry.ts +221 -0
- package/src/agent.ts +929 -0
- package/src/builtin-commands.ts +286 -0
- package/src/builtin-workflows.ts +155 -0
- package/src/code-review.ts +183 -0
- package/src/config.ts +55 -0
- package/src/deep-research.ts +135 -0
- package/src/display.ts +367 -0
- package/src/effort-command.ts +87 -0
- package/src/enums.ts +77 -0
- package/src/errors.ts +199 -0
- package/src/extension-reload.ts +100 -0
- package/src/fs-persistence.ts +124 -0
- package/src/index.ts +176 -0
- package/src/logger.ts +88 -0
- package/src/model-routing.ts +73 -0
- package/src/model-spec.ts +309 -0
- package/src/model-tier-config.ts +296 -0
- package/src/run-persistence.ts +484 -0
- package/src/saved-commands.ts +115 -0
- package/src/shared-store.ts +228 -0
- package/src/structured-output.ts +47 -0
- package/src/task-panel.ts +490 -0
- package/src/usage-limit-scheduler.ts +432 -0
- package/src/web-tools.ts +124 -0
- package/src/workflow-authoring-coverage.ts +486 -0
- package/src/workflow-authoring-reference.ts +186 -0
- package/src/workflow-capability-contract.ts +806 -0
- package/src/workflow-commands.ts +287 -0
- package/src/workflow-comprehension.ts +1673 -0
- package/src/workflow-context-measurement.ts +262 -0
- package/src/workflow-control-tool.ts +238 -0
- package/src/workflow-delivery-choice.ts +69 -0
- package/src/workflow-editor.ts +444 -0
- package/src/workflow-manager.ts +1405 -0
- package/src/workflow-paths.ts +63 -0
- package/src/workflow-release-gate.ts +529 -0
- package/src/workflow-saved.ts +180 -0
- package/src/workflow-settings.ts +194 -0
- package/src/workflow-tool.ts +464 -0
- package/src/workflow-ui.ts +1789 -0
- package/src/workflow.ts +1615 -0
- package/src/workflows-models-command.ts +211 -0
- package/src/worktree.ts +76 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-run UX, mirroring Claude Code:
|
|
3
|
+
* - A live task panel below the input lists in-progress runs while you keep working.
|
|
4
|
+
* It is informational; run /workflows to open the full navigator.
|
|
5
|
+
* - When a background run finishes, its result is delivered back into the
|
|
6
|
+
* conversation so the paused task continues with the outcome.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { type Component, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import {
|
|
13
|
+
aggregateAgentUsage,
|
|
14
|
+
fmtCost,
|
|
15
|
+
fmtTokenSegment,
|
|
16
|
+
shorten,
|
|
17
|
+
statusIcon,
|
|
18
|
+
tokenFigures,
|
|
19
|
+
type WorkflowAgentSnapshot,
|
|
20
|
+
type WorkflowSnapshot,
|
|
21
|
+
} from "./display.js";
|
|
22
|
+
import type { ManagedRun, WorkflowManager } from "./workflow-manager.js";
|
|
23
|
+
import type { WorkflowStorage } from "./workflow-saved.js";
|
|
24
|
+
import type { WorkflowSettings } from "./workflow-settings.js";
|
|
25
|
+
import { shortModel } from "./workflow-ui.js";
|
|
26
|
+
|
|
27
|
+
// `tokenUsage` is included so the detailed panel's live token/s counter refreshes
|
|
28
|
+
// as tokens accrue (not only on agent start/end). It is harmless in compact mode —
|
|
29
|
+
// it redraws identical content.
|
|
30
|
+
const RUN_EVENTS = [
|
|
31
|
+
"agentStart",
|
|
32
|
+
"agentEnd",
|
|
33
|
+
"phase",
|
|
34
|
+
"log",
|
|
35
|
+
"tokenUsage",
|
|
36
|
+
"complete",
|
|
37
|
+
"error",
|
|
38
|
+
"stopped",
|
|
39
|
+
"paused",
|
|
40
|
+
"resumed",
|
|
41
|
+
];
|
|
42
|
+
/** Events after which a run is gone and its token-rate samples can be dropped. */
|
|
43
|
+
const RUN_END_EVENTS = ["complete", "error", "stopped"] as const;
|
|
44
|
+
|
|
45
|
+
export interface TaskPanelOptions {
|
|
46
|
+
storage?: WorkflowStorage;
|
|
47
|
+
cwd?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Live settings loader. When provided, the panel reads it fresh (with a short
|
|
50
|
+
* TTL cache) on each render so `/workflows-progress` takes effect without a
|
|
51
|
+
* restart. Omitted in tests / minimal hosts → always compact.
|
|
52
|
+
*/
|
|
53
|
+
loadSettings?: () => WorkflowSettings;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Default cap on the JSON-dump fallback in a delivered result summary. Overridable
|
|
57
|
+
* via the `deliveredResultMaxChars` setting in ~/.pi/workflows/settings.json. */
|
|
58
|
+
const DEFAULT_DELIVERED_MAX_CHARS = 400;
|
|
59
|
+
|
|
60
|
+
/** Human-readable byte size for the dropped-tail hint: 512 B, 3.2 KB, 1.4 MB. */
|
|
61
|
+
function formatBytes(n: number): string {
|
|
62
|
+
if (n < 1024) return `${n} B`;
|
|
63
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
64
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pick a clean human-readable summary from a workflow result, in order of
|
|
69
|
+
* preference: a `verdict`/`report`/`summary`/`synthesis` string field, a bare
|
|
70
|
+
* string result, else a JSON dump capped at `maxChars`. When the dump is truncated the
|
|
71
|
+
* dropped size is reported (the full result is still reachable via the pointer
|
|
72
|
+
* that {@link deliverText} appends).
|
|
73
|
+
*/
|
|
74
|
+
function summarizeResult(result: unknown, maxChars: number = DEFAULT_DELIVERED_MAX_CHARS): string {
|
|
75
|
+
if (typeof result === "string") return result;
|
|
76
|
+
if (result == null) return "null";
|
|
77
|
+
if (typeof result === "object") {
|
|
78
|
+
const obj = result as Record<string, unknown>;
|
|
79
|
+
// `synthesis` is what the built-in multi-perspective workflow returns.
|
|
80
|
+
for (const key of ["verdict", "report", "summary", "synthesis"] as const) {
|
|
81
|
+
const val = obj[key];
|
|
82
|
+
if (typeof val === "string" && val.trim()) return val;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const json = JSON.stringify(result, null, 2);
|
|
86
|
+
if (json.length <= maxChars) return json;
|
|
87
|
+
// Slice once (the kept head); derive the dropped size by byte-length subtraction
|
|
88
|
+
// so we don't also allocate the (potentially large) truncated tail to measure it.
|
|
89
|
+
const kept = json.slice(0, maxChars);
|
|
90
|
+
const droppedBytes = Buffer.byteLength(json, "utf8") - Buffer.byteLength(kept, "utf8");
|
|
91
|
+
return `${kept}\n…(truncated ${formatBytes(droppedBytes)})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fitLine(line: string, width?: number): string {
|
|
95
|
+
if (typeof width !== "number" || !Number.isFinite(width)) return line;
|
|
96
|
+
const maxWidth = Math.max(0, Math.floor(width));
|
|
97
|
+
if (visibleWidth(line) <= maxWidth) return line;
|
|
98
|
+
return truncateToWidth(line, maxWidth);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function deliverText(run: ManagedRun, opts: { resultPath?: string; maxChars?: number } = {}): string {
|
|
102
|
+
const summary = summarizeResult(run.result?.result, opts.maxChars);
|
|
103
|
+
const tu = run.result?.tokenUsage;
|
|
104
|
+
const cost = tu?.cost ? ` · ${fmtCost(tu.cost)}` : "";
|
|
105
|
+
const segment = fmtTokenSegment(tokenFigures(tu), fmtTokensShort);
|
|
106
|
+
const tokens = `${segment ? ` · ${segment}` : ""}${cost}`;
|
|
107
|
+
const agents = run.result?.agentCount ?? run.snapshot.agentCount;
|
|
108
|
+
const duration = run.result?.durationMs ? ` · ${(run.result.durationMs / 1000).toFixed(1)}s` : "";
|
|
109
|
+
const lines = [
|
|
110
|
+
`✓ Background workflow "${run.snapshot.name}" finished (${agents} agents${tokens}${duration}).`,
|
|
111
|
+
"",
|
|
112
|
+
summary,
|
|
113
|
+
];
|
|
114
|
+
// Always point at the full persisted result so the tail is never lost — even when
|
|
115
|
+
// the summary above is a complete verdict/summary field or an untruncated dump.
|
|
116
|
+
if (opts.resultPath) lines.push("", `↳ Full result: ${opts.resultPath}`);
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Absolute path to a run's persisted result JSON. Undefined if the persistence
|
|
121
|
+
* layer can't be resolved — delivery must never throw in the complete handler. */
|
|
122
|
+
function persistedResultPath(manager: WorkflowManager, runId: string): string | undefined {
|
|
123
|
+
try {
|
|
124
|
+
return join(manager.getPersistence().getRunsDir(), `${runId}.json`);
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Delivered JSON-dump truncation threshold from settings (already normalized),
|
|
131
|
+
* defaulting to 400 when unset or unreadable. */
|
|
132
|
+
function deliveredMaxChars(opts: { loadSettings?: () => WorkflowSettings }): number {
|
|
133
|
+
try {
|
|
134
|
+
return opts.loadSettings?.().deliveredResultMaxChars ?? DEFAULT_DELIVERED_MAX_CHARS;
|
|
135
|
+
} catch {
|
|
136
|
+
return DEFAULT_DELIVERED_MAX_CHARS;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* When a background run finishes (or fails), deliver its result back into the
|
|
142
|
+
* conversation AND continue the turn so the assistant can act on it — without
|
|
143
|
+
* blocking the user meanwhile:
|
|
144
|
+
*
|
|
145
|
+
* - `triggerTurn: true` starts a fresh turn when the agent is idle, feeding the
|
|
146
|
+
* result to the model so the paused conversation continues.
|
|
147
|
+
* - `deliverAs: "followUp"` means that if the user is busy in another turn, the
|
|
148
|
+
* result is queued and picked up after that turn finishes — never interrupting.
|
|
149
|
+
*
|
|
150
|
+
* Set up once per extension; idempotent via an internal guard.
|
|
151
|
+
*/
|
|
152
|
+
export function installResultDelivery(
|
|
153
|
+
pi: ExtensionAPI,
|
|
154
|
+
manager: WorkflowManager,
|
|
155
|
+
opts: { loadSettings?: () => WorkflowSettings } = {},
|
|
156
|
+
): void {
|
|
157
|
+
// Mutable holder on the manager shared by extension generations across /reload.
|
|
158
|
+
const m = manager as unknown as {
|
|
159
|
+
__deliveryInstalled?: boolean;
|
|
160
|
+
__holder?: { pi: ExtensionAPI; loadSettings?: () => WorkflowSettings };
|
|
161
|
+
};
|
|
162
|
+
if (m.__deliveryInstalled) {
|
|
163
|
+
// The manager and listeners survive /reload. Refresh every generation-bound
|
|
164
|
+
// dependency while leaving listener registration exactly-once.
|
|
165
|
+
if (m.__holder) {
|
|
166
|
+
m.__holder.pi = pi;
|
|
167
|
+
m.__holder.loadSettings = opts.loadSettings;
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
m.__deliveryInstalled = true;
|
|
172
|
+
m.__holder = { pi, loadSettings: opts.loadSettings };
|
|
173
|
+
|
|
174
|
+
const deliver = (content: string) => {
|
|
175
|
+
try {
|
|
176
|
+
const ret = m.__holder?.pi.sendMessage(
|
|
177
|
+
{ customType: "workflow-result", content, display: true },
|
|
178
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
179
|
+
);
|
|
180
|
+
// sendMessage may return a promise; a sync try/catch can't catch its
|
|
181
|
+
// rejection, so swallow the async path too. A stale ctx after /reload is
|
|
182
|
+
// the expected failure — the result is still visible via /workflows.
|
|
183
|
+
void Promise.resolve(ret).catch(() => {});
|
|
184
|
+
} catch {
|
|
185
|
+
// Synchronous failure (e.g. stale ctx) — result still visible via /workflows.
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
manager.on("complete", ({ runId }: { runId: string }) => {
|
|
190
|
+
const run = manager.getRun(runId);
|
|
191
|
+
// Only background/resumed runs are delivered: a foreground (sync) run already
|
|
192
|
+
// returns its result inline as the tool result, so re-delivering would dup it.
|
|
193
|
+
if (run?.background) {
|
|
194
|
+
deliver(
|
|
195
|
+
deliverText(run, {
|
|
196
|
+
resultPath: persistedResultPath(manager, runId),
|
|
197
|
+
maxChars: deliveredMaxChars({ loadSettings: m.__holder?.loadSettings }),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
manager.on("error", ({ runId, error }: { runId: string; error?: { message?: string } }) => {
|
|
203
|
+
if (!manager.getRun(runId)?.background) return;
|
|
204
|
+
deliver(`✗ Background workflow ${runId} failed: ${error?.message ?? "unknown error"}`);
|
|
205
|
+
});
|
|
206
|
+
// A provider usage/quota limit checkpoints the run as paused (not failed): tell the
|
|
207
|
+
// user it is resumable once their budget refills, rather than letting it look dead.
|
|
208
|
+
// Manual pause() also emits "paused" but with no reason — guard so only the
|
|
209
|
+
// usage-limit case delivers a message.
|
|
210
|
+
manager.on(
|
|
211
|
+
"paused",
|
|
212
|
+
({
|
|
213
|
+
runId,
|
|
214
|
+
reason,
|
|
215
|
+
error,
|
|
216
|
+
resetHint,
|
|
217
|
+
}: {
|
|
218
|
+
runId: string;
|
|
219
|
+
reason?: string;
|
|
220
|
+
error?: { message?: string };
|
|
221
|
+
resetHint?: string;
|
|
222
|
+
}) => {
|
|
223
|
+
if (reason !== "usage_limit") return;
|
|
224
|
+
if (!manager.getRun(runId)?.background) return;
|
|
225
|
+
const when = resetHint ? ` (${resetHint})` : "";
|
|
226
|
+
const cause = error?.message ?? "provider usage limit reached";
|
|
227
|
+
deliver(
|
|
228
|
+
`⏸ Background workflow ${runId} paused: ${cause}${when}. ` +
|
|
229
|
+
`Completed steps are saved — run /workflows resume ${runId} once your usage limit resets.`,
|
|
230
|
+
);
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function renderPanel(manager: WorkflowManager, theme: Theme, width?: number): string[] {
|
|
236
|
+
const all = manager.listRuns();
|
|
237
|
+
const active = all.filter((r) => r.status === "running" || r.status === "paused");
|
|
238
|
+
if (!active.length) return [];
|
|
239
|
+
const rows = active.map((r) => {
|
|
240
|
+
const live = manager.getRun(r.runId);
|
|
241
|
+
const agents = live?.snapshot.agents ?? r.agents;
|
|
242
|
+
const done = agents.filter((a) => a.status === "done").length;
|
|
243
|
+
const icon = r.status === "paused" ? "⏸" : "◆";
|
|
244
|
+
const phase = live?.snapshot.currentPhase ? ` · ${live.snapshot.currentPhase}` : "";
|
|
245
|
+
return ` ${icon} ${r.workflowName} ${done}/${agents.length} agents${phase}`;
|
|
246
|
+
});
|
|
247
|
+
// Finished runs leave this live panel but are kept in the navigator. Tell the
|
|
248
|
+
// user so a completed run doesn't look like it vanished.
|
|
249
|
+
const finished = all.filter((r) => r.status !== "running" && r.status !== "paused").length;
|
|
250
|
+
const hint = theme.fg(
|
|
251
|
+
"dim",
|
|
252
|
+
finished > 0
|
|
253
|
+
? ` /workflows — open navigator (${finished} finished kept in history)`
|
|
254
|
+
: " /workflows — open navigator",
|
|
255
|
+
);
|
|
256
|
+
return [theme.bold(`Workflows running (${active.length}):`), ...rows, hint].map((line) => fitLine(line, width));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Detailed mode: live token rate ────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/** Rolling window for the token/s rate. Older samples age out so a stall decays to 0. */
|
|
262
|
+
const RATE_WINDOW_MS = 10_000;
|
|
263
|
+
/** Per-run (timestamp, cumulative total) samples, keyed by the persisted runId so
|
|
264
|
+
* the rolling rate survives pause→resume. Cleared when a run ends. */
|
|
265
|
+
const tokenSamples = new Map<string, Array<{ ts: number; total: number }>>();
|
|
266
|
+
|
|
267
|
+
/** Record a token-total sample for `runId` at time `now` (ms). */
|
|
268
|
+
export function sampleTokens(runId: string, total: number, now: number): void {
|
|
269
|
+
const samples = tokenSamples.get(runId) ?? [];
|
|
270
|
+
const last = samples[samples.length - 1];
|
|
271
|
+
// Collapse repeat renders within the same instant (e.g. width recalcs).
|
|
272
|
+
if (last && last.ts === now && last.total === total) return;
|
|
273
|
+
samples.push({ ts: now, total });
|
|
274
|
+
// Drop samples beyond the rolling window, always keeping ≥2 so a rate is computable.
|
|
275
|
+
while (samples.length > 2 && now - samples[0].ts > RATE_WINDOW_MS) samples.shift();
|
|
276
|
+
tokenSamples.set(runId, samples);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Tokens/second over the rolling window; 0 when too few samples or totals plateau. */
|
|
280
|
+
export function tokensPerSecond(runId: string): number {
|
|
281
|
+
const samples = tokenSamples.get(runId);
|
|
282
|
+
if (!samples || samples.length < 2) return 0;
|
|
283
|
+
const oldest = samples[0];
|
|
284
|
+
const newest = samples[samples.length - 1];
|
|
285
|
+
const elapsedMs = newest.ts - oldest.ts;
|
|
286
|
+
if (elapsedMs <= 0) return 0;
|
|
287
|
+
const delta = newest.total - oldest.total;
|
|
288
|
+
if (delta <= 0) return 0;
|
|
289
|
+
return (delta / elapsedMs) * 1000;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Forget a run's samples (call when it finishes) so the map can't grow unbounded. */
|
|
293
|
+
export function clearTokenSamples(runId: string): void {
|
|
294
|
+
tokenSamples.delete(runId);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Compact token count for the space-constrained panel: 980, 12.4K, 1.3M. */
|
|
298
|
+
function fmtTokensShort(n: number): string {
|
|
299
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
300
|
+
if (n < 1000) return `${Math.round(n)}`;
|
|
301
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
|
302
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Normalize the configured per-phase agent cap to a sane integer (default 8). */
|
|
306
|
+
export function clampMaxAgents(value: number | undefined): number {
|
|
307
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 8;
|
|
308
|
+
return Math.min(1000, Math.floor(value));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Per-phase + per-agent body for one run in detailed mode (mirrors renderWorkflowLines). */
|
|
312
|
+
function renderRunBody(
|
|
313
|
+
snap: WorkflowSnapshot,
|
|
314
|
+
agents: WorkflowAgentSnapshot[],
|
|
315
|
+
maxAgents: number,
|
|
316
|
+
theme: Theme,
|
|
317
|
+
): string[] {
|
|
318
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
319
|
+
const lines: string[] = [];
|
|
320
|
+
// Group agents by phase, declared order first then discovery order (as the navigator does).
|
|
321
|
+
const order = snap.phases.length ? [...snap.phases] : [];
|
|
322
|
+
const byPhase = new Map<string, WorkflowAgentSnapshot[]>();
|
|
323
|
+
for (const a of agents) {
|
|
324
|
+
const key = a.phase ?? "(no phase)";
|
|
325
|
+
if (!byPhase.has(key)) byPhase.set(key, []);
|
|
326
|
+
byPhase.get(key)?.push(a);
|
|
327
|
+
if (!order.includes(key)) order.push(key);
|
|
328
|
+
}
|
|
329
|
+
for (const title of order) {
|
|
330
|
+
const phaseAgents = byPhase.get(title) ?? [];
|
|
331
|
+
if (!phaseAgents.length) continue;
|
|
332
|
+
const done = phaseAgents.filter((a) => a.status === "done").length;
|
|
333
|
+
const running = phaseAgents.filter((a) => a.status === "running").length;
|
|
334
|
+
const errors = phaseAgents.filter((a) => a.status === "error").length;
|
|
335
|
+
const skipped = phaseAgents.filter((a) => a.status === "skipped").length;
|
|
336
|
+
const complete = done + errors + skipped === phaseAgents.length;
|
|
337
|
+
const marker = running > 0 || (!complete && snap.currentPhase === title) ? "▶" : complete ? "✓" : " ";
|
|
338
|
+
const phaseMeta = [
|
|
339
|
+
`${done}/${phaseAgents.length} agents`,
|
|
340
|
+
running ? `${running} running` : "",
|
|
341
|
+
errors ? `${errors} errors` : "",
|
|
342
|
+
fmtTokenSegment(aggregateAgentUsage(phaseAgents), fmtTokensShort),
|
|
343
|
+
]
|
|
344
|
+
.filter(Boolean)
|
|
345
|
+
.join(" · ");
|
|
346
|
+
lines.push(theme.fg("accent", ` ${marker} ${title}`) + dim(` ${phaseMeta}`));
|
|
347
|
+
|
|
348
|
+
const visible = phaseAgents.slice(-maxAgents);
|
|
349
|
+
for (const a of visible) {
|
|
350
|
+
const segment = fmtTokenSegment(tokenFigures(a.tokenUsage, a.tokens), fmtTokensShort);
|
|
351
|
+
const tok = segment ? dim(` ${segment}`) : "";
|
|
352
|
+
const mdl = shortModel(a.model);
|
|
353
|
+
const model = mdl ? dim(` · ${mdl}`) : "";
|
|
354
|
+
lines.push(` [${a.id}] ${statusIcon(a.status)} ${shorten(a.label, 40)}${tok}${model}`);
|
|
355
|
+
}
|
|
356
|
+
if (phaseAgents.length > visible.length) {
|
|
357
|
+
lines.push(dim(` … ${phaseAgents.length - visible.length} earlier agents`));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return lines;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Detailed variant of {@link renderPanel}: per-run header with aggregate tokens,
|
|
365
|
+
* cost, and a live token/s rate, followed by per-phase progress and per-agent rows
|
|
366
|
+
* (capped at `maxAgents` per phase). `now` is injected for testability.
|
|
367
|
+
*/
|
|
368
|
+
export function renderPanelDetailed(
|
|
369
|
+
manager: WorkflowManager,
|
|
370
|
+
theme: Theme,
|
|
371
|
+
width: number | undefined,
|
|
372
|
+
maxAgents: number,
|
|
373
|
+
now: number,
|
|
374
|
+
): string[] {
|
|
375
|
+
const all = manager.listRuns();
|
|
376
|
+
const active = all.filter((r) => r.status === "running" || r.status === "paused");
|
|
377
|
+
if (!active.length) return [];
|
|
378
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
379
|
+
const out: string[] = [theme.bold(`Workflows running (${active.length}):`)];
|
|
380
|
+
|
|
381
|
+
for (const r of active) {
|
|
382
|
+
const live = manager.getRun(r.runId);
|
|
383
|
+
const snap = live?.snapshot;
|
|
384
|
+
const agents = (snap?.agents ?? r.agents) as WorkflowAgentSnapshot[];
|
|
385
|
+
const done = agents.filter((a) => a.status === "done").length;
|
|
386
|
+
const icon = r.status === "paused" ? "⏸" : "◆";
|
|
387
|
+
const usage = snap?.tokenUsage ?? r.tokenUsage;
|
|
388
|
+
// The run-level tokenUsage aggregate is only finalized when the run ends, so
|
|
389
|
+
// it reads 0 for the whole live run; per-agent figures update on each agent
|
|
390
|
+
// completion, so aggregate those instead. The rate samples the same
|
|
391
|
+
// fresh+cacheRead sum the header displays, so tok/s tracks the visible
|
|
392
|
+
// figures. Tokens land at agent-completion granularity, so the rate reflects
|
|
393
|
+
// completion throughput — it decays to 0 during a single long-running agent
|
|
394
|
+
// or a stall (which is the intended signal). Paused runs don't accrue
|
|
395
|
+
// tokens, so their rate is suppressed (a stalled rate would mislead).
|
|
396
|
+
const runUsage = aggregateAgentUsage(agents);
|
|
397
|
+
sampleTokens(r.runId, runUsage.fresh + runUsage.cacheRead, now);
|
|
398
|
+
const rate = r.status === "running" ? tokensPerSecond(r.runId) : 0;
|
|
399
|
+
const meta = [
|
|
400
|
+
`${done}/${agents.length} agents`,
|
|
401
|
+
snap?.currentPhase || "",
|
|
402
|
+
fmtTokenSegment(runUsage, fmtTokensShort),
|
|
403
|
+
// (cost is only known once the run finalizes its usage.)
|
|
404
|
+
usage?.cost ? fmtCost(usage.cost) : "",
|
|
405
|
+
rate > 0 ? `${Math.round(rate)} tok/s` : "",
|
|
406
|
+
]
|
|
407
|
+
.filter(Boolean)
|
|
408
|
+
.join(" · ");
|
|
409
|
+
out.push(` ${icon} ${theme.bold(r.workflowName)} ${dim(meta)}`);
|
|
410
|
+
if (snap) out.push(...renderRunBody(snap, agents, maxAgents, theme));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const finished = all.filter((r) => r.status !== "running" && r.status !== "paused").length;
|
|
414
|
+
out.push(
|
|
415
|
+
dim(
|
|
416
|
+
finished > 0
|
|
417
|
+
? ` /workflows — open navigator (${finished} finished kept in history)`
|
|
418
|
+
: " /workflows — open navigator",
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
return out.map((line) => fitLine(line, width));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Install the live "workflows running" panel below the editor. Re-rendered on
|
|
426
|
+
* every manager event. Informational only — the user opens the navigator with
|
|
427
|
+
* /workflows. (`_pi` is kept for signature stability.)
|
|
428
|
+
*/
|
|
429
|
+
export function installTaskPanel(
|
|
430
|
+
_pi: ExtensionAPI,
|
|
431
|
+
manager: WorkflowManager,
|
|
432
|
+
ui: ExtensionUIContext,
|
|
433
|
+
opts: TaskPanelOptions = {},
|
|
434
|
+
): void {
|
|
435
|
+
// Live-read settings with a ~1s TTL: a render-path disk read every frame would
|
|
436
|
+
// be wasteful, but re-reading at most once a second still makes
|
|
437
|
+
// /workflows-progress take effect "immediately" (no restart).
|
|
438
|
+
let cached: WorkflowSettings = {};
|
|
439
|
+
let cachedAt = Number.NEGATIVE_INFINITY;
|
|
440
|
+
const settings = (): WorkflowSettings => {
|
|
441
|
+
if (!opts.loadSettings) return cached;
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
if (now - cachedAt > 1000) {
|
|
444
|
+
try {
|
|
445
|
+
cached = opts.loadSettings() ?? {};
|
|
446
|
+
} catch {
|
|
447
|
+
cached = {};
|
|
448
|
+
}
|
|
449
|
+
cachedAt = now;
|
|
450
|
+
}
|
|
451
|
+
return cached;
|
|
452
|
+
};
|
|
453
|
+
const hasActiveRun = () => manager.listRuns().some((r) => r.status === "running" || r.status === "paused");
|
|
454
|
+
|
|
455
|
+
ui.setWidget(
|
|
456
|
+
"workflow-tasks",
|
|
457
|
+
(tui: TUI, theme: Theme) => {
|
|
458
|
+
const onEvent = () => tui.requestRender();
|
|
459
|
+
for (const ev of RUN_EVENTS) manager.on(ev, onEvent);
|
|
460
|
+
const onRunEnd = ({ runId }: { runId: string }) => clearTokenSamples(runId);
|
|
461
|
+
for (const ev of RUN_END_EVENTS) manager.on(ev, onRunEnd);
|
|
462
|
+
// In detailed mode, force a redraw every 2s while a run is active so the
|
|
463
|
+
// token/s rate keeps updating between sparse token events — and decays to 0
|
|
464
|
+
// when an agent stalls. Gated + unref'd so it costs nothing when idle.
|
|
465
|
+
const timer = setInterval(() => {
|
|
466
|
+
if (settings().progressPanelMode === "detailed" && hasActiveRun()) tui.requestRender();
|
|
467
|
+
}, 2000);
|
|
468
|
+
(timer as { unref?: () => void }).unref?.();
|
|
469
|
+
// Purely informational: it lists running runs and re-renders on events. To
|
|
470
|
+
// open the navigator, the user runs /workflows (the panel takes no input).
|
|
471
|
+
const comp: Component & { dispose?(): void } = {
|
|
472
|
+
render: (width: number) => {
|
|
473
|
+
const s = settings();
|
|
474
|
+
if (s.progressPanelMode === "detailed") {
|
|
475
|
+
return renderPanelDetailed(manager, theme, width, clampMaxAgents(s.progressPanelMaxAgents), Date.now());
|
|
476
|
+
}
|
|
477
|
+
return renderPanel(manager, theme, width);
|
|
478
|
+
},
|
|
479
|
+
invalidate: () => {},
|
|
480
|
+
dispose: () => {
|
|
481
|
+
clearInterval(timer);
|
|
482
|
+
for (const ev of RUN_EVENTS) manager.off(ev, onEvent);
|
|
483
|
+
for (const ev of RUN_END_EVENTS) manager.off(ev, onRunEnd);
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
return comp;
|
|
487
|
+
},
|
|
488
|
+
{ placement: "belowEditor" },
|
|
489
|
+
);
|
|
490
|
+
}
|