@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,432 @@
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
+
19
+ import type { PersistedRunState, RunPersistence, RunStatus } from "./run-persistence.js";
20
+
21
+ /** Narrow surface this scheduler depends on — satisfied by WorkflowManager. */
22
+ export interface SchedulableWorkflowManager {
23
+ on(event: string, listener: (...args: any[]) => void): unknown;
24
+ off(event: string, listener: (...args: any[]) => void): unknown;
25
+ listAllRuns(): PersistedRunState[];
26
+ resume(runId: string): Promise<boolean>;
27
+ getPersistence(): RunPersistence;
28
+ }
29
+
30
+ /** Opaque timer handle so tests can inject a fake clock/timer. */
31
+ export type TimerHandle = unknown;
32
+
33
+ export interface UsageLimitSchedulerOptions {
34
+ /** Injectable clock (default Date.now). */
35
+ now?: () => number;
36
+ /** Injectable timer scheduler (default setTimeout). */
37
+ setTimer?: (fn: () => void, ms: number) => TimerHandle;
38
+ /** Injectable timer canceller (default clearTimeout). */
39
+ clearTimer?: (handle: TimerHandle) => void;
40
+ /** Max auto-resume attempts per pause-cycle before giving up. Default 5. */
41
+ maxAttempts?: number;
42
+ /** Delay floor — never arm sooner than this. Default 60_000 (1m). */
43
+ minDelayMs?: number;
44
+ /** Delay used when the provider's resetHint can't be parsed. Default 300_000 (5m). */
45
+ fallbackDelayMs?: number;
46
+ /** Delay ceiling — backoff is clamped here. Default 6h. */
47
+ maxDelayMs?: number;
48
+ /** Diagnostics sink; defaults to console.warn. Never throws back into the caller. */
49
+ onDiagnostic?: (message: string, detail?: unknown) => void;
50
+ }
51
+
52
+ interface RunState {
53
+ /** How many auto-resume attempts have been made (or armed) for this pause-cycle. */
54
+ attempts: number;
55
+ /** The currently armed timer, if any. */
56
+ timer?: TimerHandle;
57
+ /** Set once the attempt cap is hit, so the give-up diagnostic logs once. */
58
+ gaveUp?: boolean;
59
+ }
60
+
61
+ const DEFAULT_MAX_ATTEMPTS = 5;
62
+ const DEFAULT_MIN_DELAY_MS = 60_000;
63
+ const DEFAULT_FALLBACK_DELAY_MS = 300_000;
64
+ const DEFAULT_MAX_DELAY_MS = 6 * 60 * 60 * 1000;
65
+
66
+ /**
67
+ * Best-effort parse of a provider's human reset hint ("Resets in ~3h",
68
+ * "resets in 5m", "in 90s", "1h30m") into milliseconds. Sums every
69
+ * (number, unit) pair found, so combined forms like "1h30m" work for free.
70
+ * Returns undefined when nothing recognizable is found — callers should fall
71
+ * back to a fixed delay rather than guess.
72
+ */
73
+ export function parseResetHintMs(hint?: string): number | undefined {
74
+ if (!hint) return undefined;
75
+ // No trailing \b: combined forms like "1h30m" have a digit right after the
76
+ // unit letter, which is itself a word character, so \b would never match
77
+ // there. A negative lookahead for another letter is the correct boundary —
78
+ // it still stops "hours" from partially matching as bare "h" mid-word while
79
+ // allowing a unit to be followed immediately by the next (digit, unit) pair.
80
+ const re = /(\d+(?:\.\d+)?)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])/gi;
81
+ let match: RegExpExecArray | null;
82
+ let totalMs = 0;
83
+ let found = false;
84
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
85
+ while ((match = re.exec(hint)) !== null) {
86
+ const value = Number.parseFloat(match[1]);
87
+ if (!Number.isFinite(value)) continue;
88
+ const unit = match[2].toLowerCase();
89
+ found = true;
90
+ if (unit.startsWith("h")) totalMs += value * 3_600_000;
91
+ else if (unit.startsWith("m")) totalMs += value * 60_000;
92
+ else if (unit.startsWith("s")) totalMs += value * 1_000;
93
+ }
94
+ return found ? totalMs : undefined;
95
+ }
96
+
97
+ export interface AutoResumeDelayParams {
98
+ /** The provider's verbatim reset hint for this pause, if any. */
99
+ resetHint?: string;
100
+ /** 1-indexed attempt number for the pause currently being armed. */
101
+ attempts: number;
102
+ /** Milliseconds already elapsed since the pause began (0 for a live pause). */
103
+ elapsedMs: number;
104
+ minDelayMs: number;
105
+ fallbackDelayMs: number;
106
+ maxDelayMs: number;
107
+ }
108
+
109
+ /**
110
+ * delay = clamp(minDelayMs, remaining * 2^(attempts-1), maxDelayMs), where
111
+ * remaining = parsed(resetHint) ?? fallbackDelayMs, minus time already elapsed.
112
+ * The exponent is capped defensively so a pathological attempt count can't
113
+ * overflow the multiplication to Infinity/NaN before the maxDelayMs clamp runs.
114
+ */
115
+ export function computeAutoResumeDelayMs(params: AutoResumeDelayParams): number {
116
+ const base = parseResetHintMs(params.resetHint) ?? params.fallbackDelayMs;
117
+ const remaining = base - params.elapsedMs;
118
+ const exponent = Math.min(Math.max(params.attempts - 1, 0), 30);
119
+ const backoff = remaining * 2 ** exponent;
120
+ return Math.min(params.maxDelayMs, Math.max(params.minDelayMs, backoff));
121
+ }
122
+
123
+ /**
124
+ * Watches a WorkflowManager for usage-limit pauses and auto-resumes eligible
125
+ * runs once the provider's quota is likely to have refilled.
126
+ *
127
+ * Event-driven "fire and watch": an attempt is consumed when a run ENTERS a
128
+ * usage_limit pause (live via the "paused" event, or once at cold start for a
129
+ * run that was already paused), never when a resume is merely fired. When an
130
+ * armed timer fires, resume() is called; if it returns false (lease busy, run
131
+ * already gone, etc.) no attempt is consumed and a short un-backed-off retry is
132
+ * armed instead, unless the run has reached a terminal state on disk. If resume()
133
+ * returns true, this scheduler steps back — the existing "paused" subscription
134
+ * re-arms with backoff if the run hits the wall again, and "complete"/"error"/
135
+ * "stopped" clean up its timer.
136
+ */
137
+ export class UsageLimitScheduler {
138
+ private readonly manager: SchedulableWorkflowManager;
139
+ private readonly now: () => number;
140
+ private readonly setTimer: (fn: () => void, ms: number) => TimerHandle;
141
+ private readonly clearTimer: (handle: TimerHandle) => void;
142
+ private readonly maxAttempts: number;
143
+ private readonly minDelayMs: number;
144
+ private readonly fallbackDelayMs: number;
145
+ private readonly maxDelayMs: number;
146
+ private readonly diagnostic: (message: string, detail?: unknown) => void;
147
+
148
+ private readonly state = new Map<string, RunState>();
149
+ private disposed = false;
150
+ /**
151
+ * Runs this scheduler is currently auto-resuming (its own timer fired). Used to
152
+ * tell an auto-resume's "resumed" event apart from a manual one: an auto-resume
153
+ * must keep the backoff counter (it IS the backoff), a manual resume resets it.
154
+ */
155
+ private readonly autoResumingRunIds = new Set<string>();
156
+
157
+ private readonly onPaused = (event: { runId?: string; reason?: string; resetHint?: string }): void => {
158
+ this.safe(() => this.handlePaused(event));
159
+ };
160
+ private readonly onTerminal = (event: { runId?: string }): void => {
161
+ this.safe(() => this.cleanup(event?.runId));
162
+ };
163
+ private readonly onResumed = (event: { runId?: string }): void => {
164
+ this.safe(() => this.handleResumed(event));
165
+ };
166
+
167
+ constructor(manager: SchedulableWorkflowManager, options: UsageLimitSchedulerOptions = {}) {
168
+ this.manager = manager;
169
+ this.now = options.now ?? Date.now;
170
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
171
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as NodeJS.Timeout));
172
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
173
+ this.minDelayMs = options.minDelayMs ?? DEFAULT_MIN_DELAY_MS;
174
+ this.fallbackDelayMs = options.fallbackDelayMs ?? DEFAULT_FALLBACK_DELAY_MS;
175
+ this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
176
+ this.diagnostic =
177
+ options.onDiagnostic ??
178
+ ((message, detail) => {
179
+ console.warn(message, detail ?? "");
180
+ });
181
+
182
+ this.manager.on("paused", this.onPaused);
183
+ this.manager.on("resumed", this.onResumed);
184
+ this.manager.on("complete", this.onTerminal);
185
+ this.manager.on("error", this.onTerminal);
186
+ this.manager.on("stopped", this.onTerminal);
187
+
188
+ // Cold-start re-arm: pick up any run that was already paused-on-usage_limit
189
+ // before this process (and thus this scheduler instance) existed.
190
+ this.safe(() => this.coldStartRearm());
191
+ }
192
+
193
+ /** Clear every armed timer and unsubscribe from the manager. Idempotent. */
194
+ dispose(): void {
195
+ if (this.disposed) return;
196
+ this.disposed = true;
197
+ this.manager.off("paused", this.onPaused);
198
+ this.manager.off("resumed", this.onResumed);
199
+ this.manager.off("complete", this.onTerminal);
200
+ this.manager.off("error", this.onTerminal);
201
+ this.manager.off("stopped", this.onTerminal);
202
+ for (const entry of this.state.values()) {
203
+ if (entry.timer !== undefined) this.clearTimer(entry.timer);
204
+ }
205
+ this.state.clear();
206
+ }
207
+
208
+ /** Test/diagnostic helper: in-memory attempt count tracked for a run, if any. */
209
+ getAttemptCount(runId: string): number | undefined {
210
+ return this.state.get(runId)?.attempts;
211
+ }
212
+
213
+ /** Test/diagnostic helper: whether a resume timer is currently armed for a run. */
214
+ hasArmedTimer(runId: string): boolean {
215
+ return this.state.get(runId)?.timer !== undefined;
216
+ }
217
+
218
+ // ---- event handlers -----------------------------------------------------
219
+
220
+ private handlePaused(event: { runId?: string; reason?: string; resetHint?: string }): void {
221
+ if (this.disposed || !event?.runId || event.reason !== "usage_limit") return;
222
+ const runId = event.runId;
223
+
224
+ // The "paused" event fires BEFORE the manager's own persistRun() write for
225
+ // this pause (see executeRun()'s catch block: emit then persist). A disk
226
+ // read here can therefore be stale for fields this exact pause is about to
227
+ // set (status/pauseReason/resetHint) — but NOT for `autoResume`, which is
228
+ // fixed at run-start and persisted on every persistRun() call since, so a
229
+ // stale read of it is still correct. resetHint comes off the event itself,
230
+ // not disk, to avoid that race.
231
+ const persisted = this.safeLoad(runId);
232
+ if (persisted?.autoResume === false) {
233
+ this.diagnostic(`[usage-limit-scheduler] ${runId}: autoResume is disabled for this run, not arming`);
234
+ return;
235
+ }
236
+
237
+ const priorAttempts = this.state.get(runId)?.attempts ?? persisted?.autoResumeAttempts ?? 0;
238
+ this.arm(runId, {
239
+ attempts: priorAttempts + 1,
240
+ resetHint: event.resetHint ?? persisted?.resetHint,
241
+ elapsedMs: 0,
242
+ });
243
+ }
244
+
245
+ private cleanup(runId?: string): void {
246
+ if (!runId) return;
247
+ const entry = this.state.get(runId);
248
+ if (entry?.timer !== undefined) this.clearTimer(entry.timer);
249
+ this.state.delete(runId);
250
+ }
251
+
252
+ /**
253
+ * A run was resumed. If WE resumed it (auto-resume timer fired), leave the
254
+ * backoff counter alone — that's the sequence doing its job, and it must still
255
+ * be able to reach the cap. If a human resumed it (via /workflows), treat that
256
+ * as a deliberate fresh start: drop the in-memory given-up state and reset the
257
+ * persisted counter so a later pause re-enters the normal backoff from attempt 1
258
+ * instead of staying silently given-up forever.
259
+ */
260
+ private handleResumed(event: { runId?: string }): void {
261
+ if (this.disposed || !event?.runId) return;
262
+ if (this.autoResumingRunIds.has(event.runId)) return;
263
+ this.cleanup(event.runId);
264
+ this.persistAttempts(event.runId, 0);
265
+ }
266
+
267
+ private coldStartRearm(): void {
268
+ const runs = this.manager.listAllRuns();
269
+ for (const run of runs) {
270
+ if (run.status !== "paused" || run.pauseReason !== "usage_limit") continue;
271
+ if (run.autoResume === false) continue;
272
+ if (this.state.has(run.runId)) continue;
273
+
274
+ const priorAttempts = run.autoResumeAttempts ?? 0;
275
+ const updatedAtMs = Date.parse(run.updatedAt);
276
+ const elapsedMs = Number.isFinite(updatedAtMs) ? Math.max(0, this.now() - updatedAtMs) : 0;
277
+ this.arm(run.runId, {
278
+ attempts: priorAttempts + 1,
279
+ resetHint: run.resetHint,
280
+ elapsedMs,
281
+ });
282
+ }
283
+ }
284
+
285
+ // ---- arming / firing ------------------------------------------------------
286
+
287
+ private arm(runId: string, params: { attempts: number; resetHint?: string; elapsedMs: number }): void {
288
+ const existing = this.state.get(runId);
289
+ if (existing?.timer !== undefined) this.clearTimer(existing.timer);
290
+
291
+ if (params.attempts > this.maxAttempts) {
292
+ const alreadyLogged = existing?.gaveUp === true;
293
+ // Freeze the counter at a single sentinel (maxAttempts + 1) instead of
294
+ // storing the raw overflow. coldStartRearm() reads the persisted count and
295
+ // adds 1 on every restart; without this clamp a given-up run's counter
296
+ // grew without bound (…6, 7, 8… → "giving up after 23") across cold starts
297
+ // (#106). Clamping makes the persisted value idempotent — a rearm of an
298
+ // already-given-up run rewrites the same 6.
299
+ const frozen = this.maxAttempts + 1;
300
+ this.state.set(runId, { attempts: frozen, gaveUp: true });
301
+ this.persistAttempts(runId, frozen);
302
+ // Log the give-up exactly once per crossing. In-process the gaveUp flag
303
+ // guards it; across restarts a fresh scheduler has no memory, so also
304
+ // suppress when this arm is merely re-giving-up an already-capped run
305
+ // (params.attempts already past the sentinel, i.e. prior was ≥ frozen).
306
+ if (!alreadyLogged && params.attempts <= frozen) {
307
+ this.diagnostic(
308
+ `[usage-limit-scheduler] ${runId}: giving up after ${this.maxAttempts} auto-resume attempt(s) ` +
309
+ `(max ${this.maxAttempts}); leaving paused for manual resume`,
310
+ );
311
+ }
312
+ return;
313
+ }
314
+
315
+ const delay = computeAutoResumeDelayMs({
316
+ resetHint: params.resetHint,
317
+ attempts: params.attempts,
318
+ elapsedMs: params.elapsedMs,
319
+ minDelayMs: this.minDelayMs,
320
+ fallbackDelayMs: this.fallbackDelayMs,
321
+ maxDelayMs: this.maxDelayMs,
322
+ });
323
+
324
+ const timer = this.setTimer(() => this.safe(() => this.onTimerFire(runId)), delay);
325
+ this.state.set(runId, { attempts: params.attempts, timer });
326
+ this.persistAttempts(runId, params.attempts);
327
+ }
328
+
329
+ private async onTimerFire(runId: string): Promise<void> {
330
+ if (this.disposed) return;
331
+ const entry = this.state.get(runId);
332
+ if (!entry || entry.gaveUp) return;
333
+ // The timer that just fired is spent; clear its handle while we await.
334
+ this.state.set(runId, { ...entry, timer: undefined });
335
+
336
+ let resumed = false;
337
+ // Mark this as OUR resume so handleResumed() (fired synchronously inside
338
+ // resume(), before it returns) doesn't mistake it for a manual resume and
339
+ // reset the backoff counter mid-sequence.
340
+ this.autoResumingRunIds.add(runId);
341
+ try {
342
+ resumed = await this.manager.resume(runId);
343
+ } catch (err) {
344
+ this.diagnostic(`[usage-limit-scheduler] ${runId}: resume() threw`, err);
345
+ resumed = false;
346
+ } finally {
347
+ this.autoResumingRunIds.delete(runId);
348
+ }
349
+ if (this.disposed) return;
350
+
351
+ if (resumed) {
352
+ // Don't consume/advance anything further here — the existing "paused"
353
+ // subscription re-arms (with backoff) if this run hits the wall again,
354
+ // and "complete"/"error"/"stopped" clean up on any terminal outcome.
355
+ return;
356
+ }
357
+
358
+ // resume() returned false without throwing: it refused for a structural
359
+ // reason (already running/aborted, no persisted script, or the lease is
360
+ // held elsewhere) rather than a real failed attempt. Per the fix for bug
361
+ // (a), that must NOT consume an attempt. Distinguish "gone for good" from
362
+ // "try again shortly":
363
+ const status = this.safeStatus(runId);
364
+ if (status === undefined || status === "completed" || status === "aborted") {
365
+ this.cleanup(runId);
366
+ return;
367
+ }
368
+
369
+ const current = this.state.get(runId) ?? entry;
370
+ const timer = this.setTimer(() => this.safe(() => this.onTimerFire(runId)), this.minDelayMs);
371
+ this.state.set(runId, { attempts: current.attempts, timer });
372
+ }
373
+
374
+ // ---- helpers --------------------------------------------------------------
375
+
376
+ private safeLoad(runId: string): PersistedRunState | undefined {
377
+ try {
378
+ return this.manager.getPersistence().load(runId) ?? undefined;
379
+ } catch (err) {
380
+ this.diagnostic(`[usage-limit-scheduler] ${runId}: persistence load failed`, err);
381
+ return undefined;
382
+ }
383
+ }
384
+
385
+ private safeStatus(runId: string): RunStatus | undefined {
386
+ try {
387
+ return this.manager.listAllRuns().find((r) => r.runId === runId)?.status;
388
+ } catch (err) {
389
+ this.diagnostic(`[usage-limit-scheduler] ${runId}: listAllRuns() failed`, err);
390
+ return undefined;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Best-effort persist of the in-memory attempt counter, so a cold start after
396
+ * a crash can approximately resume the backoff sequence instead of restarting
397
+ * it. Deferred to a microtask so it lands AFTER the manager's own persistRun()
398
+ * write for this same pause (which happens synchronously, right after the
399
+ * "paused" event we're reacting to returns control to executeRun()) — writing
400
+ * synchronously here would just get clobbered, since persistRun() writes a
401
+ * fresh PersistedRunState object literal that doesn't know about this field.
402
+ * This is still inherently racy across process crashes (see class docs); it
403
+ * is a best-effort durability aid, not a correctness requirement for the live
404
+ * (in-memory) path.
405
+ */
406
+ private persistAttempts(runId: string, attempts: number): void {
407
+ queueMicrotask(() => {
408
+ if (this.disposed) return;
409
+ try {
410
+ const persistence = this.manager.getPersistence();
411
+ const current = persistence.load(runId);
412
+ if (!current) return;
413
+ persistence.save({ ...current, autoResumeAttempts: attempts });
414
+ } catch (err) {
415
+ this.diagnostic(`[usage-limit-scheduler] ${runId}: failed to persist autoResumeAttempts`, err);
416
+ }
417
+ });
418
+ }
419
+
420
+ private safe(fn: () => void | Promise<void>): void {
421
+ try {
422
+ const result = fn();
423
+ if (result && typeof (result as Promise<void>).catch === "function") {
424
+ (result as Promise<void>).catch((err: unknown) => {
425
+ this.diagnostic("[usage-limit-scheduler] async handler error", err);
426
+ });
427
+ }
428
+ } catch (err) {
429
+ this.diagnostic("[usage-limit-scheduler] handler error", err);
430
+ }
431
+ }
432
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Real web tools for research workflows. These execute in the extension host
3
+ * process (which has network access), not in a subagent sandbox, so they perform
4
+ * genuine HTTP requests via Node's fetch.
5
+ *
6
+ * - web_search: best-effort Bing HTML scrape -> result {url, title}
7
+ * - web_fetch: fetch a URL and return readable text (HTML stripped, truncated)
8
+ */
9
+
10
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
11
+ import { Type } from "typebox";
12
+
13
+ const UA =
14
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
15
+
16
+ async function fetchText(url: string, timeoutMs = 15000): Promise<{ status: number; body: string }> {
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
19
+ try {
20
+ const res = await fetch(url, { headers: { "user-agent": UA }, signal: controller.signal, redirect: "follow" });
21
+ return { status: res.status, body: await res.text() };
22
+ } finally {
23
+ clearTimeout(timer);
24
+ }
25
+ }
26
+
27
+ export function htmlToText(html: string): string {
28
+ return html
29
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
30
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
31
+ .replace(/<\/(p|div|li|h[1-6]|tr|br)>/gi, "\n")
32
+ .replace(/<[^>]+>/g, " ")
33
+ .replace(/&nbsp;/g, " ")
34
+ .replace(/&amp;/g, "&")
35
+ .replace(/&lt;/g, "<")
36
+ .replace(/&gt;/g, ">")
37
+ .replace(/&#39;|&apos;/g, "'")
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/[ \t]+/g, " ")
40
+ .replace(/\n +/g, "\n")
41
+ .replace(/\n{3,}/g, "\n\n")
42
+ .trim();
43
+ }
44
+
45
+ export function parseBingResults(html: string, limit: number): Array<{ url: string; title: string }> {
46
+ const out: Array<{ url: string; title: string }> = [];
47
+ const seen = new Set<string>();
48
+ for (const m of html.matchAll(/<h2[^>]*>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/g)) {
49
+ const url = m[1];
50
+ if (/\.bing\.com|go\.microsoft\.com/.test(url) || seen.has(url)) continue;
51
+ seen.add(url);
52
+ out.push({ url, title: m[2].replace(/<[^>]+>/g, "").trim() });
53
+ if (out.length >= limit) break;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** A tool that searches the web (best-effort) and returns result URLs + titles. */
59
+ export function createWebSearchTool(): ToolDefinition {
60
+ return defineTool({
61
+ name: "web_search",
62
+ label: "Web Search",
63
+ description: "Search the web and return a list of result URLs and titles. Use before web_fetch to find sources.",
64
+ promptSnippet: "Search the web for sources",
65
+ parameters: Type.Object({
66
+ query: Type.String({ description: "The search query." }),
67
+ count: Type.Optional(Type.Number({ description: "Max results (default 6)." })),
68
+ }),
69
+ async execute(_id, params: { query: string; count?: number }) {
70
+ const limit = Math.min(Math.max(params.count ?? 6, 1), 10);
71
+ try {
72
+ const { status, body } = await fetchText(`https://www.bing.com/search?q=${encodeURIComponent(params.query)}`);
73
+ const results = parseBingResults(body, limit);
74
+ const text = results.length
75
+ ? results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n")
76
+ : `No results parsed (HTTP ${status}). Try a different query or fetch a known URL directly.`;
77
+ return { content: [{ type: "text", text }], details: { results } };
78
+ } catch (error) {
79
+ return {
80
+ content: [{ type: "text", text: `web_search failed: ${error instanceof Error ? error.message : error}` }],
81
+ details: { results: [] as Array<{ url: string; title: string }> },
82
+ };
83
+ }
84
+ },
85
+ }) as unknown as ToolDefinition;
86
+ }
87
+
88
+ /** A tool that fetches a URL and returns readable text. */
89
+ export function createWebFetchTool(maxChars = 6000): ToolDefinition {
90
+ return defineTool({
91
+ name: "web_fetch",
92
+ label: "Web Fetch",
93
+ description: "Fetch a URL and return its readable text content (HTML stripped, truncated).",
94
+ promptSnippet: "Fetch a URL's text",
95
+ parameters: Type.Object({
96
+ url: Type.String({ description: "The absolute URL to fetch." }),
97
+ }),
98
+ async execute(_id, params: { url: string }) {
99
+ try {
100
+ const { status, body } = await fetchText(params.url);
101
+ const text = htmlToText(body).slice(0, maxChars);
102
+ return {
103
+ content: [{ type: "text", text: `HTTP ${status} ${params.url}\n\n${text}` }],
104
+ details: { status, url: params.url },
105
+ };
106
+ } catch (error) {
107
+ return {
108
+ content: [
109
+ {
110
+ type: "text",
111
+ text: `web_fetch failed for ${params.url}: ${error instanceof Error ? error.message : error}`,
112
+ },
113
+ ],
114
+ details: { status: 0, url: params.url },
115
+ };
116
+ }
117
+ },
118
+ }) as unknown as ToolDefinition;
119
+ }
120
+
121
+ /** Both web tools, for injecting into a research workflow's agents. */
122
+ export function createWebTools(): ToolDefinition[] {
123
+ return [createWebSearchTool(), createWebFetchTool()];
124
+ }