@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,422 @@
|
|
|
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
|
+
import { join } from "node:path";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { aggregateAgentUsage, fmtCost, fmtTokenSegment, shorten, statusIcon, tokenFigures, } from "./display.js";
|
|
11
|
+
import { shortModel } from "./workflow-ui.js";
|
|
12
|
+
// `tokenUsage` is included so the detailed panel's live token/s counter refreshes
|
|
13
|
+
// as tokens accrue (not only on agent start/end). It is harmless in compact mode —
|
|
14
|
+
// it redraws identical content.
|
|
15
|
+
const RUN_EVENTS = [
|
|
16
|
+
"agentStart",
|
|
17
|
+
"agentEnd",
|
|
18
|
+
"phase",
|
|
19
|
+
"log",
|
|
20
|
+
"tokenUsage",
|
|
21
|
+
"complete",
|
|
22
|
+
"error",
|
|
23
|
+
"stopped",
|
|
24
|
+
"paused",
|
|
25
|
+
"resumed",
|
|
26
|
+
];
|
|
27
|
+
/** Events after which a run is gone and its token-rate samples can be dropped. */
|
|
28
|
+
const RUN_END_EVENTS = ["complete", "error", "stopped"];
|
|
29
|
+
/** Default cap on the JSON-dump fallback in a delivered result summary. Overridable
|
|
30
|
+
* via the `deliveredResultMaxChars` setting in ~/.pi/workflows/settings.json. */
|
|
31
|
+
const DEFAULT_DELIVERED_MAX_CHARS = 400;
|
|
32
|
+
/** Human-readable byte size for the dropped-tail hint: 512 B, 3.2 KB, 1.4 MB. */
|
|
33
|
+
function formatBytes(n) {
|
|
34
|
+
if (n < 1024)
|
|
35
|
+
return `${n} B`;
|
|
36
|
+
if (n < 1024 * 1024)
|
|
37
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
38
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Pick a clean human-readable summary from a workflow result, in order of
|
|
42
|
+
* preference: a `verdict`/`report`/`summary`/`synthesis` string field, a bare
|
|
43
|
+
* string result, else a JSON dump capped at `maxChars`. When the dump is truncated the
|
|
44
|
+
* dropped size is reported (the full result is still reachable via the pointer
|
|
45
|
+
* that {@link deliverText} appends).
|
|
46
|
+
*/
|
|
47
|
+
function summarizeResult(result, maxChars = DEFAULT_DELIVERED_MAX_CHARS) {
|
|
48
|
+
if (typeof result === "string")
|
|
49
|
+
return result;
|
|
50
|
+
if (result == null)
|
|
51
|
+
return "null";
|
|
52
|
+
if (typeof result === "object") {
|
|
53
|
+
const obj = result;
|
|
54
|
+
// `synthesis` is what the built-in multi-perspective workflow returns.
|
|
55
|
+
for (const key of ["verdict", "report", "summary", "synthesis"]) {
|
|
56
|
+
const val = obj[key];
|
|
57
|
+
if (typeof val === "string" && val.trim())
|
|
58
|
+
return val;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const json = JSON.stringify(result, null, 2);
|
|
62
|
+
if (json.length <= maxChars)
|
|
63
|
+
return json;
|
|
64
|
+
// Slice once (the kept head); derive the dropped size by byte-length subtraction
|
|
65
|
+
// so we don't also allocate the (potentially large) truncated tail to measure it.
|
|
66
|
+
const kept = json.slice(0, maxChars);
|
|
67
|
+
const droppedBytes = Buffer.byteLength(json, "utf8") - Buffer.byteLength(kept, "utf8");
|
|
68
|
+
return `${kept}\n…(truncated ${formatBytes(droppedBytes)})`;
|
|
69
|
+
}
|
|
70
|
+
function fitLine(line, width) {
|
|
71
|
+
if (typeof width !== "number" || !Number.isFinite(width))
|
|
72
|
+
return line;
|
|
73
|
+
const maxWidth = Math.max(0, Math.floor(width));
|
|
74
|
+
if (visibleWidth(line) <= maxWidth)
|
|
75
|
+
return line;
|
|
76
|
+
return truncateToWidth(line, maxWidth);
|
|
77
|
+
}
|
|
78
|
+
export function deliverText(run, opts = {}) {
|
|
79
|
+
const summary = summarizeResult(run.result?.result, opts.maxChars);
|
|
80
|
+
const tu = run.result?.tokenUsage;
|
|
81
|
+
const cost = tu?.cost ? ` · ${fmtCost(tu.cost)}` : "";
|
|
82
|
+
const segment = fmtTokenSegment(tokenFigures(tu), fmtTokensShort);
|
|
83
|
+
const tokens = `${segment ? ` · ${segment}` : ""}${cost}`;
|
|
84
|
+
const agents = run.result?.agentCount ?? run.snapshot.agentCount;
|
|
85
|
+
const duration = run.result?.durationMs ? ` · ${(run.result.durationMs / 1000).toFixed(1)}s` : "";
|
|
86
|
+
const lines = [
|
|
87
|
+
`✓ Background workflow "${run.snapshot.name}" finished (${agents} agents${tokens}${duration}).`,
|
|
88
|
+
"",
|
|
89
|
+
summary,
|
|
90
|
+
];
|
|
91
|
+
// Always point at the full persisted result so the tail is never lost — even when
|
|
92
|
+
// the summary above is a complete verdict/summary field or an untruncated dump.
|
|
93
|
+
if (opts.resultPath)
|
|
94
|
+
lines.push("", `↳ Full result: ${opts.resultPath}`);
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
/** Absolute path to a run's persisted result JSON. Undefined if the persistence
|
|
98
|
+
* layer can't be resolved — delivery must never throw in the complete handler. */
|
|
99
|
+
function persistedResultPath(manager, runId) {
|
|
100
|
+
try {
|
|
101
|
+
return join(manager.getPersistence().getRunsDir(), `${runId}.json`);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Delivered JSON-dump truncation threshold from settings (already normalized),
|
|
108
|
+
* defaulting to 400 when unset or unreadable. */
|
|
109
|
+
function deliveredMaxChars(opts) {
|
|
110
|
+
try {
|
|
111
|
+
return opts.loadSettings?.().deliveredResultMaxChars ?? DEFAULT_DELIVERED_MAX_CHARS;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return DEFAULT_DELIVERED_MAX_CHARS;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* When a background run finishes (or fails), deliver its result back into the
|
|
119
|
+
* conversation AND continue the turn so the assistant can act on it — without
|
|
120
|
+
* blocking the user meanwhile:
|
|
121
|
+
*
|
|
122
|
+
* - `triggerTurn: true` starts a fresh turn when the agent is idle, feeding the
|
|
123
|
+
* result to the model so the paused conversation continues.
|
|
124
|
+
* - `deliverAs: "followUp"` means that if the user is busy in another turn, the
|
|
125
|
+
* result is queued and picked up after that turn finishes — never interrupting.
|
|
126
|
+
*
|
|
127
|
+
* Set up once per extension; idempotent via an internal guard.
|
|
128
|
+
*/
|
|
129
|
+
export function installResultDelivery(pi, manager, opts = {}) {
|
|
130
|
+
// Mutable holder on the manager shared by extension generations across /reload.
|
|
131
|
+
const m = manager;
|
|
132
|
+
if (m.__deliveryInstalled) {
|
|
133
|
+
// The manager and listeners survive /reload. Refresh every generation-bound
|
|
134
|
+
// dependency while leaving listener registration exactly-once.
|
|
135
|
+
if (m.__holder) {
|
|
136
|
+
m.__holder.pi = pi;
|
|
137
|
+
m.__holder.loadSettings = opts.loadSettings;
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
m.__deliveryInstalled = true;
|
|
142
|
+
m.__holder = { pi, loadSettings: opts.loadSettings };
|
|
143
|
+
const deliver = (content) => {
|
|
144
|
+
try {
|
|
145
|
+
const ret = m.__holder?.pi.sendMessage({ customType: "workflow-result", content, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
146
|
+
// sendMessage may return a promise; a sync try/catch can't catch its
|
|
147
|
+
// rejection, so swallow the async path too. A stale ctx after /reload is
|
|
148
|
+
// the expected failure — the result is still visible via /workflows.
|
|
149
|
+
void Promise.resolve(ret).catch(() => { });
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Synchronous failure (e.g. stale ctx) — result still visible via /workflows.
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
manager.on("complete", ({ runId }) => {
|
|
156
|
+
const run = manager.getRun(runId);
|
|
157
|
+
// Only background/resumed runs are delivered: a foreground (sync) run already
|
|
158
|
+
// returns its result inline as the tool result, so re-delivering would dup it.
|
|
159
|
+
if (run?.background) {
|
|
160
|
+
deliver(deliverText(run, {
|
|
161
|
+
resultPath: persistedResultPath(manager, runId),
|
|
162
|
+
maxChars: deliveredMaxChars({ loadSettings: m.__holder?.loadSettings }),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
manager.on("error", ({ runId, error }) => {
|
|
167
|
+
if (!manager.getRun(runId)?.background)
|
|
168
|
+
return;
|
|
169
|
+
deliver(`✗ Background workflow ${runId} failed: ${error?.message ?? "unknown error"}`);
|
|
170
|
+
});
|
|
171
|
+
// A provider usage/quota limit checkpoints the run as paused (not failed): tell the
|
|
172
|
+
// user it is resumable once their budget refills, rather than letting it look dead.
|
|
173
|
+
// Manual pause() also emits "paused" but with no reason — guard so only the
|
|
174
|
+
// usage-limit case delivers a message.
|
|
175
|
+
manager.on("paused", ({ runId, reason, error, resetHint, }) => {
|
|
176
|
+
if (reason !== "usage_limit")
|
|
177
|
+
return;
|
|
178
|
+
if (!manager.getRun(runId)?.background)
|
|
179
|
+
return;
|
|
180
|
+
const when = resetHint ? ` (${resetHint})` : "";
|
|
181
|
+
const cause = error?.message ?? "provider usage limit reached";
|
|
182
|
+
deliver(`⏸ Background workflow ${runId} paused: ${cause}${when}. ` +
|
|
183
|
+
`Completed steps are saved — run /workflows resume ${runId} once your usage limit resets.`);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
export function renderPanel(manager, theme, width) {
|
|
187
|
+
const all = manager.listRuns();
|
|
188
|
+
const active = all.filter((r) => r.status === "running" || r.status === "paused");
|
|
189
|
+
if (!active.length)
|
|
190
|
+
return [];
|
|
191
|
+
const rows = active.map((r) => {
|
|
192
|
+
const live = manager.getRun(r.runId);
|
|
193
|
+
const agents = live?.snapshot.agents ?? r.agents;
|
|
194
|
+
const done = agents.filter((a) => a.status === "done").length;
|
|
195
|
+
const icon = r.status === "paused" ? "⏸" : "◆";
|
|
196
|
+
const phase = live?.snapshot.currentPhase ? ` · ${live.snapshot.currentPhase}` : "";
|
|
197
|
+
return ` ${icon} ${r.workflowName} ${done}/${agents.length} agents${phase}`;
|
|
198
|
+
});
|
|
199
|
+
// Finished runs leave this live panel but are kept in the navigator. Tell the
|
|
200
|
+
// user so a completed run doesn't look like it vanished.
|
|
201
|
+
const finished = all.filter((r) => r.status !== "running" && r.status !== "paused").length;
|
|
202
|
+
const hint = theme.fg("dim", finished > 0
|
|
203
|
+
? ` /workflows — open navigator (${finished} finished kept in history)`
|
|
204
|
+
: " /workflows — open navigator");
|
|
205
|
+
return [theme.bold(`Workflows running (${active.length}):`), ...rows, hint].map((line) => fitLine(line, width));
|
|
206
|
+
}
|
|
207
|
+
// ─── Detailed mode: live token rate ────────────────────────────────────────────
|
|
208
|
+
/** Rolling window for the token/s rate. Older samples age out so a stall decays to 0. */
|
|
209
|
+
const RATE_WINDOW_MS = 10_000;
|
|
210
|
+
/** Per-run (timestamp, cumulative total) samples, keyed by the persisted runId so
|
|
211
|
+
* the rolling rate survives pause→resume. Cleared when a run ends. */
|
|
212
|
+
const tokenSamples = new Map();
|
|
213
|
+
/** Record a token-total sample for `runId` at time `now` (ms). */
|
|
214
|
+
export function sampleTokens(runId, total, now) {
|
|
215
|
+
const samples = tokenSamples.get(runId) ?? [];
|
|
216
|
+
const last = samples[samples.length - 1];
|
|
217
|
+
// Collapse repeat renders within the same instant (e.g. width recalcs).
|
|
218
|
+
if (last && last.ts === now && last.total === total)
|
|
219
|
+
return;
|
|
220
|
+
samples.push({ ts: now, total });
|
|
221
|
+
// Drop samples beyond the rolling window, always keeping ≥2 so a rate is computable.
|
|
222
|
+
while (samples.length > 2 && now - samples[0].ts > RATE_WINDOW_MS)
|
|
223
|
+
samples.shift();
|
|
224
|
+
tokenSamples.set(runId, samples);
|
|
225
|
+
}
|
|
226
|
+
/** Tokens/second over the rolling window; 0 when too few samples or totals plateau. */
|
|
227
|
+
export function tokensPerSecond(runId) {
|
|
228
|
+
const samples = tokenSamples.get(runId);
|
|
229
|
+
if (!samples || samples.length < 2)
|
|
230
|
+
return 0;
|
|
231
|
+
const oldest = samples[0];
|
|
232
|
+
const newest = samples[samples.length - 1];
|
|
233
|
+
const elapsedMs = newest.ts - oldest.ts;
|
|
234
|
+
if (elapsedMs <= 0)
|
|
235
|
+
return 0;
|
|
236
|
+
const delta = newest.total - oldest.total;
|
|
237
|
+
if (delta <= 0)
|
|
238
|
+
return 0;
|
|
239
|
+
return (delta / elapsedMs) * 1000;
|
|
240
|
+
}
|
|
241
|
+
/** Forget a run's samples (call when it finishes) so the map can't grow unbounded. */
|
|
242
|
+
export function clearTokenSamples(runId) {
|
|
243
|
+
tokenSamples.delete(runId);
|
|
244
|
+
}
|
|
245
|
+
/** Compact token count for the space-constrained panel: 980, 12.4K, 1.3M. */
|
|
246
|
+
function fmtTokensShort(n) {
|
|
247
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
248
|
+
return "";
|
|
249
|
+
if (n < 1000)
|
|
250
|
+
return `${Math.round(n)}`;
|
|
251
|
+
if (n < 1_000_000)
|
|
252
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
253
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
254
|
+
}
|
|
255
|
+
/** Normalize the configured per-phase agent cap to a sane integer (default 8). */
|
|
256
|
+
export function clampMaxAgents(value) {
|
|
257
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1)
|
|
258
|
+
return 8;
|
|
259
|
+
return Math.min(1000, Math.floor(value));
|
|
260
|
+
}
|
|
261
|
+
/** Per-phase + per-agent body for one run in detailed mode (mirrors renderWorkflowLines). */
|
|
262
|
+
function renderRunBody(snap, agents, maxAgents, theme) {
|
|
263
|
+
const dim = (t) => theme.fg("dim", t);
|
|
264
|
+
const lines = [];
|
|
265
|
+
// Group agents by phase, declared order first then discovery order (as the navigator does).
|
|
266
|
+
const order = snap.phases.length ? [...snap.phases] : [];
|
|
267
|
+
const byPhase = new Map();
|
|
268
|
+
for (const a of agents) {
|
|
269
|
+
const key = a.phase ?? "(no phase)";
|
|
270
|
+
if (!byPhase.has(key))
|
|
271
|
+
byPhase.set(key, []);
|
|
272
|
+
byPhase.get(key)?.push(a);
|
|
273
|
+
if (!order.includes(key))
|
|
274
|
+
order.push(key);
|
|
275
|
+
}
|
|
276
|
+
for (const title of order) {
|
|
277
|
+
const phaseAgents = byPhase.get(title) ?? [];
|
|
278
|
+
if (!phaseAgents.length)
|
|
279
|
+
continue;
|
|
280
|
+
const done = phaseAgents.filter((a) => a.status === "done").length;
|
|
281
|
+
const running = phaseAgents.filter((a) => a.status === "running").length;
|
|
282
|
+
const errors = phaseAgents.filter((a) => a.status === "error").length;
|
|
283
|
+
const skipped = phaseAgents.filter((a) => a.status === "skipped").length;
|
|
284
|
+
const complete = done + errors + skipped === phaseAgents.length;
|
|
285
|
+
const marker = running > 0 || (!complete && snap.currentPhase === title) ? "▶" : complete ? "✓" : " ";
|
|
286
|
+
const phaseMeta = [
|
|
287
|
+
`${done}/${phaseAgents.length} agents`,
|
|
288
|
+
running ? `${running} running` : "",
|
|
289
|
+
errors ? `${errors} errors` : "",
|
|
290
|
+
fmtTokenSegment(aggregateAgentUsage(phaseAgents), fmtTokensShort),
|
|
291
|
+
]
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.join(" · ");
|
|
294
|
+
lines.push(theme.fg("accent", ` ${marker} ${title}`) + dim(` ${phaseMeta}`));
|
|
295
|
+
const visible = phaseAgents.slice(-maxAgents);
|
|
296
|
+
for (const a of visible) {
|
|
297
|
+
const segment = fmtTokenSegment(tokenFigures(a.tokenUsage, a.tokens), fmtTokensShort);
|
|
298
|
+
const tok = segment ? dim(` ${segment}`) : "";
|
|
299
|
+
const mdl = shortModel(a.model);
|
|
300
|
+
const model = mdl ? dim(` · ${mdl}`) : "";
|
|
301
|
+
lines.push(` [${a.id}] ${statusIcon(a.status)} ${shorten(a.label, 40)}${tok}${model}`);
|
|
302
|
+
}
|
|
303
|
+
if (phaseAgents.length > visible.length) {
|
|
304
|
+
lines.push(dim(` … ${phaseAgents.length - visible.length} earlier agents`));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return lines;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Detailed variant of {@link renderPanel}: per-run header with aggregate tokens,
|
|
311
|
+
* cost, and a live token/s rate, followed by per-phase progress and per-agent rows
|
|
312
|
+
* (capped at `maxAgents` per phase). `now` is injected for testability.
|
|
313
|
+
*/
|
|
314
|
+
export function renderPanelDetailed(manager, theme, width, maxAgents, now) {
|
|
315
|
+
const all = manager.listRuns();
|
|
316
|
+
const active = all.filter((r) => r.status === "running" || r.status === "paused");
|
|
317
|
+
if (!active.length)
|
|
318
|
+
return [];
|
|
319
|
+
const dim = (t) => theme.fg("dim", t);
|
|
320
|
+
const out = [theme.bold(`Workflows running (${active.length}):`)];
|
|
321
|
+
for (const r of active) {
|
|
322
|
+
const live = manager.getRun(r.runId);
|
|
323
|
+
const snap = live?.snapshot;
|
|
324
|
+
const agents = (snap?.agents ?? r.agents);
|
|
325
|
+
const done = agents.filter((a) => a.status === "done").length;
|
|
326
|
+
const icon = r.status === "paused" ? "⏸" : "◆";
|
|
327
|
+
const usage = snap?.tokenUsage ?? r.tokenUsage;
|
|
328
|
+
// The run-level tokenUsage aggregate is only finalized when the run ends, so
|
|
329
|
+
// it reads 0 for the whole live run; per-agent figures update on each agent
|
|
330
|
+
// completion, so aggregate those instead. The rate samples the same
|
|
331
|
+
// fresh+cacheRead sum the header displays, so tok/s tracks the visible
|
|
332
|
+
// figures. Tokens land at agent-completion granularity, so the rate reflects
|
|
333
|
+
// completion throughput — it decays to 0 during a single long-running agent
|
|
334
|
+
// or a stall (which is the intended signal). Paused runs don't accrue
|
|
335
|
+
// tokens, so their rate is suppressed (a stalled rate would mislead).
|
|
336
|
+
const runUsage = aggregateAgentUsage(agents);
|
|
337
|
+
sampleTokens(r.runId, runUsage.fresh + runUsage.cacheRead, now);
|
|
338
|
+
const rate = r.status === "running" ? tokensPerSecond(r.runId) : 0;
|
|
339
|
+
const meta = [
|
|
340
|
+
`${done}/${agents.length} agents`,
|
|
341
|
+
snap?.currentPhase || "",
|
|
342
|
+
fmtTokenSegment(runUsage, fmtTokensShort),
|
|
343
|
+
// (cost is only known once the run finalizes its usage.)
|
|
344
|
+
usage?.cost ? fmtCost(usage.cost) : "",
|
|
345
|
+
rate > 0 ? `${Math.round(rate)} tok/s` : "",
|
|
346
|
+
]
|
|
347
|
+
.filter(Boolean)
|
|
348
|
+
.join(" · ");
|
|
349
|
+
out.push(` ${icon} ${theme.bold(r.workflowName)} ${dim(meta)}`);
|
|
350
|
+
if (snap)
|
|
351
|
+
out.push(...renderRunBody(snap, agents, maxAgents, theme));
|
|
352
|
+
}
|
|
353
|
+
const finished = all.filter((r) => r.status !== "running" && r.status !== "paused").length;
|
|
354
|
+
out.push(dim(finished > 0
|
|
355
|
+
? ` /workflows — open navigator (${finished} finished kept in history)`
|
|
356
|
+
: " /workflows — open navigator"));
|
|
357
|
+
return out.map((line) => fitLine(line, width));
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Install the live "workflows running" panel below the editor. Re-rendered on
|
|
361
|
+
* every manager event. Informational only — the user opens the navigator with
|
|
362
|
+
* /workflows. (`_pi` is kept for signature stability.)
|
|
363
|
+
*/
|
|
364
|
+
export function installTaskPanel(_pi, manager, ui, opts = {}) {
|
|
365
|
+
// Live-read settings with a ~1s TTL: a render-path disk read every frame would
|
|
366
|
+
// be wasteful, but re-reading at most once a second still makes
|
|
367
|
+
// /workflows-progress take effect "immediately" (no restart).
|
|
368
|
+
let cached = {};
|
|
369
|
+
let cachedAt = Number.NEGATIVE_INFINITY;
|
|
370
|
+
const settings = () => {
|
|
371
|
+
if (!opts.loadSettings)
|
|
372
|
+
return cached;
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
if (now - cachedAt > 1000) {
|
|
375
|
+
try {
|
|
376
|
+
cached = opts.loadSettings() ?? {};
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
cached = {};
|
|
380
|
+
}
|
|
381
|
+
cachedAt = now;
|
|
382
|
+
}
|
|
383
|
+
return cached;
|
|
384
|
+
};
|
|
385
|
+
const hasActiveRun = () => manager.listRuns().some((r) => r.status === "running" || r.status === "paused");
|
|
386
|
+
ui.setWidget("workflow-tasks", (tui, theme) => {
|
|
387
|
+
const onEvent = () => tui.requestRender();
|
|
388
|
+
for (const ev of RUN_EVENTS)
|
|
389
|
+
manager.on(ev, onEvent);
|
|
390
|
+
const onRunEnd = ({ runId }) => clearTokenSamples(runId);
|
|
391
|
+
for (const ev of RUN_END_EVENTS)
|
|
392
|
+
manager.on(ev, onRunEnd);
|
|
393
|
+
// In detailed mode, force a redraw every 2s while a run is active so the
|
|
394
|
+
// token/s rate keeps updating between sparse token events — and decays to 0
|
|
395
|
+
// when an agent stalls. Gated + unref'd so it costs nothing when idle.
|
|
396
|
+
const timer = setInterval(() => {
|
|
397
|
+
if (settings().progressPanelMode === "detailed" && hasActiveRun())
|
|
398
|
+
tui.requestRender();
|
|
399
|
+
}, 2000);
|
|
400
|
+
timer.unref?.();
|
|
401
|
+
// Purely informational: it lists running runs and re-renders on events. To
|
|
402
|
+
// open the navigator, the user runs /workflows (the panel takes no input).
|
|
403
|
+
const comp = {
|
|
404
|
+
render: (width) => {
|
|
405
|
+
const s = settings();
|
|
406
|
+
if (s.progressPanelMode === "detailed") {
|
|
407
|
+
return renderPanelDetailed(manager, theme, width, clampMaxAgents(s.progressPanelMaxAgents), Date.now());
|
|
408
|
+
}
|
|
409
|
+
return renderPanel(manager, theme, width);
|
|
410
|
+
},
|
|
411
|
+
invalidate: () => { },
|
|
412
|
+
dispose: () => {
|
|
413
|
+
clearInterval(timer);
|
|
414
|
+
for (const ev of RUN_EVENTS)
|
|
415
|
+
manager.off(ev, onEvent);
|
|
416
|
+
for (const ev of RUN_END_EVENTS)
|
|
417
|
+
manager.off(ev, onRunEnd);
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
return comp;
|
|
421
|
+
}, { placement: "belowEditor" });
|
|
422
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-resume for runs paused on a provider usage limit.
|
|
3
|
+
*
|
|
4
|
+
* A workflow run pauses (does not fail) when a provider quota/usage limit is hit
|
|
5
|
+
* (see errors.ts PROVIDER_USAGE_LIMIT, workflow-manager.ts executeRun()'s catch
|
|
6
|
+
* block). Left alone, the run just sits there until a human runs /workflows and
|
|
7
|
+
* hits resume. This module watches the manager's public event stream and, for
|
|
8
|
+
* runs that are auto-resume-eligible, arms a timer to call manager.resume() once
|
|
9
|
+
* the provider's quota is likely to have refilled — with exponential backoff if
|
|
10
|
+
* it keeps hitting the wall, and a hard attempt cap so it never retries forever.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately standalone: it consumes ONLY WorkflowManager's public surface
|
|
13
|
+
* (on/off, listAllRuns, resume, getPersistence) so it stays decoupled from
|
|
14
|
+
* manager/persistence internals. It owns its own timers and its own bookkeeping
|
|
15
|
+
* (in-memory, best-effort persisted) — it does not rely on manager.stop(), which
|
|
16
|
+
* only operates on in-memory runs.
|
|
17
|
+
*/
|
|
18
|
+
import type { PersistedRunState, RunPersistence } from "./run-persistence.js";
|
|
19
|
+
/** Narrow surface this scheduler depends on — satisfied by WorkflowManager. */
|
|
20
|
+
export interface SchedulableWorkflowManager {
|
|
21
|
+
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
22
|
+
off(event: string, listener: (...args: any[]) => void): unknown;
|
|
23
|
+
listAllRuns(): PersistedRunState[];
|
|
24
|
+
resume(runId: string): Promise<boolean>;
|
|
25
|
+
getPersistence(): RunPersistence;
|
|
26
|
+
}
|
|
27
|
+
/** Opaque timer handle so tests can inject a fake clock/timer. */
|
|
28
|
+
export type TimerHandle = unknown;
|
|
29
|
+
export interface UsageLimitSchedulerOptions {
|
|
30
|
+
/** Injectable clock (default Date.now). */
|
|
31
|
+
now?: () => number;
|
|
32
|
+
/** Injectable timer scheduler (default setTimeout). */
|
|
33
|
+
setTimer?: (fn: () => void, ms: number) => TimerHandle;
|
|
34
|
+
/** Injectable timer canceller (default clearTimeout). */
|
|
35
|
+
clearTimer?: (handle: TimerHandle) => void;
|
|
36
|
+
/** Max auto-resume attempts per pause-cycle before giving up. Default 5. */
|
|
37
|
+
maxAttempts?: number;
|
|
38
|
+
/** Delay floor — never arm sooner than this. Default 60_000 (1m). */
|
|
39
|
+
minDelayMs?: number;
|
|
40
|
+
/** Delay used when the provider's resetHint can't be parsed. Default 300_000 (5m). */
|
|
41
|
+
fallbackDelayMs?: number;
|
|
42
|
+
/** Delay ceiling — backoff is clamped here. Default 6h. */
|
|
43
|
+
maxDelayMs?: number;
|
|
44
|
+
/** Diagnostics sink; defaults to console.warn. Never throws back into the caller. */
|
|
45
|
+
onDiagnostic?: (message: string, detail?: unknown) => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Best-effort parse of a provider's human reset hint ("Resets in ~3h",
|
|
49
|
+
* "resets in 5m", "in 90s", "1h30m") into milliseconds. Sums every
|
|
50
|
+
* (number, unit) pair found, so combined forms like "1h30m" work for free.
|
|
51
|
+
* Returns undefined when nothing recognizable is found — callers should fall
|
|
52
|
+
* back to a fixed delay rather than guess.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseResetHintMs(hint?: string): number | undefined;
|
|
55
|
+
export interface AutoResumeDelayParams {
|
|
56
|
+
/** The provider's verbatim reset hint for this pause, if any. */
|
|
57
|
+
resetHint?: string;
|
|
58
|
+
/** 1-indexed attempt number for the pause currently being armed. */
|
|
59
|
+
attempts: number;
|
|
60
|
+
/** Milliseconds already elapsed since the pause began (0 for a live pause). */
|
|
61
|
+
elapsedMs: number;
|
|
62
|
+
minDelayMs: number;
|
|
63
|
+
fallbackDelayMs: number;
|
|
64
|
+
maxDelayMs: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* delay = clamp(minDelayMs, remaining * 2^(attempts-1), maxDelayMs), where
|
|
68
|
+
* remaining = parsed(resetHint) ?? fallbackDelayMs, minus time already elapsed.
|
|
69
|
+
* The exponent is capped defensively so a pathological attempt count can't
|
|
70
|
+
* overflow the multiplication to Infinity/NaN before the maxDelayMs clamp runs.
|
|
71
|
+
*/
|
|
72
|
+
export declare function computeAutoResumeDelayMs(params: AutoResumeDelayParams): number;
|
|
73
|
+
/**
|
|
74
|
+
* Watches a WorkflowManager for usage-limit pauses and auto-resumes eligible
|
|
75
|
+
* runs once the provider's quota is likely to have refilled.
|
|
76
|
+
*
|
|
77
|
+
* Event-driven "fire and watch": an attempt is consumed when a run ENTERS a
|
|
78
|
+
* usage_limit pause (live via the "paused" event, or once at cold start for a
|
|
79
|
+
* run that was already paused), never when a resume is merely fired. When an
|
|
80
|
+
* armed timer fires, resume() is called; if it returns false (lease busy, run
|
|
81
|
+
* already gone, etc.) no attempt is consumed and a short un-backed-off retry is
|
|
82
|
+
* armed instead, unless the run has reached a terminal state on disk. If resume()
|
|
83
|
+
* returns true, this scheduler steps back — the existing "paused" subscription
|
|
84
|
+
* re-arms with backoff if the run hits the wall again, and "complete"/"error"/
|
|
85
|
+
* "stopped" clean up its timer.
|
|
86
|
+
*/
|
|
87
|
+
export declare class UsageLimitScheduler {
|
|
88
|
+
private readonly manager;
|
|
89
|
+
private readonly now;
|
|
90
|
+
private readonly setTimer;
|
|
91
|
+
private readonly clearTimer;
|
|
92
|
+
private readonly maxAttempts;
|
|
93
|
+
private readonly minDelayMs;
|
|
94
|
+
private readonly fallbackDelayMs;
|
|
95
|
+
private readonly maxDelayMs;
|
|
96
|
+
private readonly diagnostic;
|
|
97
|
+
private readonly state;
|
|
98
|
+
private disposed;
|
|
99
|
+
/**
|
|
100
|
+
* Runs this scheduler is currently auto-resuming (its own timer fired). Used to
|
|
101
|
+
* tell an auto-resume's "resumed" event apart from a manual one: an auto-resume
|
|
102
|
+
* must keep the backoff counter (it IS the backoff), a manual resume resets it.
|
|
103
|
+
*/
|
|
104
|
+
private readonly autoResumingRunIds;
|
|
105
|
+
private readonly onPaused;
|
|
106
|
+
private readonly onTerminal;
|
|
107
|
+
private readonly onResumed;
|
|
108
|
+
constructor(manager: SchedulableWorkflowManager, options?: UsageLimitSchedulerOptions);
|
|
109
|
+
/** Clear every armed timer and unsubscribe from the manager. Idempotent. */
|
|
110
|
+
dispose(): void;
|
|
111
|
+
/** Test/diagnostic helper: in-memory attempt count tracked for a run, if any. */
|
|
112
|
+
getAttemptCount(runId: string): number | undefined;
|
|
113
|
+
/** Test/diagnostic helper: whether a resume timer is currently armed for a run. */
|
|
114
|
+
hasArmedTimer(runId: string): boolean;
|
|
115
|
+
private handlePaused;
|
|
116
|
+
private cleanup;
|
|
117
|
+
/**
|
|
118
|
+
* A run was resumed. If WE resumed it (auto-resume timer fired), leave the
|
|
119
|
+
* backoff counter alone — that's the sequence doing its job, and it must still
|
|
120
|
+
* be able to reach the cap. If a human resumed it (via /workflows), treat that
|
|
121
|
+
* as a deliberate fresh start: drop the in-memory given-up state and reset the
|
|
122
|
+
* persisted counter so a later pause re-enters the normal backoff from attempt 1
|
|
123
|
+
* instead of staying silently given-up forever.
|
|
124
|
+
*/
|
|
125
|
+
private handleResumed;
|
|
126
|
+
private coldStartRearm;
|
|
127
|
+
private arm;
|
|
128
|
+
private onTimerFire;
|
|
129
|
+
private safeLoad;
|
|
130
|
+
private safeStatus;
|
|
131
|
+
/**
|
|
132
|
+
* Best-effort persist of the in-memory attempt counter, so a cold start after
|
|
133
|
+
* a crash can approximately resume the backoff sequence instead of restarting
|
|
134
|
+
* it. Deferred to a microtask so it lands AFTER the manager's own persistRun()
|
|
135
|
+
* write for this same pause (which happens synchronously, right after the
|
|
136
|
+
* "paused" event we're reacting to returns control to executeRun()) — writing
|
|
137
|
+
* synchronously here would just get clobbered, since persistRun() writes a
|
|
138
|
+
* fresh PersistedRunState object literal that doesn't know about this field.
|
|
139
|
+
* This is still inherently racy across process crashes (see class docs); it
|
|
140
|
+
* is a best-effort durability aid, not a correctness requirement for the live
|
|
141
|
+
* (in-memory) path.
|
|
142
|
+
*/
|
|
143
|
+
private persistAttempts;
|
|
144
|
+
private safe;
|
|
145
|
+
}
|