@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,484 @@
1
+ /**
2
+ * Workflow run state persistence for pause/resume support.
3
+ */
4
+
5
+ import { join } from "node:path";
6
+ import type { AgentUsage } from "./agent.js";
7
+ import type { AgentHistoryEntry } from "./agent-history.js";
8
+ import type { WorkflowErrorCode } from "./errors.js";
9
+ import {
10
+ ensureDir as ensureDirFs,
11
+ listJsonFilesSafe,
12
+ type PersistenceFsLayer,
13
+ readJsonWithBackupRecovery,
14
+ resolvePersistenceFs,
15
+ unlinkIfExistsSafe,
16
+ writeJsonAtomicWithBackup,
17
+ } from "./fs-persistence.js";
18
+ import { workflowProjectPaths } from "./workflow-paths.js";
19
+
20
+ export type RunStatus = "pending" | "running" | "paused" | "completed" | "failed" | "aborted";
21
+
22
+ export interface PersistedAgentState {
23
+ id: number;
24
+ /** Runtime call identity (`${runId}:${callIndex}`), used to rehydrate journaled results. */
25
+ callId?: string;
26
+ label: string;
27
+ phase?: string;
28
+ prompt: string;
29
+ status: "queued" | "running" | "done" | "error" | "skipped";
30
+ result?: unknown;
31
+ /** Compact result written by releases before full agent results were retained. */
32
+ resultPreview?: string;
33
+ error?: string;
34
+ errorCode?: WorkflowErrorCode;
35
+ recoverable?: boolean;
36
+ history?: AgentHistoryEntry[];
37
+ startedAt?: string;
38
+ endedAt?: string;
39
+ /** Tokens used by this agent (a scalar estimate when the provider reports no usage). */
40
+ tokens?: number;
41
+ /** Per-agent token usage breakdown, when the provider reported one. */
42
+ tokenUsage?: AgentUsage;
43
+ /** The model this agent ran on (provider/id), when known. */
44
+ model?: string;
45
+ }
46
+
47
+ export interface PersistedRunState {
48
+ runId: string;
49
+ workflowName: string;
50
+ script: string;
51
+ args?: unknown;
52
+ /** The pi session this run belongs to. Runs persist on disk across sessions but
53
+ * the navigator shows only the current session's runs (undefined = legacy/global). */
54
+ sessionId?: string;
55
+ status: RunStatus;
56
+ /** Why a paused run is paused (e.g. "usage_limit" when a provider quota was hit). */
57
+ pauseReason?: string;
58
+ /** Provider reset hint for a usage-limit pause, e.g. "Resets in ~3h" (verbatim). */
59
+ resetHint?: string;
60
+ phases: string[];
61
+ currentPhase?: string;
62
+ agents: PersistedAgentState[];
63
+ logs: string[];
64
+ result?: unknown;
65
+ startedAt: string;
66
+ updatedAt: string;
67
+ completedAt?: string;
68
+ durationMs?: number;
69
+ tokenUsage?: {
70
+ input: number;
71
+ output: number;
72
+ total: number;
73
+ cost?: number;
74
+ cacheRead?: number;
75
+ cacheWrite?: number;
76
+ };
77
+ /**
78
+ * Cached agent/checkpoint results for resume, keyed by deterministic call
79
+ * index. `runId` namespaces `index` (a nested workflow() call restarts its
80
+ * own callSeq at 0) — absent on journals persisted before that namespacing
81
+ * existed; see JournalEntry.runId in workflow.ts for the resume-time
82
+ * legacy-degradation behavior. `storeDelta` is this call's SharedStore
83
+ * write delta, replayed additively on resume.
84
+ */
85
+ journal?: Array<{
86
+ index: number;
87
+ runId?: string;
88
+ hash: string;
89
+ result: unknown;
90
+ storeDelta?: Record<string, unknown>;
91
+ }>;
92
+ /**
93
+ * Opt-out of auto-resume for this run (default true, i.e. eligible unless
94
+ * explicitly set to false via ExecOptions.autoResume). Set once at run start
95
+ * and carried through resumes; see UsageLimitScheduler.
96
+ */
97
+ autoResume?: boolean;
98
+ /**
99
+ * The run's resolved hard token budget, fixed at start (per-run value, else
100
+ * the manager default at the time). Resume re-applies THIS value — never the
101
+ * current default — so an explicit no-budget (`null`) or custom cap survives
102
+ * a pause/resume cycle. Absent on legacy runs (resumed unbudgeted).
103
+ */
104
+ tokenBudget?: number | null;
105
+ /**
106
+ * Named toolset tag (WorkflowManagerOptions.toolsets). ToolDefinitions are
107
+ * functions and can't be serialized, so this tag is how a resumed run (e.g.
108
+ * /deep-research with web tools) re-resolves the tool set it started with.
109
+ */
110
+ toolset?: string;
111
+ /**
112
+ * The run's resolved cap on total agents, fixed at start (per-run value,
113
+ * else undefined so runWorkflow applies its own MAX_AGENTS_PER_RUN default).
114
+ * Resume re-applies THIS value — never the manager's current default — same
115
+ * rationale as tokenBudget. Absent on legacy runs (resumed with no cap
116
+ * carried forward, i.e. runWorkflow's own default applies).
117
+ */
118
+ maxAgents?: number;
119
+ /**
120
+ * The run's resolved per-agent timeout, fixed at start (per-run value, else
121
+ * the manager default at the time). Absent on legacy runs — unlike
122
+ * tokenBudget, a legacy run's real timeout was never "no timeout" by
123
+ * omission; it was always the manager's default (pre-A1 resume always fell
124
+ * back to it), so resume applies the manager's CURRENT default for such
125
+ * runs rather than null, preserving both the run's original semantics and
126
+ * pre-fix resume behavior.
127
+ */
128
+ agentTimeoutMs?: number | null;
129
+ /**
130
+ * The run's resolved concurrency, fixed at start (per-run value, else the
131
+ * manager's concurrency at the time). Same rationale as tokenBudget.
132
+ */
133
+ concurrency?: number;
134
+ /**
135
+ * The run's resolved agent-retry count, fixed at start (per-run value, else
136
+ * the manager default at the time). Same rationale as tokenBudget.
137
+ */
138
+ agentRetries?: number;
139
+ /**
140
+ * Auto-resume attempt counter for the current usage_limit pause-cycle, owned
141
+ * and persisted by UsageLimitScheduler (best-effort). Absent/0 means no
142
+ * auto-resume attempt has been recorded yet.
143
+ */
144
+ autoResumeAttempts?: number;
145
+ }
146
+
147
+ export interface RunPersistence {
148
+ /** Save current run state. */
149
+ save(state: PersistedRunState): void;
150
+ /** Load a persisted run by ID. */
151
+ load(runId: string): PersistedRunState | null;
152
+ /** List all persisted runs. */
153
+ list(): PersistedRunState[];
154
+ /** Delete a persisted run. */
155
+ delete(runId: string): boolean;
156
+ /**
157
+ * Acquire an exclusive cross-process lease for a run. Returns null when another
158
+ * live process owns the run; stale/corrupt lock files are removed and retried.
159
+ */
160
+ acquireRunLease(runId: string): RunLease | null;
161
+ /** Release a lease previously returned by acquireRunLease(). */
162
+ releaseRunLease(lease: RunLease): void;
163
+ /** Get runs directory path. */
164
+ getRunsDir(): string;
165
+ }
166
+
167
+ export interface RunLease {
168
+ runId: string;
169
+ token: string;
170
+ }
171
+
172
+ interface LockFile {
173
+ runId: string;
174
+ runPath: string;
175
+ pid: number;
176
+ startedAt: string;
177
+ token: string;
178
+ }
179
+
180
+ /**
181
+ * Filesystem operations used by run persistence.
182
+ * Exposed for testing – pass overrides to inject mock implementations.
183
+ * (Alias of the shared PersistenceFsLayer — see fs-persistence.ts.)
184
+ */
185
+ export type FsLayer = PersistenceFsLayer;
186
+
187
+ /**
188
+ * Retention policy for terminal (completed/failed/aborted) runs kept on
189
+ * disk. Bounded so a long-lived project directory can't accumulate an
190
+ * unbounded number of run files (each polled/listed on every list() call).
191
+ * A run in "running" or "paused" status is NEVER counted against this cap
192
+ * or evicted by it — only genuinely finished runs age out, oldest (by
193
+ * updatedAt) first, once the terminal-run count exceeds the cap. 300 is
194
+ * generous enough to cover weeks of typical usage while keeping list()'s
195
+ * per-call directory scan bounded.
196
+ */
197
+ export const DEFAULT_MAX_TERMINAL_RUNS_ON_DISK = 300;
198
+
199
+ const TERMINAL_RUN_STATUSES: ReadonlySet<RunStatus> = new Set(["completed", "failed", "aborted"]);
200
+
201
+ export interface RunPersistenceOptions {
202
+ /** Override DEFAULT_MAX_TERMINAL_RUNS_ON_DISK (tests; advanced tuning). */
203
+ maxTerminalRunsOnDisk?: number;
204
+ }
205
+
206
+ /**
207
+ * `list()` does a full readdirSync + per-file readFileSync + JSON.parse of the
208
+ * entire lifetime run history. It is called on essentially every progress tick
209
+ * (task-panel re-render → WorkflowManager.listRuns()/listAllRuns()), so an
210
+ * unbounded number of ticks each re-walked and re-parsed every run file on
211
+ * disk. Cache the computed list for a short TTL — long enough to absorb a
212
+ * burst of same-tick reads, short enough that a read from a DIFFERENT process
213
+ * (or a mutation this instance doesn't own) still shows up quickly. Mirrors
214
+ * the ~1s settings-read TTL cache in task-panel.ts.
215
+ */
216
+ const LIST_CACHE_TTL_MS = 300;
217
+
218
+ export function createRunPersistence(
219
+ cwd: string,
220
+ fsOverride?: Partial<FsLayer>,
221
+ options?: RunPersistenceOptions,
222
+ ): RunPersistence {
223
+ const fs = resolvePersistenceFs(fsOverride);
224
+ const _existsSync = fs.existsSync;
225
+ const _readFileSync = fs.readFileSync;
226
+ const _statSync = fs.statSync;
227
+ const _unlinkSync = fs.unlinkSync;
228
+ const _writeFileSync = fs.writeFileSync;
229
+ const maxTerminalRunsOnDisk = options?.maxTerminalRunsOnDisk ?? DEFAULT_MAX_TERMINAL_RUNS_ON_DISK;
230
+
231
+ const paths = workflowProjectPaths(cwd);
232
+ const runsDir = paths.runsDir;
233
+ const legacyRunsDir = paths.legacyRunsDir;
234
+
235
+ const ensureDir = () => ensureDirFs(fs, runsDir);
236
+
237
+ const runPath = (dir: string, runId: string) => join(dir, `${runId}.json`);
238
+ const primaryRunPath = (runId: string) => runPath(runsDir, runId);
239
+ const legacyRunPath = (runId: string) => runPath(legacyRunsDir, runId);
240
+ const lockPath = (dir: string, runId: string) => join(dir, `${runId}.lock`);
241
+ const primaryLockPath = (runId: string) => lockPath(runsDir, runId);
242
+ const legacyLockPath = (runId: string) => lockPath(legacyRunsDir, runId);
243
+ const candidateRunPaths = (runId: string) => [primaryRunPath(runId), legacyRunPath(runId)];
244
+
245
+ const pidIsAlive = (pid: number): boolean => {
246
+ if (!Number.isInteger(pid) || pid <= 0) return false;
247
+ try {
248
+ process.kill(pid, 0);
249
+ return true;
250
+ } catch (err) {
251
+ if ((err as { code?: string }).code === "EPERM") return true;
252
+ return false;
253
+ }
254
+ };
255
+
256
+ const readLockAt = (path: string): LockFile | null => {
257
+ try {
258
+ return JSON.parse(_readFileSync(path, "utf-8")) as LockFile;
259
+ } catch {
260
+ return null;
261
+ }
262
+ };
263
+
264
+ const readLock = (runId: string): LockFile | null => readLockAt(primaryLockPath(runId));
265
+
266
+ // list() cache: recomputed lazily, invalidated synchronously by every
267
+ // mutation this instance performs (save()/delete()) so a stale read can
268
+ // never outlive a mutation this process made. A read from another process
269
+ // (or a direct fs write bypassing this instance) is picked up once the TTL
270
+ // elapses, same as before this cache existed on the next un-cached call.
271
+ let listCache: PersistedRunState[] | undefined;
272
+ let listCacheAt = 0;
273
+ const invalidateListCache = () => {
274
+ listCache = undefined;
275
+ };
276
+
277
+ // Per-file mtime+size+ino cache, keyed by absolute path: even once the
278
+ // TTL-level listCache above expires (the active panel polls roughly every
279
+ // 300ms, i.e. faster than or comparable to the TTL), most run files on
280
+ // disk haven't changed since the last recompute. Re-stat is cheap; re-read
281
+ // + re-JSON.parse is not, and scales with total lifetime run history, not
282
+ // with what actually changed. A file whose (mtimeMs, size, ino) all match
283
+ // what we last parsed is reused as-is instead of being re-read; entries
284
+ // for files that vanished between recomputes are pruned so this cache
285
+ // can't grow unbounded independent of what's actually on disk.
286
+ //
287
+ // ino is load-bearing, not redundant with mtime+size: save() writes via
288
+ // tmp-write + rename (writeJsonAtomicWithBackup), and a rename onto an
289
+ // existing path allocates a NEW inode for the replacement file. Two
290
+ // consecutive saves landing in the same mtime tick (400ms-throttled
291
+ // progress persists vs. 1-2s mtime granularity on HFS+/many network
292
+ // mounts/some Docker volume drivers is entirely realistic) with
293
+ // coincidentally equal byte length (e.g. "paused" and "failed" are the
294
+ // same length) would otherwise be indistinguishable from "unchanged" by
295
+ // (mtimeMs, size) alone — serving stale, previously-cached content
296
+ // forever until something ELSE about the file changes. The inode always
297
+ // changes on such a rename, so adding it closes that hole for free.
298
+ const fileStateCache = new Map<string, { mtimeMs: number; size: number; ino: number; state: PersistedRunState }>();
299
+
300
+ const removeStaleLegacyLock = (runId: string): boolean => {
301
+ const lock = legacyLockPath(runId);
302
+ const existing = readLockAt(lock);
303
+ if (existing?.runId === runId && pidIsAlive(existing.pid)) return false;
304
+ try {
305
+ if (_existsSync(lock)) _unlinkSync(lock);
306
+ } catch {
307
+ return false;
308
+ }
309
+ return true;
310
+ };
311
+
312
+ const computeList = (): PersistedRunState[] => {
313
+ const byRunId = new Map<string, PersistedRunState>();
314
+ const seenPaths = new Set<string>();
315
+ for (const dir of [runsDir, legacyRunsDir]) {
316
+ for (const file of listJsonFilesSafe(fs, dir)) {
317
+ const path = join(dir, file);
318
+ seenPaths.add(path);
319
+ try {
320
+ const stat = _statSync(path);
321
+ const cached = fileStateCache.get(path);
322
+ // Reuse the last parse when the file is byte-identical (same
323
+ // mtime + size + inode) to what produced it — the dominant case
324
+ // on every poll tick once a run goes terminal and stops changing.
325
+ // ino is what actually rules out a false "unchanged" match on a
326
+ // coarse-mtime filesystem (see the field doc comment above).
327
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size && cached.ino === stat.ino) {
328
+ if (!byRunId.has(cached.state.runId)) byRunId.set(cached.state.runId, cached.state);
329
+ continue;
330
+ }
331
+ const state = JSON.parse(_readFileSync(path, "utf-8")) as PersistedRunState;
332
+ fileStateCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, ino: stat.ino, state });
333
+ if (!byRunId.has(state.runId)) byRunId.set(state.runId, state);
334
+ } catch {
335
+ // Skip corrupted/unreadable files; don't let a stale cache entry
336
+ // for a file that's now failing to read linger either.
337
+ fileStateCache.delete(path);
338
+ }
339
+ }
340
+ }
341
+ // Prune cache entries for files that no longer exist (deleted runs) so
342
+ // this map's size tracks what's actually on disk, not lifetime history.
343
+ for (const path of fileStateCache.keys()) {
344
+ if (!seenPaths.has(path)) fileStateCache.delete(path);
345
+ }
346
+ return [...byRunId.values()].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
347
+ };
348
+
349
+ // Bound the number of terminal (completed/failed/aborted) runs kept on
350
+ // disk (see DEFAULT_MAX_TERMINAL_RUNS_ON_DISK) — called after every save()
351
+ // whose state is terminal, since that's the only time the terminal count
352
+ // can grow. Running/paused runs are never candidates: they're filtered out
353
+ // before the cap is even considered.
354
+ const enforceRetention = () => {
355
+ const terminal = computeList()
356
+ .filter((r) => TERMINAL_RUN_STATUSES.has(r.status))
357
+ .sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
358
+ const excess = terminal.length - maxTerminalRunsOnDisk;
359
+ if (excess <= 0) return;
360
+ for (const run of terminal.slice(0, excess)) {
361
+ deleteRunFiles(run.runId);
362
+ }
363
+ invalidateListCache();
364
+ };
365
+
366
+ const deleteRunFiles = (runId: string): boolean => {
367
+ let deleted = false;
368
+ for (const path of candidateRunPaths(runId)) {
369
+ const dir = path === primaryRunPath(runId) ? runsDir : legacyRunsDir;
370
+ // Best-effort cleanup of the sidecar files alongside the primary.
371
+ for (const sidecar of [`${path}.bak`, `${path}.tmp`, lockPath(dir, runId)]) {
372
+ unlinkIfExistsSafe(fs, sidecar);
373
+ fileStateCache.delete(sidecar);
374
+ }
375
+ if (unlinkIfExistsSafe(fs, path)) deleted = true;
376
+ fileStateCache.delete(path);
377
+ }
378
+ return deleted;
379
+ };
380
+
381
+ return {
382
+ save(state: PersistedRunState) {
383
+ ensureDir();
384
+ state.updatedAt = new Date().toISOString();
385
+ const path = primaryRunPath(state.runId);
386
+ // Atomic write: a crash mid-write can't corrupt the live file (tmp+rename is
387
+ // atomic on the same filesystem). A .bak from the previous good save is the
388
+ // recovery fallback if the primary is somehow truncated.
389
+ writeJsonAtomicWithBackup(fs, path, state);
390
+ invalidateListCache();
391
+ // Only a terminal write can grow the terminal-run count, so only check
392
+ // the cap then — a "running"/"paused" save is on the hot path (every
393
+ // progress tick) and must not pay for a retention scan.
394
+ if (TERMINAL_RUN_STATUSES.has(state.status)) enforceRetention();
395
+ },
396
+
397
+ load(runId: string): PersistedRunState | null {
398
+ // Try the primary, then the .bak — so a corrupt primary doesn't lose the run.
399
+ for (const path of candidateRunPaths(runId)) {
400
+ const state = readJsonWithBackupRecovery<PersistedRunState>(fs, path);
401
+ if (state) return state;
402
+ }
403
+ return null;
404
+ },
405
+
406
+ list(): PersistedRunState[] {
407
+ const now = Date.now();
408
+ // Return a fresh array on every call (a cheap ref-copy) so a caller that
409
+ // sorts/reverses/mutates the result in place can't corrupt the cache — the
410
+ // pre-cache code re-parsed into a new array each call, preserve that.
411
+ if (listCache && now - listCacheAt < LIST_CACHE_TTL_MS) {
412
+ return [...listCache];
413
+ }
414
+ const result = computeList();
415
+ listCache = result;
416
+ listCacheAt = now;
417
+ return [...result];
418
+ },
419
+
420
+ delete(runId: string): boolean {
421
+ try {
422
+ return deleteRunFiles(runId);
423
+ } finally {
424
+ invalidateListCache();
425
+ }
426
+ },
427
+
428
+ acquireRunLease(runId: string): RunLease | null {
429
+ ensureDir();
430
+ const path = primaryRunPath(runId);
431
+ const lock = primaryLockPath(runId);
432
+ if (!removeStaleLegacyLock(runId)) return null;
433
+ for (let attempt = 0; attempt < 2; attempt++) {
434
+ const token = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
435
+ const payload: LockFile = {
436
+ runId,
437
+ runPath: path,
438
+ pid: process.pid,
439
+ startedAt: new Date().toISOString(),
440
+ token,
441
+ };
442
+ try {
443
+ _writeFileSync(lock, JSON.stringify(payload, null, 2), { flag: "wx" });
444
+ return { runId, token };
445
+ } catch (err) {
446
+ const code = (err as { code?: string }).code;
447
+ if (code !== "EEXIST") throw err;
448
+ const existing = readLock(runId);
449
+ if (existing && existing.runPath === path && pidIsAlive(existing.pid)) {
450
+ return null;
451
+ }
452
+ try {
453
+ _unlinkSync(lock);
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
458
+ }
459
+ return null;
460
+ },
461
+
462
+ releaseRunLease(lease: RunLease): void {
463
+ try {
464
+ const existing = readLock(lease.runId);
465
+ if (existing?.token === lease.token) _unlinkSync(primaryLockPath(lease.runId));
466
+ } catch {
467
+ // Best-effort cleanup only.
468
+ }
469
+ },
470
+
471
+ getRunsDir(): string {
472
+ return runsDir;
473
+ },
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Generate a unique run ID.
479
+ */
480
+ export function generateRunId(): string {
481
+ const timestamp = Date.now().toString(36);
482
+ const random = Math.random().toString(36).slice(2, 8);
483
+ return `${timestamp}-${random}`;
484
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Saved workflows as `/<name>` slash commands. Each saved workflow becomes a
3
+ * command that runs its script, passing parsed arguments through as `args`.
4
+ */
5
+
6
+ import { createCodingTools, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { runWorkflow, type WorkflowRunResult } from "./workflow.js";
8
+ import type { WorkflowManager } from "./workflow-manager.js";
9
+ import type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
10
+
11
+ function isRegistered(pi: ExtensionAPI, name: string): boolean {
12
+ try {
13
+ return (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === name);
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ function reportText(result: WorkflowRunResult): string {
20
+ const r = result.result as { report?: unknown } | undefined;
21
+ if (r && typeof r.report === "string" && r.report.trim()) return r.report;
22
+ return JSON.stringify(result.result, null, 2);
23
+ }
24
+
25
+ /**
26
+ * Parse a command argument string into an `args` object for the script.
27
+ * Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
28
+ * Declared parameter defaults fill in missing keys.
29
+ */
30
+ export function parseCommandArgs(raw: string, parameters?: SavedWorkflow["parameters"]): Record<string, unknown> {
31
+ const out: Record<string, unknown> = {};
32
+ const positional: string[] = [];
33
+ for (const tok of raw.trim().split(/\s+/).filter(Boolean)) {
34
+ const eq = tok.indexOf("=");
35
+ if (eq > 0) out[tok.slice(0, eq)] = tok.slice(eq + 1);
36
+ else positional.push(tok);
37
+ }
38
+ out._ = positional.join(" ");
39
+ out._raw = raw.trim();
40
+ for (const [key, spec] of Object.entries(parameters ?? {})) {
41
+ if (out[key] === undefined && spec.default !== undefined) out[key] = spec.default;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Register one saved workflow as a `/<name>` command (idempotent).
47
+ * When a WorkflowManager is provided, the workflow runs through it (visible in
48
+ * /workflows TUI, background execution, task panel). Otherwise falls back to
49
+ * the inline runWorkflow() (foreground, no TUI tracking).
50
+ *
51
+ * Pi has no `unregisterCommand`, so a command cannot be removed mid-session
52
+ * after its workflow is deleted (it is correctly gone on next launch, since
53
+ * registerAllSavedWorkflows only registers what's in storage). The optional
54
+ * `exists` predicate lets the handler detect that case at invocation time and
55
+ * tell the user to reload rather than silently re-running a deleted workflow. */
56
+ export function registerSavedWorkflow(
57
+ pi: ExtensionAPI,
58
+ cwd: string,
59
+ wf: SavedWorkflow,
60
+ manager?: WorkflowManager,
61
+ exists?: () => boolean,
62
+ ): void {
63
+ if (isRegistered(pi, wf.name)) return;
64
+ pi.registerCommand(wf.name, {
65
+ description: wf.description || `Saved workflow: ${wf.name}`,
66
+ async handler(args: string, ctx: ExtensionCommandContext) {
67
+ if (exists && !exists()) {
68
+ ctx.ui.notify(`/${wf.name} was deleted — reload the session to remove this command.`, "warning");
69
+ return;
70
+ }
71
+ try {
72
+ if (manager) {
73
+ // Run through the WorkflowManager's background path: the handler
74
+ // returns immediately (awaiting the promise here would block the whole
75
+ // session, #104), progress shows in the /workflows TUI and task panel,
76
+ // and installResultDelivery posts the result back into the
77
+ // conversation on completion — sending it here too would duplicate it.
78
+ const { runId } = manager.startInBackground(wf.script, parseCommandArgs(args, wf.parameters));
79
+ ctx.ui.notify(
80
+ `/${wf.name} running in the background (${runId}) — watch the task panel or /workflows; the result is posted here when it finishes.`,
81
+ "info",
82
+ );
83
+ return;
84
+ }
85
+ // Fallback: inline runWorkflow (foreground, no TUI tracking, blocks).
86
+ ctx.ui.notify(`Starting /${wf.name}…`, "info");
87
+ const result = await runWorkflow(wf.script, {
88
+ cwd,
89
+ args: parseCommandArgs(args, wf.parameters),
90
+ tools: createCodingTools(cwd),
91
+ onPhase: (title) => ctx.ui.setStatus(`wf:${wf.name}`, `${wf.name}: ${title}`),
92
+ });
93
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
94
+ await pi.sendMessage({ customType: `workflow:${wf.name}`, content: reportText(result), display: true });
95
+ } catch (error) {
96
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
97
+ ctx.ui.notify(`/${wf.name} failed: ${error instanceof Error ? error.message : error}`, "error");
98
+ }
99
+ },
100
+ });
101
+ }
102
+
103
+ /** Register every saved workflow found in storage.
104
+ * When a WorkflowManager is provided, workflows run through it (visible in
105
+ * /workflows TUI, background execution, task panel). */
106
+ export function registerAllSavedWorkflows(
107
+ pi: ExtensionAPI,
108
+ cwd: string,
109
+ storage: WorkflowStorage,
110
+ manager?: WorkflowManager,
111
+ ): void {
112
+ for (const wf of storage.list()) {
113
+ registerSavedWorkflow(pi, cwd, wf, manager, () => storage.list().some((w) => w.name === wf.name));
114
+ }
115
+ }