@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,133 @@
1
+ /**
2
+ * Model tier configuration for workflow subagent model routing.
3
+ *
4
+ * A tier is a named slot (small/medium/big) holding exactly ONE model spec
5
+ * string (e.g. "openai/gpt-4.1-mini" or "openai-codex/gpt-5.5:xhigh").
6
+ * When an agent() call specifies opts.tier, that single model is resolved with
7
+ * Pi CLI-style parsing and used as the subagent's model/thinking level (unless
8
+ * an explicit opts.model is given, which always wins — see agent.ts).
9
+ *
10
+ * This augments the phase-pattern routing in model-routing.ts: phase routing
11
+ * maps workflow phases → models via the script's meta; tiers give scripts a
12
+ * coarse, user-configurable small/medium/big knob that is independent of any
13
+ * concrete provider/model id.
14
+ */
15
+ /**
16
+ * Model tier configuration. Maps tier names (e.g. "small", "medium", "big")
17
+ * to a single model spec string (e.g. "gpt-4.1-mini", "openai/gpt-4.1-mini",
18
+ * or "openai-codex/gpt-5.5:xhigh").
19
+ */
20
+ export interface ModelTierConfig {
21
+ tiers: Record<string, string>;
22
+ }
23
+ /**
24
+ * The minimal projection of a model that tier ranking needs. Deliberately NOT
25
+ * the SDK's full `Model` type: tier logic depends only on these three fields,
26
+ * so it stays decoupled from the SDK (no `@earendil-works/pi-ai` import here)
27
+ * and is trivially unit-testable with plain objects. `agent.ts`'s
28
+ * `listAvailableModels()` produces these from the live registry.
29
+ */
30
+ export interface RankableModel {
31
+ /** Canonical "provider/id" spec string. */
32
+ spec: string;
33
+ /** Per-token output price, if the registry reports one. Missing or 0 = unknown. */
34
+ costOutput?: number;
35
+ /** Context window size, if the registry reports one. */
36
+ contextWindow?: number;
37
+ }
38
+ /** Path to the model tiers JSON config file (~/.pi/workflows/model-tiers.json). */
39
+ export declare function getModelTierConfigPath(): string;
40
+ /**
41
+ * Substrings that identify small/cheap models (case-insensitive), used only as
42
+ * a fallback capability hint when price signals are absent or tied.
43
+ */
44
+ export declare const SMALL_MODEL_HINTS: readonly ["mini", "flash", "haiku", "nano", "small"];
45
+ /**
46
+ * Substrings that identify large/capable models (case-insensitive), used only
47
+ * as a fallback capability hint when price signals are absent or tied.
48
+ */
49
+ export declare const BIG_MODEL_HINTS: readonly ["opus", "pro", "ultra", "large", "plus"];
50
+ /**
51
+ * Fallback capability hint from a model's name: -1 for a small/cheap name, +1
52
+ * for a large/capable name, 0 otherwise. If a name matches both sets, the small
53
+ * hint wins (we never want a "mini"-labelled model to outrank a neutral or
54
+ * clearly-large one). This is only a FALLBACK: `rankByCapability` prefers the
55
+ * registry's price signal, which is robust to new vendor names (e.g. "fable",
56
+ * "mimo") that match no hint and would otherwise all score 0.
57
+ */
58
+ export declare function hintScore(spec: string): number;
59
+ /**
60
+ * Rank models from least → most capable.
61
+ *
62
+ * PRIMARY signal is output price (higher price ≈ more capable): within a single
63
+ * registry, price tracks the vendor's capability tier far more robustly than
64
+ * model-name substrings, and it works for models whose names match no hint.
65
+ *
66
+ * Models with an UNKNOWN price (missing or 0 — common for self-hosted
67
+ * `models.json` entries) are NOT treated as "cheapest = weakest". Instead they
68
+ * are projected onto the known price range via their substring hint: a
69
+ * big-hint name lands at the top of the range, a small-hint name at the bottom,
70
+ * a neutral name at the middle. When NO model has a known price at all, this
71
+ * degrades to pure hint ordering (the previous behavior).
72
+ *
73
+ * The comparison is a single total order (projected cost → hint → contextWindow
74
+ * → stable registry index), so the sort is transitive and stable.
75
+ */
76
+ export declare function rankByCapability(models: readonly RankableModel[]): RankableModel[];
77
+ /**
78
+ * Build a default tier config. When the available model registry is known,
79
+ * spread it across tiers so small/medium/big routing is meaningful out of the
80
+ * box. When the registry is empty or unavailable, fall back to the current Pi
81
+ * model so fresh installs still get usable tier values.
82
+ *
83
+ * Models are first ranked least → most capable via `rankByCapability` (price
84
+ * first, name-substring hint as fallback). Tiers are then assigned from this
85
+ * single ranked pool with exclusion — each model is used for at most one tier —
86
+ * so distinct tiers never collapse onto the same model and a weaker model can
87
+ * never outrank a stronger one (no inversion):
88
+ *
89
+ * - big = the most capable model (last in the ranking)
90
+ * - small = the least capable model (first in the ranking)
91
+ * - medium = the middle-ranked model
92
+ *
93
+ * When fewer than 3 distinct models are available, this degrades gracefully by
94
+ * reusing the *strongest* available model for the higher tier(s):
95
+ *
96
+ * - 2 models: small = weaker, medium = big = stronger
97
+ * - 1 / 0 models: small = medium = big = that model (or the current model /
98
+ * "" fallback)
99
+ *
100
+ * `availableModels` is injectable for testing and for callers that already
101
+ * fetched the registry. When omitted, this reads from the live registry.
102
+ */
103
+ export declare function buildDefaultTierConfig(currentModelSpec?: string, availableModels?: readonly RankableModel[]): ModelTierConfig;
104
+ /**
105
+ * One-time notice shown when an agent requests `opts.tier` but no
106
+ * model-tiers.json is configured — in that state tiers silently fall back to
107
+ * the session model (see `resolveAgentModelSpec` in agent.ts), which is easy to
108
+ * miss. This surfaces the fallback and the mapping the user *would* get by
109
+ * configuring, using the same `buildDefaultTierConfig` ranking so the hint is
110
+ * actionable. Pure/string-only so the caller owns how it's emitted.
111
+ */
112
+ export declare function formatTierFallbackNotice(mainModel: string | undefined, availableModels: readonly RankableModel[]): string;
113
+ /**
114
+ * Load the model tier config from disk. Returns null if the file does not
115
+ * exist or is unparseable (callers fall back to a default).
116
+ */
117
+ export declare function loadModelTierConfig(configPath?: string): ModelTierConfig | null;
118
+ /**
119
+ * Save a model tier config to disk. Creates parent directories if needed.
120
+ *
121
+ * Refuses a degenerate `tiers` (e.g. all-empty-string, from buildDefaultTierConfig
122
+ * with an empty model registry) using the same isValidTiersMap check the loader
123
+ * uses — otherwise the write side could produce exactly the shape loadModelTierConfig
124
+ * now rejects on the next read, silently discarding the "saved" config.
125
+ */
126
+ export declare function saveModelTierConfig(config: ModelTierConfig, configPath?: string): void;
127
+ /**
128
+ * Resolve a tier name to its configured model spec, or undefined if the tier
129
+ * is not configured.
130
+ */
131
+ export declare function resolveTierModel(tier: string, config: ModelTierConfig): string | undefined;
132
+ /** Return all tier names sorted: small < medium < big, then alphabetically. */
133
+ export declare function sortedTierNames(config: ModelTierConfig): string[];
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Model tier configuration for workflow subagent model routing.
3
+ *
4
+ * A tier is a named slot (small/medium/big) holding exactly ONE model spec
5
+ * string (e.g. "openai/gpt-4.1-mini" or "openai-codex/gpt-5.5:xhigh").
6
+ * When an agent() call specifies opts.tier, that single model is resolved with
7
+ * Pi CLI-style parsing and used as the subagent's model/thinking level (unless
8
+ * an explicit opts.model is given, which always wins — see agent.ts).
9
+ *
10
+ * This augments the phase-pattern routing in model-routing.ts: phase routing
11
+ * maps workflow phases → models via the script's meta; tiers give scripts a
12
+ * coarse, user-configurable small/medium/big knob that is independent of any
13
+ * concrete provider/model id.
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { listAvailableModels } from "./agent.js";
19
+ import { MODEL_TIERS_FILE } from "./config.js";
20
+ // ---------------------------------------------------------------------------
21
+ // Configuration path
22
+ // ---------------------------------------------------------------------------
23
+ /** Path to the model tiers JSON config file (~/.pi/workflows/model-tiers.json). */
24
+ export function getModelTierConfigPath() {
25
+ return join(homedir(), MODEL_TIERS_FILE);
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Capability signal
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Substrings that identify small/cheap models (case-insensitive), used only as
32
+ * a fallback capability hint when price signals are absent or tied.
33
+ */
34
+ export const SMALL_MODEL_HINTS = ["mini", "flash", "haiku", "nano", "small"];
35
+ /**
36
+ * Substrings that identify large/capable models (case-insensitive), used only
37
+ * as a fallback capability hint when price signals are absent or tied.
38
+ */
39
+ export const BIG_MODEL_HINTS = ["opus", "pro", "ultra", "large", "plus"];
40
+ /**
41
+ * Fallback capability hint from a model's name: -1 for a small/cheap name, +1
42
+ * for a large/capable name, 0 otherwise. If a name matches both sets, the small
43
+ * hint wins (we never want a "mini"-labelled model to outrank a neutral or
44
+ * clearly-large one). This is only a FALLBACK: `rankByCapability` prefers the
45
+ * registry's price signal, which is robust to new vendor names (e.g. "fable",
46
+ * "mimo") that match no hint and would otherwise all score 0.
47
+ */
48
+ export function hintScore(spec) {
49
+ const lower = spec.toLowerCase();
50
+ if (SMALL_MODEL_HINTS.some((hint) => lower.includes(hint)))
51
+ return -1;
52
+ if (BIG_MODEL_HINTS.some((hint) => lower.includes(hint)))
53
+ return 1;
54
+ return 0;
55
+ }
56
+ /**
57
+ * Rank models from least → most capable.
58
+ *
59
+ * PRIMARY signal is output price (higher price ≈ more capable): within a single
60
+ * registry, price tracks the vendor's capability tier far more robustly than
61
+ * model-name substrings, and it works for models whose names match no hint.
62
+ *
63
+ * Models with an UNKNOWN price (missing or 0 — common for self-hosted
64
+ * `models.json` entries) are NOT treated as "cheapest = weakest". Instead they
65
+ * are projected onto the known price range via their substring hint: a
66
+ * big-hint name lands at the top of the range, a small-hint name at the bottom,
67
+ * a neutral name at the middle. When NO model has a known price at all, this
68
+ * degrades to pure hint ordering (the previous behavior).
69
+ *
70
+ * The comparison is a single total order (projected cost → hint → contextWindow
71
+ * → stable registry index), so the sort is transitive and stable.
72
+ */
73
+ export function rankByCapability(models) {
74
+ const knownCosts = models
75
+ .map((m) => m.costOutput)
76
+ .filter((c) => typeof c === "number" && c > 0)
77
+ .sort((a, b) => a - b);
78
+ const hasPriceSignal = knownCosts.length > 0;
79
+ const min = knownCosts[0];
80
+ const max = knownCosts[knownCosts.length - 1];
81
+ const median = knownCosts[Math.floor(knownCosts.length / 2)];
82
+ // Project every model onto the price axis. Undefined only when there is no
83
+ // price signal anywhere (all models unpriced) — then the sort falls through
84
+ // to the hint comparison below.
85
+ const costKey = (m) => {
86
+ if (typeof m.costOutput === "number" && m.costOutput > 0)
87
+ return m.costOutput;
88
+ if (!hasPriceSignal)
89
+ return undefined;
90
+ const hint = hintScore(m.spec);
91
+ return hint > 0 ? max : hint < 0 ? min : median;
92
+ };
93
+ return models
94
+ .map((m, index) => ({ m, index, cost: costKey(m), hint: hintScore(m.spec), ctx: m.contextWindow ?? 0 }))
95
+ .sort((a, b) => {
96
+ if (a.cost !== undefined && b.cost !== undefined && a.cost !== b.cost)
97
+ return a.cost - b.cost;
98
+ if (a.hint !== b.hint)
99
+ return a.hint - b.hint;
100
+ if (a.ctx !== b.ctx)
101
+ return a.ctx - b.ctx;
102
+ return a.index - b.index;
103
+ })
104
+ .map((entry) => entry.m);
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Defaults
108
+ // ---------------------------------------------------------------------------
109
+ /**
110
+ * Build a default tier config. When the available model registry is known,
111
+ * spread it across tiers so small/medium/big routing is meaningful out of the
112
+ * box. When the registry is empty or unavailable, fall back to the current Pi
113
+ * model so fresh installs still get usable tier values.
114
+ *
115
+ * Models are first ranked least → most capable via `rankByCapability` (price
116
+ * first, name-substring hint as fallback). Tiers are then assigned from this
117
+ * single ranked pool with exclusion — each model is used for at most one tier —
118
+ * so distinct tiers never collapse onto the same model and a weaker model can
119
+ * never outrank a stronger one (no inversion):
120
+ *
121
+ * - big = the most capable model (last in the ranking)
122
+ * - small = the least capable model (first in the ranking)
123
+ * - medium = the middle-ranked model
124
+ *
125
+ * When fewer than 3 distinct models are available, this degrades gracefully by
126
+ * reusing the *strongest* available model for the higher tier(s):
127
+ *
128
+ * - 2 models: small = weaker, medium = big = stronger
129
+ * - 1 / 0 models: small = medium = big = that model (or the current model /
130
+ * "" fallback)
131
+ *
132
+ * `availableModels` is injectable for testing and for callers that already
133
+ * fetched the registry. When omitted, this reads from the live registry.
134
+ */
135
+ export function buildDefaultTierConfig(currentModelSpec, availableModels) {
136
+ const models = availableModels ?? listAvailableModels();
137
+ const ranked = rankByCapability(models).map((m) => m.spec);
138
+ if (ranked.length >= 3) {
139
+ const small = ranked[0];
140
+ const big = ranked[ranked.length - 1];
141
+ const medium = ranked[Math.floor(ranked.length / 2)];
142
+ return { tiers: { small, medium, big } };
143
+ }
144
+ if (ranked.length === 2) {
145
+ const [weaker, stronger] = ranked;
146
+ return { tiers: { small: weaker, medium: stronger, big: stronger } };
147
+ }
148
+ const fallback = ranked[0] ?? currentModelSpec ?? "";
149
+ return {
150
+ tiers: {
151
+ small: fallback,
152
+ medium: fallback,
153
+ big: fallback,
154
+ },
155
+ };
156
+ }
157
+ /**
158
+ * One-time notice shown when an agent requests `opts.tier` but no
159
+ * model-tiers.json is configured — in that state tiers silently fall back to
160
+ * the session model (see `resolveAgentModelSpec` in agent.ts), which is easy to
161
+ * miss. This surfaces the fallback and the mapping the user *would* get by
162
+ * configuring, using the same `buildDefaultTierConfig` ranking so the hint is
163
+ * actionable. Pure/string-only so the caller owns how it's emitted.
164
+ */
165
+ export function formatTierFallbackNotice(mainModel, availableModels) {
166
+ const fallback = mainModel ?? "the session default model";
167
+ const suggested = buildDefaultTierConfig(mainModel, availableModels);
168
+ const mapping = sortedTierNames(suggested)
169
+ .map((tier) => `${tier}=${suggested.tiers[tier] || "?"}`)
170
+ .join(" ");
171
+ return (`[workflow] An agent requested opts.tier but no model-tiers.json is configured, so tiers currently ` +
172
+ `fall back to ${fallback}. Run /workflows-models to configure them` +
173
+ (mapping ? `. Suggested mapping from your available models: ${mapping}` : "."));
174
+ }
175
+ // ---------------------------------------------------------------------------
176
+ // Load / Save
177
+ // ---------------------------------------------------------------------------
178
+ /**
179
+ * True iff `value` is a usable tiers map: a plain (non-array) object with at
180
+ * least one entry, every key and value a non-empty string. Anything else
181
+ * (an array, `{}`, or a tier mapped to `""`) is treated as absent rather than
182
+ * a truthy-but-broken config — resolveTierModel would silently resolve such
183
+ * entries to `undefined`/`""` while the caller's "no model-tiers.json
184
+ * configured" warning only fires on an exactly-null config.
185
+ */
186
+ function isValidTiersMap(value) {
187
+ if (!value || typeof value !== "object" || Array.isArray(value))
188
+ return false;
189
+ const entries = Object.entries(value);
190
+ if (entries.length === 0)
191
+ return false;
192
+ return entries.every(([key, val]) => key.trim().length > 0 && typeof val === "string" && val.trim().length > 0);
193
+ }
194
+ /**
195
+ * Load the model tier config from disk. Returns null if the file does not
196
+ * exist or is unparseable (callers fall back to a default).
197
+ */
198
+ export function loadModelTierConfig(configPath) {
199
+ const path = configPath ?? getModelTierConfigPath();
200
+ if (!existsSync(path))
201
+ return null;
202
+ try {
203
+ const raw = readFileSync(path, "utf-8");
204
+ const parsed = JSON.parse(raw);
205
+ if (!parsed || typeof parsed !== "object")
206
+ return null;
207
+ if (!isValidTiersMap(parsed.tiers))
208
+ return null;
209
+ return parsed;
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ /**
216
+ * Save a model tier config to disk. Creates parent directories if needed.
217
+ *
218
+ * Refuses a degenerate `tiers` (e.g. all-empty-string, from buildDefaultTierConfig
219
+ * with an empty model registry) using the same isValidTiersMap check the loader
220
+ * uses — otherwise the write side could produce exactly the shape loadModelTierConfig
221
+ * now rejects on the next read, silently discarding the "saved" config.
222
+ */
223
+ export function saveModelTierConfig(config, configPath) {
224
+ if (!isValidTiersMap(config?.tiers)) {
225
+ throw new Error("Refusing to save a degenerate model tier config: tiers must be a non-empty map of tier name to a non-empty model spec string.");
226
+ }
227
+ const path = configPath ?? getModelTierConfigPath();
228
+ const dir = dirname(path);
229
+ if (!existsSync(dir)) {
230
+ mkdirSync(dir, { recursive: true });
231
+ }
232
+ writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // Resolve / helpers
236
+ // ---------------------------------------------------------------------------
237
+ /**
238
+ * Resolve a tier name to its configured model spec, or undefined if the tier
239
+ * is not configured.
240
+ */
241
+ export function resolveTierModel(tier, config) {
242
+ return config.tiers[tier];
243
+ }
244
+ /** Return all tier names sorted: small < medium < big, then alphabetically. */
245
+ export function sortedTierNames(config) {
246
+ const names = Object.keys(config.tiers);
247
+ const rank = { small: 0, medium: 1, big: 2 };
248
+ return names.sort((a, b) => (rank[a] ?? 99) - (rank[b] ?? 99) || a.localeCompare(b));
249
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Workflow run state persistence for pause/resume support.
3
+ */
4
+ import type { AgentUsage } from "./agent.js";
5
+ import type { AgentHistoryEntry } from "./agent-history.js";
6
+ import type { WorkflowErrorCode } from "./errors.js";
7
+ import { type PersistenceFsLayer } from "./fs-persistence.js";
8
+ export type RunStatus = "pending" | "running" | "paused" | "completed" | "failed" | "aborted";
9
+ export interface PersistedAgentState {
10
+ id: number;
11
+ /** Runtime call identity (`${runId}:${callIndex}`), used to rehydrate journaled results. */
12
+ callId?: string;
13
+ label: string;
14
+ phase?: string;
15
+ prompt: string;
16
+ status: "queued" | "running" | "done" | "error" | "skipped";
17
+ result?: unknown;
18
+ /** Compact result written by releases before full agent results were retained. */
19
+ resultPreview?: string;
20
+ error?: string;
21
+ errorCode?: WorkflowErrorCode;
22
+ recoverable?: boolean;
23
+ history?: AgentHistoryEntry[];
24
+ startedAt?: string;
25
+ endedAt?: string;
26
+ /** Tokens used by this agent (a scalar estimate when the provider reports no usage). */
27
+ tokens?: number;
28
+ /** Per-agent token usage breakdown, when the provider reported one. */
29
+ tokenUsage?: AgentUsage;
30
+ /** The model this agent ran on (provider/id), when known. */
31
+ model?: string;
32
+ }
33
+ export interface PersistedRunState {
34
+ runId: string;
35
+ workflowName: string;
36
+ script: string;
37
+ args?: unknown;
38
+ /** The pi session this run belongs to. Runs persist on disk across sessions but
39
+ * the navigator shows only the current session's runs (undefined = legacy/global). */
40
+ sessionId?: string;
41
+ status: RunStatus;
42
+ /** Why a paused run is paused (e.g. "usage_limit" when a provider quota was hit). */
43
+ pauseReason?: string;
44
+ /** Provider reset hint for a usage-limit pause, e.g. "Resets in ~3h" (verbatim). */
45
+ resetHint?: string;
46
+ phases: string[];
47
+ currentPhase?: string;
48
+ agents: PersistedAgentState[];
49
+ logs: string[];
50
+ result?: unknown;
51
+ startedAt: string;
52
+ updatedAt: string;
53
+ completedAt?: string;
54
+ durationMs?: number;
55
+ tokenUsage?: {
56
+ input: number;
57
+ output: number;
58
+ total: number;
59
+ cost?: number;
60
+ cacheRead?: number;
61
+ cacheWrite?: number;
62
+ };
63
+ /**
64
+ * Cached agent/checkpoint results for resume, keyed by deterministic call
65
+ * index. `runId` namespaces `index` (a nested workflow() call restarts its
66
+ * own callSeq at 0) — absent on journals persisted before that namespacing
67
+ * existed; see JournalEntry.runId in workflow.ts for the resume-time
68
+ * legacy-degradation behavior. `storeDelta` is this call's SharedStore
69
+ * write delta, replayed additively on resume.
70
+ */
71
+ journal?: Array<{
72
+ index: number;
73
+ runId?: string;
74
+ hash: string;
75
+ result: unknown;
76
+ storeDelta?: Record<string, unknown>;
77
+ }>;
78
+ /**
79
+ * Opt-out of auto-resume for this run (default true, i.e. eligible unless
80
+ * explicitly set to false via ExecOptions.autoResume). Set once at run start
81
+ * and carried through resumes; see UsageLimitScheduler.
82
+ */
83
+ autoResume?: boolean;
84
+ /**
85
+ * The run's resolved hard token budget, fixed at start (per-run value, else
86
+ * the manager default at the time). Resume re-applies THIS value — never the
87
+ * current default — so an explicit no-budget (`null`) or custom cap survives
88
+ * a pause/resume cycle. Absent on legacy runs (resumed unbudgeted).
89
+ */
90
+ tokenBudget?: number | null;
91
+ /**
92
+ * Named toolset tag (WorkflowManagerOptions.toolsets). ToolDefinitions are
93
+ * functions and can't be serialized, so this tag is how a resumed run (e.g.
94
+ * /deep-research with web tools) re-resolves the tool set it started with.
95
+ */
96
+ toolset?: string;
97
+ /**
98
+ * The run's resolved cap on total agents, fixed at start (per-run value,
99
+ * else undefined so runWorkflow applies its own MAX_AGENTS_PER_RUN default).
100
+ * Resume re-applies THIS value — never the manager's current default — same
101
+ * rationale as tokenBudget. Absent on legacy runs (resumed with no cap
102
+ * carried forward, i.e. runWorkflow's own default applies).
103
+ */
104
+ maxAgents?: number;
105
+ /**
106
+ * The run's resolved per-agent timeout, fixed at start (per-run value, else
107
+ * the manager default at the time). Absent on legacy runs — unlike
108
+ * tokenBudget, a legacy run's real timeout was never "no timeout" by
109
+ * omission; it was always the manager's default (pre-A1 resume always fell
110
+ * back to it), so resume applies the manager's CURRENT default for such
111
+ * runs rather than null, preserving both the run's original semantics and
112
+ * pre-fix resume behavior.
113
+ */
114
+ agentTimeoutMs?: number | null;
115
+ /**
116
+ * The run's resolved concurrency, fixed at start (per-run value, else the
117
+ * manager's concurrency at the time). Same rationale as tokenBudget.
118
+ */
119
+ concurrency?: number;
120
+ /**
121
+ * The run's resolved agent-retry count, fixed at start (per-run value, else
122
+ * the manager default at the time). Same rationale as tokenBudget.
123
+ */
124
+ agentRetries?: number;
125
+ /**
126
+ * Auto-resume attempt counter for the current usage_limit pause-cycle, owned
127
+ * and persisted by UsageLimitScheduler (best-effort). Absent/0 means no
128
+ * auto-resume attempt has been recorded yet.
129
+ */
130
+ autoResumeAttempts?: number;
131
+ }
132
+ export interface RunPersistence {
133
+ /** Save current run state. */
134
+ save(state: PersistedRunState): void;
135
+ /** Load a persisted run by ID. */
136
+ load(runId: string): PersistedRunState | null;
137
+ /** List all persisted runs. */
138
+ list(): PersistedRunState[];
139
+ /** Delete a persisted run. */
140
+ delete(runId: string): boolean;
141
+ /**
142
+ * Acquire an exclusive cross-process lease for a run. Returns null when another
143
+ * live process owns the run; stale/corrupt lock files are removed and retried.
144
+ */
145
+ acquireRunLease(runId: string): RunLease | null;
146
+ /** Release a lease previously returned by acquireRunLease(). */
147
+ releaseRunLease(lease: RunLease): void;
148
+ /** Get runs directory path. */
149
+ getRunsDir(): string;
150
+ }
151
+ export interface RunLease {
152
+ runId: string;
153
+ token: string;
154
+ }
155
+ /**
156
+ * Filesystem operations used by run persistence.
157
+ * Exposed for testing – pass overrides to inject mock implementations.
158
+ * (Alias of the shared PersistenceFsLayer — see fs-persistence.ts.)
159
+ */
160
+ export type FsLayer = PersistenceFsLayer;
161
+ /**
162
+ * Retention policy for terminal (completed/failed/aborted) runs kept on
163
+ * disk. Bounded so a long-lived project directory can't accumulate an
164
+ * unbounded number of run files (each polled/listed on every list() call).
165
+ * A run in "running" or "paused" status is NEVER counted against this cap
166
+ * or evicted by it — only genuinely finished runs age out, oldest (by
167
+ * updatedAt) first, once the terminal-run count exceeds the cap. 300 is
168
+ * generous enough to cover weeks of typical usage while keeping list()'s
169
+ * per-call directory scan bounded.
170
+ */
171
+ export declare const DEFAULT_MAX_TERMINAL_RUNS_ON_DISK = 300;
172
+ export interface RunPersistenceOptions {
173
+ /** Override DEFAULT_MAX_TERMINAL_RUNS_ON_DISK (tests; advanced tuning). */
174
+ maxTerminalRunsOnDisk?: number;
175
+ }
176
+ export declare function createRunPersistence(cwd: string, fsOverride?: Partial<FsLayer>, options?: RunPersistenceOptions): RunPersistence;
177
+ /**
178
+ * Generate a unique run ID.
179
+ */
180
+ export declare function generateRunId(): string;