@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,294 @@
1
+ /**
2
+ * Workflow run state persistence for pause/resume support.
3
+ */
4
+ import { join } from "node:path";
5
+ import { ensureDir as ensureDirFs, listJsonFilesSafe, readJsonWithBackupRecovery, resolvePersistenceFs, unlinkIfExistsSafe, writeJsonAtomicWithBackup, } from "./fs-persistence.js";
6
+ import { workflowProjectPaths } from "./workflow-paths.js";
7
+ /**
8
+ * Retention policy for terminal (completed/failed/aborted) runs kept on
9
+ * disk. Bounded so a long-lived project directory can't accumulate an
10
+ * unbounded number of run files (each polled/listed on every list() call).
11
+ * A run in "running" or "paused" status is NEVER counted against this cap
12
+ * or evicted by it — only genuinely finished runs age out, oldest (by
13
+ * updatedAt) first, once the terminal-run count exceeds the cap. 300 is
14
+ * generous enough to cover weeks of typical usage while keeping list()'s
15
+ * per-call directory scan bounded.
16
+ */
17
+ export const DEFAULT_MAX_TERMINAL_RUNS_ON_DISK = 300;
18
+ const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "aborted"]);
19
+ /**
20
+ * `list()` does a full readdirSync + per-file readFileSync + JSON.parse of the
21
+ * entire lifetime run history. It is called on essentially every progress tick
22
+ * (task-panel re-render → WorkflowManager.listRuns()/listAllRuns()), so an
23
+ * unbounded number of ticks each re-walked and re-parsed every run file on
24
+ * disk. Cache the computed list for a short TTL — long enough to absorb a
25
+ * burst of same-tick reads, short enough that a read from a DIFFERENT process
26
+ * (or a mutation this instance doesn't own) still shows up quickly. Mirrors
27
+ * the ~1s settings-read TTL cache in task-panel.ts.
28
+ */
29
+ const LIST_CACHE_TTL_MS = 300;
30
+ export function createRunPersistence(cwd, fsOverride, options) {
31
+ const fs = resolvePersistenceFs(fsOverride);
32
+ const _existsSync = fs.existsSync;
33
+ const _readFileSync = fs.readFileSync;
34
+ const _statSync = fs.statSync;
35
+ const _unlinkSync = fs.unlinkSync;
36
+ const _writeFileSync = fs.writeFileSync;
37
+ const maxTerminalRunsOnDisk = options?.maxTerminalRunsOnDisk ?? DEFAULT_MAX_TERMINAL_RUNS_ON_DISK;
38
+ const paths = workflowProjectPaths(cwd);
39
+ const runsDir = paths.runsDir;
40
+ const legacyRunsDir = paths.legacyRunsDir;
41
+ const ensureDir = () => ensureDirFs(fs, runsDir);
42
+ const runPath = (dir, runId) => join(dir, `${runId}.json`);
43
+ const primaryRunPath = (runId) => runPath(runsDir, runId);
44
+ const legacyRunPath = (runId) => runPath(legacyRunsDir, runId);
45
+ const lockPath = (dir, runId) => join(dir, `${runId}.lock`);
46
+ const primaryLockPath = (runId) => lockPath(runsDir, runId);
47
+ const legacyLockPath = (runId) => lockPath(legacyRunsDir, runId);
48
+ const candidateRunPaths = (runId) => [primaryRunPath(runId), legacyRunPath(runId)];
49
+ const pidIsAlive = (pid) => {
50
+ if (!Number.isInteger(pid) || pid <= 0)
51
+ return false;
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ }
56
+ catch (err) {
57
+ if (err.code === "EPERM")
58
+ return true;
59
+ return false;
60
+ }
61
+ };
62
+ const readLockAt = (path) => {
63
+ try {
64
+ return JSON.parse(_readFileSync(path, "utf-8"));
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ };
70
+ const readLock = (runId) => readLockAt(primaryLockPath(runId));
71
+ // list() cache: recomputed lazily, invalidated synchronously by every
72
+ // mutation this instance performs (save()/delete()) so a stale read can
73
+ // never outlive a mutation this process made. A read from another process
74
+ // (or a direct fs write bypassing this instance) is picked up once the TTL
75
+ // elapses, same as before this cache existed on the next un-cached call.
76
+ let listCache;
77
+ let listCacheAt = 0;
78
+ const invalidateListCache = () => {
79
+ listCache = undefined;
80
+ };
81
+ // Per-file mtime+size+ino cache, keyed by absolute path: even once the
82
+ // TTL-level listCache above expires (the active panel polls roughly every
83
+ // 300ms, i.e. faster than or comparable to the TTL), most run files on
84
+ // disk haven't changed since the last recompute. Re-stat is cheap; re-read
85
+ // + re-JSON.parse is not, and scales with total lifetime run history, not
86
+ // with what actually changed. A file whose (mtimeMs, size, ino) all match
87
+ // what we last parsed is reused as-is instead of being re-read; entries
88
+ // for files that vanished between recomputes are pruned so this cache
89
+ // can't grow unbounded independent of what's actually on disk.
90
+ //
91
+ // ino is load-bearing, not redundant with mtime+size: save() writes via
92
+ // tmp-write + rename (writeJsonAtomicWithBackup), and a rename onto an
93
+ // existing path allocates a NEW inode for the replacement file. Two
94
+ // consecutive saves landing in the same mtime tick (400ms-throttled
95
+ // progress persists vs. 1-2s mtime granularity on HFS+/many network
96
+ // mounts/some Docker volume drivers is entirely realistic) with
97
+ // coincidentally equal byte length (e.g. "paused" and "failed" are the
98
+ // same length) would otherwise be indistinguishable from "unchanged" by
99
+ // (mtimeMs, size) alone — serving stale, previously-cached content
100
+ // forever until something ELSE about the file changes. The inode always
101
+ // changes on such a rename, so adding it closes that hole for free.
102
+ const fileStateCache = new Map();
103
+ const removeStaleLegacyLock = (runId) => {
104
+ const lock = legacyLockPath(runId);
105
+ const existing = readLockAt(lock);
106
+ if (existing?.runId === runId && pidIsAlive(existing.pid))
107
+ return false;
108
+ try {
109
+ if (_existsSync(lock))
110
+ _unlinkSync(lock);
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ return true;
116
+ };
117
+ const computeList = () => {
118
+ const byRunId = new Map();
119
+ const seenPaths = new Set();
120
+ for (const dir of [runsDir, legacyRunsDir]) {
121
+ for (const file of listJsonFilesSafe(fs, dir)) {
122
+ const path = join(dir, file);
123
+ seenPaths.add(path);
124
+ try {
125
+ const stat = _statSync(path);
126
+ const cached = fileStateCache.get(path);
127
+ // Reuse the last parse when the file is byte-identical (same
128
+ // mtime + size + inode) to what produced it — the dominant case
129
+ // on every poll tick once a run goes terminal and stops changing.
130
+ // ino is what actually rules out a false "unchanged" match on a
131
+ // coarse-mtime filesystem (see the field doc comment above).
132
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size && cached.ino === stat.ino) {
133
+ if (!byRunId.has(cached.state.runId))
134
+ byRunId.set(cached.state.runId, cached.state);
135
+ continue;
136
+ }
137
+ const state = JSON.parse(_readFileSync(path, "utf-8"));
138
+ fileStateCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, ino: stat.ino, state });
139
+ if (!byRunId.has(state.runId))
140
+ byRunId.set(state.runId, state);
141
+ }
142
+ catch {
143
+ // Skip corrupted/unreadable files; don't let a stale cache entry
144
+ // for a file that's now failing to read linger either.
145
+ fileStateCache.delete(path);
146
+ }
147
+ }
148
+ }
149
+ // Prune cache entries for files that no longer exist (deleted runs) so
150
+ // this map's size tracks what's actually on disk, not lifetime history.
151
+ for (const path of fileStateCache.keys()) {
152
+ if (!seenPaths.has(path))
153
+ fileStateCache.delete(path);
154
+ }
155
+ return [...byRunId.values()].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
156
+ };
157
+ // Bound the number of terminal (completed/failed/aborted) runs kept on
158
+ // disk (see DEFAULT_MAX_TERMINAL_RUNS_ON_DISK) — called after every save()
159
+ // whose state is terminal, since that's the only time the terminal count
160
+ // can grow. Running/paused runs are never candidates: they're filtered out
161
+ // before the cap is even considered.
162
+ const enforceRetention = () => {
163
+ const terminal = computeList()
164
+ .filter((r) => TERMINAL_RUN_STATUSES.has(r.status))
165
+ .sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
166
+ const excess = terminal.length - maxTerminalRunsOnDisk;
167
+ if (excess <= 0)
168
+ return;
169
+ for (const run of terminal.slice(0, excess)) {
170
+ deleteRunFiles(run.runId);
171
+ }
172
+ invalidateListCache();
173
+ };
174
+ const deleteRunFiles = (runId) => {
175
+ let deleted = false;
176
+ for (const path of candidateRunPaths(runId)) {
177
+ const dir = path === primaryRunPath(runId) ? runsDir : legacyRunsDir;
178
+ // Best-effort cleanup of the sidecar files alongside the primary.
179
+ for (const sidecar of [`${path}.bak`, `${path}.tmp`, lockPath(dir, runId)]) {
180
+ unlinkIfExistsSafe(fs, sidecar);
181
+ fileStateCache.delete(sidecar);
182
+ }
183
+ if (unlinkIfExistsSafe(fs, path))
184
+ deleted = true;
185
+ fileStateCache.delete(path);
186
+ }
187
+ return deleted;
188
+ };
189
+ return {
190
+ save(state) {
191
+ ensureDir();
192
+ state.updatedAt = new Date().toISOString();
193
+ const path = primaryRunPath(state.runId);
194
+ // Atomic write: a crash mid-write can't corrupt the live file (tmp+rename is
195
+ // atomic on the same filesystem). A .bak from the previous good save is the
196
+ // recovery fallback if the primary is somehow truncated.
197
+ writeJsonAtomicWithBackup(fs, path, state);
198
+ invalidateListCache();
199
+ // Only a terminal write can grow the terminal-run count, so only check
200
+ // the cap then — a "running"/"paused" save is on the hot path (every
201
+ // progress tick) and must not pay for a retention scan.
202
+ if (TERMINAL_RUN_STATUSES.has(state.status))
203
+ enforceRetention();
204
+ },
205
+ load(runId) {
206
+ // Try the primary, then the .bak — so a corrupt primary doesn't lose the run.
207
+ for (const path of candidateRunPaths(runId)) {
208
+ const state = readJsonWithBackupRecovery(fs, path);
209
+ if (state)
210
+ return state;
211
+ }
212
+ return null;
213
+ },
214
+ list() {
215
+ const now = Date.now();
216
+ // Return a fresh array on every call (a cheap ref-copy) so a caller that
217
+ // sorts/reverses/mutates the result in place can't corrupt the cache — the
218
+ // pre-cache code re-parsed into a new array each call, preserve that.
219
+ if (listCache && now - listCacheAt < LIST_CACHE_TTL_MS) {
220
+ return [...listCache];
221
+ }
222
+ const result = computeList();
223
+ listCache = result;
224
+ listCacheAt = now;
225
+ return [...result];
226
+ },
227
+ delete(runId) {
228
+ try {
229
+ return deleteRunFiles(runId);
230
+ }
231
+ finally {
232
+ invalidateListCache();
233
+ }
234
+ },
235
+ acquireRunLease(runId) {
236
+ ensureDir();
237
+ const path = primaryRunPath(runId);
238
+ const lock = primaryLockPath(runId);
239
+ if (!removeStaleLegacyLock(runId))
240
+ return null;
241
+ for (let attempt = 0; attempt < 2; attempt++) {
242
+ const token = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
243
+ const payload = {
244
+ runId,
245
+ runPath: path,
246
+ pid: process.pid,
247
+ startedAt: new Date().toISOString(),
248
+ token,
249
+ };
250
+ try {
251
+ _writeFileSync(lock, JSON.stringify(payload, null, 2), { flag: "wx" });
252
+ return { runId, token };
253
+ }
254
+ catch (err) {
255
+ const code = err.code;
256
+ if (code !== "EEXIST")
257
+ throw err;
258
+ const existing = readLock(runId);
259
+ if (existing && existing.runPath === path && pidIsAlive(existing.pid)) {
260
+ return null;
261
+ }
262
+ try {
263
+ _unlinkSync(lock);
264
+ }
265
+ catch {
266
+ return null;
267
+ }
268
+ }
269
+ }
270
+ return null;
271
+ },
272
+ releaseRunLease(lease) {
273
+ try {
274
+ const existing = readLock(lease.runId);
275
+ if (existing?.token === lease.token)
276
+ _unlinkSync(primaryLockPath(lease.runId));
277
+ }
278
+ catch {
279
+ // Best-effort cleanup only.
280
+ }
281
+ },
282
+ getRunsDir() {
283
+ return runsDir;
284
+ },
285
+ };
286
+ }
287
+ /**
288
+ * Generate a unique run ID.
289
+ */
290
+ export function generateRunId() {
291
+ const timestamp = Date.now().toString(36);
292
+ const random = Math.random().toString(36).slice(2, 8);
293
+ return `${timestamp}-${random}`;
294
+ }
@@ -0,0 +1,28 @@
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
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { WorkflowManager } from "./workflow-manager.js";
7
+ import type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
8
+ /**
9
+ * Parse a command argument string into an `args` object for the script.
10
+ * Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
11
+ * Declared parameter defaults fill in missing keys.
12
+ */
13
+ export declare function parseCommandArgs(raw: string, parameters?: SavedWorkflow["parameters"]): Record<string, unknown>;
14
+ /** Register one saved workflow as a `/<name>` command (idempotent).
15
+ * When a WorkflowManager is provided, the workflow runs through it (visible in
16
+ * /workflows TUI, background execution, task panel). Otherwise falls back to
17
+ * the inline runWorkflow() (foreground, no TUI tracking).
18
+ *
19
+ * Pi has no `unregisterCommand`, so a command cannot be removed mid-session
20
+ * after its workflow is deleted (it is correctly gone on next launch, since
21
+ * registerAllSavedWorkflows only registers what's in storage). The optional
22
+ * `exists` predicate lets the handler detect that case at invocation time and
23
+ * tell the user to reload rather than silently re-running a deleted workflow. */
24
+ export declare function registerSavedWorkflow(pi: ExtensionAPI, cwd: string, wf: SavedWorkflow, manager?: WorkflowManager, exists?: () => boolean): void;
25
+ /** Register every saved workflow found in storage.
26
+ * When a WorkflowManager is provided, workflows run through it (visible in
27
+ * /workflows TUI, background execution, task panel). */
28
+ export declare function registerAllSavedWorkflows(pi: ExtensionAPI, cwd: string, storage: WorkflowStorage, manager?: WorkflowManager): void;
@@ -0,0 +1,100 @@
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
+ import { createCodingTools } from "@earendil-works/pi-coding-agent";
6
+ import { runWorkflow } from "./workflow.js";
7
+ function isRegistered(pi, name) {
8
+ try {
9
+ return (pi.getCommands?.() ?? []).some((c) => c.name === name);
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ function reportText(result) {
16
+ const r = result.result;
17
+ if (r && typeof r.report === "string" && r.report.trim())
18
+ return r.report;
19
+ return JSON.stringify(result.result, null, 2);
20
+ }
21
+ /**
22
+ * Parse a command argument string into an `args` object for the script.
23
+ * Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
24
+ * Declared parameter defaults fill in missing keys.
25
+ */
26
+ export function parseCommandArgs(raw, parameters) {
27
+ const out = {};
28
+ const positional = [];
29
+ for (const tok of raw.trim().split(/\s+/).filter(Boolean)) {
30
+ const eq = tok.indexOf("=");
31
+ if (eq > 0)
32
+ out[tok.slice(0, eq)] = tok.slice(eq + 1);
33
+ else
34
+ positional.push(tok);
35
+ }
36
+ out._ = positional.join(" ");
37
+ out._raw = raw.trim();
38
+ for (const [key, spec] of Object.entries(parameters ?? {})) {
39
+ if (out[key] === undefined && spec.default !== undefined)
40
+ out[key] = spec.default;
41
+ }
42
+ return out;
43
+ }
44
+ /** Register one saved workflow as a `/<name>` command (idempotent).
45
+ * When a WorkflowManager is provided, the workflow runs through it (visible in
46
+ * /workflows TUI, background execution, task panel). Otherwise falls back to
47
+ * the inline runWorkflow() (foreground, no TUI tracking).
48
+ *
49
+ * Pi has no `unregisterCommand`, so a command cannot be removed mid-session
50
+ * after its workflow is deleted (it is correctly gone on next launch, since
51
+ * registerAllSavedWorkflows only registers what's in storage). The optional
52
+ * `exists` predicate lets the handler detect that case at invocation time and
53
+ * tell the user to reload rather than silently re-running a deleted workflow. */
54
+ export function registerSavedWorkflow(pi, cwd, wf, manager, exists) {
55
+ if (isRegistered(pi, wf.name))
56
+ return;
57
+ pi.registerCommand(wf.name, {
58
+ description: wf.description || `Saved workflow: ${wf.name}`,
59
+ async handler(args, ctx) {
60
+ if (exists && !exists()) {
61
+ ctx.ui.notify(`/${wf.name} was deleted — reload the session to remove this command.`, "warning");
62
+ return;
63
+ }
64
+ try {
65
+ if (manager) {
66
+ // Run through the WorkflowManager's background path: the handler
67
+ // returns immediately (awaiting the promise here would block the whole
68
+ // session, #104), progress shows in the /workflows TUI and task panel,
69
+ // and installResultDelivery posts the result back into the
70
+ // conversation on completion — sending it here too would duplicate it.
71
+ const { runId } = manager.startInBackground(wf.script, parseCommandArgs(args, wf.parameters));
72
+ ctx.ui.notify(`/${wf.name} running in the background (${runId}) — watch the task panel or /workflows; the result is posted here when it finishes.`, "info");
73
+ return;
74
+ }
75
+ // Fallback: inline runWorkflow (foreground, no TUI tracking, blocks).
76
+ ctx.ui.notify(`Starting /${wf.name}…`, "info");
77
+ const result = await runWorkflow(wf.script, {
78
+ cwd,
79
+ args: parseCommandArgs(args, wf.parameters),
80
+ tools: createCodingTools(cwd),
81
+ onPhase: (title) => ctx.ui.setStatus(`wf:${wf.name}`, `${wf.name}: ${title}`),
82
+ });
83
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
84
+ await pi.sendMessage({ customType: `workflow:${wf.name}`, content: reportText(result), display: true });
85
+ }
86
+ catch (error) {
87
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
88
+ ctx.ui.notify(`/${wf.name} failed: ${error instanceof Error ? error.message : error}`, "error");
89
+ }
90
+ },
91
+ });
92
+ }
93
+ /** Register every saved workflow found in storage.
94
+ * When a WorkflowManager is provided, workflows run through it (visible in
95
+ * /workflows TUI, background execution, task panel). */
96
+ export function registerAllSavedWorkflows(pi, cwd, storage, manager) {
97
+ for (const wf of storage.list()) {
98
+ registerSavedWorkflow(pi, cwd, wf, manager, () => storage.list().some((w) => w.name === wf.name));
99
+ }
100
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * In-memory key-value store scoped to a single workflow run.
3
+ *
4
+ * One `SharedStore` instance is created at run start and disposed when the run
5
+ * ends. Two MCP-compatible tool definitions (`store_put` / `store_get`) are
6
+ * injected into every agent's tool list so parallel agents can share
7
+ * intermediate state without coordinating through the script itself.
8
+ *
9
+ * Journal integration: callers capture `store.commitDelta(deltaKey)` alongside
10
+ * each agent result in the journal. On resume, `store.applyDelta(delta)` rebuilds
11
+ * the store state additively in callSeq order, so parallel-agent writes are
12
+ * replayed correctly without the last-complete-wins ordering bug that a
13
+ * whole-Map restore() would cause.
14
+ *
15
+ * `deltaKey` must be unique across every run that shares this store instance,
16
+ * not just within one run's callSeq. A nested `workflow()` call restarts its own
17
+ * callSeq at 0 while inheriting the parent's store (so parent and nested-run
18
+ * agents can share state), so a bare callIndex would collide between a parent
19
+ * agent and a concurrently-running nested-run agent that both got index 0 —
20
+ * whichever commits its delta last would clobber the other's entry in
21
+ * `agentDeltas`. Callers compose `deltaKey` as `${runId}:${callIndex}`, and
22
+ * since every run (including each nested run) gets its own distinct `runId`,
23
+ * the composite key is unique across the whole store's lifetime.
24
+ */
25
+ import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
26
+ export declare class SharedStore {
27
+ private readonly map;
28
+ private readonly agentDeltas;
29
+ private readonly priorValues;
30
+ /** Store a value under `key`. Overwrites any existing value. */
31
+ put(key: string, value: unknown): void;
32
+ /**
33
+ * Store a value and record the write in the per-agent delta for `deltaKey`
34
+ * (a run-unique `${runId}:${callIndex}` string — see class doc). Used by
35
+ * per-agent tools created via `createAgentStoreTools` so that each agent's
36
+ * writes can be journaled and replayed independently.
37
+ */
38
+ trackPut(key: string, value: unknown, deltaKey: string): void;
39
+ /** Retrieve the value for `key`, or `undefined` when absent. */
40
+ get(key: string): unknown;
41
+ /** Whether `key` is present in the store. */
42
+ has(key: string): boolean;
43
+ /** Return a deep-copied plain-object snapshot of all entries. */
44
+ snapshot(): Record<string, unknown>;
45
+ /**
46
+ * Extract and clear the write delta accumulated for `deltaKey`.
47
+ * Called after an agent completes to get the set of keys it wrote.
48
+ */
49
+ commitDelta(deltaKey: string): Record<string, unknown>;
50
+ /**
51
+ * Undo the writes recorded for `deltaKey` and discard its bookkeeping,
52
+ * without touching any other key. Used when a retry attempt fails: that
53
+ * attempt's writes must not remain visible in the live store (e.g. to a
54
+ * concurrently-running sibling agent's store_get, or to script code reading
55
+ * `store.get` directly) and must not merge into the delta eventually
56
+ * recorded when a later attempt of the SAME call succeeds — otherwise a
57
+ * failed attempt's mutations would silently survive into the run's live
58
+ * state while being absent from the journaled delta that resume replay
59
+ * reconstructs from, leaving live execution and replay permanently
60
+ * inconsistent. Each key touched during this delta window is restored to
61
+ * whatever it held immediately before the window started (or deleted, if
62
+ * it did not exist yet) — never to some other attempt's or caller's value.
63
+ *
64
+ * Per-key guard: a key is only rolled back if the store STILL holds this
65
+ * attempt's own last write to it (checked with `Object.is` against the
66
+ * value recorded in `delta`). If a concurrently-running sibling (a
67
+ * different `deltaKey`, e.g. another agent in the same parallel() batch)
68
+ * legitimately overwrote the same key AFTER this attempt wrote it but
69
+ * BEFORE it failed, that sibling's write is left untouched — rolling back
70
+ * unconditionally would silently erase a live, unrelated write that this
71
+ * attempt never made and has no business undoing.
72
+ *
73
+ * A no-op if `deltaKey` never wrote anything (nothing to roll back).
74
+ */
75
+ discardDelta(deltaKey: string): void;
76
+ /**
77
+ * Apply a write delta additively — sets each key without clearing others.
78
+ * Used during resume replay so parallel-agent deltas applied in callSeq
79
+ * order accumulate correctly regardless of original completion order.
80
+ */
81
+ applyDelta(delta: Record<string, unknown>): void;
82
+ /**
83
+ * Replace all entries with a snapshot (for full resets).
84
+ * Prefer `applyDelta` for resume replay — see journal integration above.
85
+ */
86
+ restore(snap: Record<string, unknown>): void;
87
+ /** Clear all entries (called when the run ends). */
88
+ dispose(): void;
89
+ }
90
+ /**
91
+ * Create per-agent store tools that attribute writes to `deltaKey`, a
92
+ * run-unique `${runId}:${callIndex}` string (see the `SharedStore` class doc
93
+ * for why the bare callIndex alone is not enough once a nested `workflow()`
94
+ * call shares this store).
95
+ * Used internally by `runWorkflow` so each agent's puts are tracked in the
96
+ * store's delta journal and can be replayed additively on resume.
97
+ */
98
+ export declare function createAgentStoreTools(store: SharedStore, deltaKey: string): ToolDefinition[];