@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.
Files changed (179) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +325 -0
  3. package/assets/readme/hero.png +0 -0
  4. package/assets/readme/hero.svg +70 -0
  5. package/assets/readme/package-cover.png +0 -0
  6. package/assets/readme/package-cover.svg +66 -0
  7. package/assets/readme/workflow.png +0 -0
  8. package/assets/readme/workflow.svg +77 -0
  9. package/dist/accept-workflow-guidance.d.ts +14 -0
  10. package/dist/accept-workflow-guidance.js +53 -0
  11. package/dist/adversarial-review.d.ts +30 -0
  12. package/dist/adversarial-review.js +107 -0
  13. package/dist/agent-history.d.ts +20 -0
  14. package/dist/agent-history.js +122 -0
  15. package/dist/agent-registry.d.ts +83 -0
  16. package/dist/agent-registry.js +190 -0
  17. package/dist/agent.d.ts +364 -0
  18. package/dist/agent.js +714 -0
  19. package/dist/builtin-commands.d.ts +19 -0
  20. package/dist/builtin-commands.js +251 -0
  21. package/dist/builtin-workflows.d.ts +45 -0
  22. package/dist/builtin-workflows.js +121 -0
  23. package/dist/code-review.d.ts +26 -0
  24. package/dist/code-review.js +181 -0
  25. package/dist/config.d.ts +37 -0
  26. package/dist/config.js +44 -0
  27. package/dist/deep-research.d.ts +30 -0
  28. package/dist/deep-research.js +124 -0
  29. package/dist/display.d.ts +134 -0
  30. package/dist/display.js +248 -0
  31. package/dist/effort-command.d.ts +28 -0
  32. package/dist/effort-command.js +68 -0
  33. package/dist/enums.d.ts +69 -0
  34. package/dist/enums.js +78 -0
  35. package/dist/errors.d.ts +113 -0
  36. package/dist/errors.js +140 -0
  37. package/dist/extension-reload.d.ts +37 -0
  38. package/dist/extension-reload.js +78 -0
  39. package/dist/fs-persistence.d.ts +63 -0
  40. package/dist/fs-persistence.js +102 -0
  41. package/dist/index.d.ts +57 -0
  42. package/dist/index.js +35 -0
  43. package/dist/logger.d.ts +21 -0
  44. package/dist/logger.js +66 -0
  45. package/dist/model-routing.d.ts +30 -0
  46. package/dist/model-routing.js +50 -0
  47. package/dist/model-spec.d.ts +29 -0
  48. package/dist/model-spec.js +252 -0
  49. package/dist/model-tier-config.d.ts +133 -0
  50. package/dist/model-tier-config.js +249 -0
  51. package/dist/run-persistence.d.ts +180 -0
  52. package/dist/run-persistence.js +294 -0
  53. package/dist/saved-commands.d.ts +28 -0
  54. package/dist/saved-commands.js +100 -0
  55. package/dist/shared-store.d.ts +98 -0
  56. package/dist/shared-store.js +212 -0
  57. package/dist/structured-output.d.ts +19 -0
  58. package/dist/structured-output.js +30 -0
  59. package/dist/task-panel.d.ts +61 -0
  60. package/dist/task-panel.js +422 -0
  61. package/dist/usage-limit-scheduler.d.ts +145 -0
  62. package/dist/usage-limit-scheduler.js +368 -0
  63. package/dist/web-tools.d.ts +20 -0
  64. package/dist/web-tools.js +120 -0
  65. package/dist/workflow-authoring-coverage.d.ts +70 -0
  66. package/dist/workflow-authoring-coverage.js +421 -0
  67. package/dist/workflow-authoring-reference.d.ts +20 -0
  68. package/dist/workflow-authoring-reference.js +156 -0
  69. package/dist/workflow-capability-contract.d.ts +131 -0
  70. package/dist/workflow-capability-contract.js +604 -0
  71. package/dist/workflow-commands.d.ts +18 -0
  72. package/dist/workflow-commands.js +260 -0
  73. package/dist/workflow-comprehension.d.ts +133 -0
  74. package/dist/workflow-comprehension.js +1321 -0
  75. package/dist/workflow-context-measurement.d.ts +72 -0
  76. package/dist/workflow-context-measurement.js +213 -0
  77. package/dist/workflow-control-tool.d.ts +30 -0
  78. package/dist/workflow-control-tool.js +176 -0
  79. package/dist/workflow-delivery-choice.d.ts +20 -0
  80. package/dist/workflow-delivery-choice.js +48 -0
  81. package/dist/workflow-editor.d.ts +93 -0
  82. package/dist/workflow-editor.js +363 -0
  83. package/dist/workflow-manager.d.ts +492 -0
  84. package/dist/workflow-manager.js +1124 -0
  85. package/dist/workflow-paths.d.ts +22 -0
  86. package/dist/workflow-paths.js +46 -0
  87. package/dist/workflow-release-gate.d.ts +39 -0
  88. package/dist/workflow-release-gate.js +309 -0
  89. package/dist/workflow-saved.d.ts +38 -0
  90. package/dist/workflow-saved.js +126 -0
  91. package/dist/workflow-settings.d.ts +70 -0
  92. package/dist/workflow-settings.js +131 -0
  93. package/dist/workflow-tool.d.ts +71 -0
  94. package/dist/workflow-tool.js +367 -0
  95. package/dist/workflow-ui.d.ts +182 -0
  96. package/dist/workflow-ui.js +1587 -0
  97. package/dist/workflow.d.ts +333 -0
  98. package/dist/workflow.js +1151 -0
  99. package/dist/workflows-models-command.d.ts +31 -0
  100. package/dist/workflows-models-command.js +156 -0
  101. package/dist/worktree.d.ts +25 -0
  102. package/dist/worktree.js +61 -0
  103. package/extensions/workflow.ts +151 -0
  104. package/package.json +104 -0
  105. package/skills/workflow-authoring/SKILL.md +30 -0
  106. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  107. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  108. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  109. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  110. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  111. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  112. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  113. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  114. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  115. package/skills/workflow-authoring/examples/tournament.js +81 -0
  116. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  117. package/skills/workflow-authoring/references/capabilities.md +41 -0
  118. package/skills/workflow-authoring/references/capability-details.md +357 -0
  119. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  120. package/skills/workflow-authoring/references/debugging.md +27 -0
  121. package/skills/workflow-authoring/references/focused-recipes.md +11 -0
  122. package/skills/workflow-authoring/references/helpers.md +9 -0
  123. package/skills/workflow-authoring/references/lifecycle.md +33 -0
  124. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  125. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  126. package/skills/workflow-authoring/references/registry-ownership.md +15 -0
  127. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  128. package/skills/workflow-authoring/references/review.md +43 -0
  129. package/skills/workflow-authoring/references/runtime.md +27 -0
  130. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  131. package/skills/workflow-authoring/references/versions.md +13 -0
  132. package/skills/workflow-patterns/SKILL.md +51 -0
  133. package/src/accept-workflow-guidance.ts +71 -0
  134. package/src/adversarial-review.ts +120 -0
  135. package/src/agent-history.ts +157 -0
  136. package/src/agent-registry.ts +221 -0
  137. package/src/agent.ts +929 -0
  138. package/src/builtin-commands.ts +286 -0
  139. package/src/builtin-workflows.ts +155 -0
  140. package/src/code-review.ts +183 -0
  141. package/src/config.ts +55 -0
  142. package/src/deep-research.ts +135 -0
  143. package/src/display.ts +367 -0
  144. package/src/effort-command.ts +87 -0
  145. package/src/enums.ts +77 -0
  146. package/src/errors.ts +199 -0
  147. package/src/extension-reload.ts +100 -0
  148. package/src/fs-persistence.ts +124 -0
  149. package/src/index.ts +176 -0
  150. package/src/logger.ts +88 -0
  151. package/src/model-routing.ts +73 -0
  152. package/src/model-spec.ts +309 -0
  153. package/src/model-tier-config.ts +296 -0
  154. package/src/run-persistence.ts +484 -0
  155. package/src/saved-commands.ts +115 -0
  156. package/src/shared-store.ts +228 -0
  157. package/src/structured-output.ts +47 -0
  158. package/src/task-panel.ts +490 -0
  159. package/src/usage-limit-scheduler.ts +432 -0
  160. package/src/web-tools.ts +124 -0
  161. package/src/workflow-authoring-coverage.ts +486 -0
  162. package/src/workflow-authoring-reference.ts +186 -0
  163. package/src/workflow-capability-contract.ts +806 -0
  164. package/src/workflow-commands.ts +287 -0
  165. package/src/workflow-comprehension.ts +1673 -0
  166. package/src/workflow-context-measurement.ts +262 -0
  167. package/src/workflow-control-tool.ts +238 -0
  168. package/src/workflow-delivery-choice.ts +69 -0
  169. package/src/workflow-editor.ts +444 -0
  170. package/src/workflow-manager.ts +1405 -0
  171. package/src/workflow-paths.ts +63 -0
  172. package/src/workflow-release-gate.ts +529 -0
  173. package/src/workflow-saved.ts +180 -0
  174. package/src/workflow-settings.ts +194 -0
  175. package/src/workflow-tool.ts +464 -0
  176. package/src/workflow-ui.ts +1789 -0
  177. package/src/workflow.ts +1615 -0
  178. package/src/workflows-models-command.ts +211 -0
  179. 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
+ }