@worca/app 1.0.0 → 1.1.1

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 (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -15,7 +15,7 @@
15
15
  import { getDb, prepare, tx } from './db.mjs';
16
16
  import { projectKey } from './store.mjs';
17
17
  import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
18
- import { EFFORTS, prepareModelEnv } from './model-env.mjs';
18
+ import { EFFORTS, prepareModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
19
19
  import { listGlobalModels, addGlobalModel, removeGlobalModel } from './settings.mjs';
20
20
  import { listPluginModels, allPluginModels, flattenPluginModelEnv } from './plugin-models.mjs';
21
21
 
@@ -59,7 +59,8 @@ export { EFFORTS };
59
59
  * is intentionally omitted — the CLI rejects it ("long context beta is not yet
60
60
  * available for this subscription"). Fable 5 needs no `[1m]` suffix: its context
61
61
  * window is 1M by default (verified to resolve via `claude --model`). Opus 5
62
- * (`claude-opus-5`) is likewise 1M-only and carries no `[1m]` twin.
62
+ * (`claude-opus-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
63
+ * carry no `[1m]` twin.
63
64
  */
64
65
  export const PREDEFINED_MODELS = [
65
66
  { id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
@@ -70,6 +71,7 @@ export const PREDEFINED_MODELS = [
70
71
  { id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
71
72
  { id: 'claude-opus-4-6', label: 'Opus 4.6', efforts: ['medium', 'high', 'max'] },
72
73
  { id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
74
+ { id: 'claude-sonnet-5', label: 'Sonnet 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
73
75
  { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', efforts: ['medium', 'high', 'max'] },
74
76
  { id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
75
77
  { id: 'claude-haiku-4-5', label: 'Haiku 4.5', efforts: ['medium', 'high'] },
@@ -84,24 +86,17 @@ function defaultConfig() {
84
86
  return { steps: {}, customModels: [] };
85
87
  }
86
88
 
87
- /** Keep only known step keys carrying a non-empty model/effort and/or a fanOut/askQuestions boolean. */
89
+ /** Keep only known step keys whose entry survives cleanNodeSel ONE coercion
90
+ * rule for both scopes (a per-step entry IS a node selection; a second
91
+ * line-for-line copy here is how the two silently diverge). An unknown
92
+ * subagentModel is dropped, never stored. */
88
93
  function sanitizeSteps(steps) {
89
94
  const out = {};
90
95
  const keys = stepKeys();
91
96
  for (const [k, v] of Object.entries(steps || {})) {
92
97
  if (!keys.has(k) || !v || typeof v !== 'object') continue;
93
- const model = typeof v.model === 'string' ? v.model.trim() : '';
94
- const effort = typeof v.effort === 'string' ? v.effort.trim() : '';
95
- const fanOut = typeof v.fanOut === 'boolean' ? v.fanOut : undefined;
96
- const askQuestions = typeof v.askQuestions === 'boolean' ? v.askQuestions : undefined;
97
- if (model || effort || fanOut !== undefined || askQuestions !== undefined) {
98
- out[k] = {
99
- ...(model && { model }),
100
- ...(effort && { effort }),
101
- ...(fanOut !== undefined && { fanOut }),
102
- ...(askQuestions !== undefined && { askQuestions }),
103
- };
104
- }
98
+ const sel = cleanNodeSel(v); // hoisted declaration (defined below)
99
+ if (sel) out[k] = sel;
105
100
  }
106
101
  return out;
107
102
  }
@@ -172,7 +167,10 @@ export async function readConfig(projectDir) {
172
167
  * `custom` is false | 'global' | 'plugin' | 'project' (strings truthy, so
173
168
  * existing `m.custom` checks keep working); plugin entries also carry
174
169
  * `plugin: '<name>'`; `hasEnv` advertises routing env WITHOUT the values
175
- * (this shape feeds UI dropdowns).
170
+ * (this shape feeds UI dropdowns). `routed` advertises an ANTHROPIC_BASE_URL
171
+ * override the same way (key presence, values never leaked) — computed from the
172
+ * env objects already in scope here, NEVER by calling modelHasBaseUrlRouting per
173
+ * entry (that re-reads settings + the plugins lock from disk on every row).
176
174
  */
177
175
  function composeCatalog(projectCustom = []) {
178
176
  const globals = listGlobalModels();
@@ -183,26 +181,27 @@ function composeCatalog(projectCustom = []) {
183
181
  const out = [];
184
182
  const seen = new Set();
185
183
  const unreliable = (lc) => (flagged.has(lc) ? { costUnreliable: true } : {});
184
+ const routedOf = (env) => !!(env && 'ANTHROPIC_BASE_URL' in env);
186
185
  const pluginShape = (id, m, lc) => ({
187
186
  id, label: m.label, efforts: [...m.efforts], custom: 'plugin', plugin: m.plugin,
188
- hasEnv: !!m.env, ...unreliable(lc),
187
+ hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc),
189
188
  });
190
189
  for (const m of PREDEFINED_MODELS) {
191
190
  const lc = m.id.toLowerCase();
192
191
  const shadow = globalByIdLc.get(lc);
193
192
  const pshadow = pluginByIdLc.get(lc);
194
193
  out.push(shadow
195
- ? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, ...unreliable(lc) }
194
+ ? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, routed: routedOf(shadow.env), ...unreliable(lc) }
196
195
  : pshadow
197
196
  ? pluginShape(m.id, pshadow, lc)
198
- : { ...m, custom: false, hasEnv: false });
197
+ : { ...m, custom: false, hasEnv: false, routed: false });
199
198
  seen.add(lc);
200
199
  }
201
200
  for (const m of globals) {
202
201
  const lc = m.id.toLowerCase();
203
202
  if (seen.has(lc)) continue; // predefined shadow, already emitted
204
203
  seen.add(lc);
205
- out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, ...unreliable(lc) });
204
+ out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc) });
206
205
  }
207
206
  for (const m of plugins) {
208
207
  const lc = m.id.toLowerCase();
@@ -213,7 +212,7 @@ function composeCatalog(projectCustom = []) {
213
212
  for (const m of projectCustom) {
214
213
  if (seen.has(m.id.toLowerCase())) continue; // predefined/global/plugin wins
215
214
  seen.add(m.id.toLowerCase());
216
- out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false });
215
+ out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false, routed: false });
217
216
  }
218
217
  return out;
219
218
  }
@@ -258,11 +257,24 @@ export function costUnreliableModelIds() {
258
257
  * @param {string} modelId
259
258
  * @param {number|null} costUsd the reported cost (null when absent)
260
259
  * @param {object} [usage]
260
+ * @param {object|null} [costCfg] this model's already-looked-up cost override
261
+ * (modelCostConfig). Pass it when the caller has one in hand — every lookup is
262
+ * a fresh settings.json read (settings.mjs is deliberately uncached), and the
263
+ * result path needs the same answer for resolveModelCost. `undefined` = look
264
+ * it up here; `null` = "checked, there is none".
261
265
  * @returns {'flagged'|'cleared'|null} what changed — 'flagged' asks the caller
262
266
  * to surface its one-per-run warning; null = no observation recorded
263
267
  */
264
- export function observeModelCost(modelId, costUsd, usage) {
268
+ export function observeModelCost(modelId, costUsd, usage, costCfg = undefined) {
265
269
  if (!modelHasBaseUrlRouting(modelId)) return null;
270
+ // An explicit per-model cost override GOVERNS this model's spend (resolveModelCost
271
+ // below) — the CLI's own figure is never trusted for it, so the "unreliable"
272
+ // badge is meaningless. Never flag it, and lift any flag left from before the
273
+ // override existed. Derived state: a DB hiccup here must never fail the run.
274
+ if (costCfg !== undefined ? costCfg : modelCostConfig(modelId)) {
275
+ try { prepare('DELETE FROM model_cost_flags WHERE model_id = ?').run(String(modelId).trim()); } catch { /* derived */ }
276
+ return null;
277
+ }
266
278
  const u = usage && typeof usage === 'object' ? usage : {};
267
279
  const tokens = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']
268
280
  .reduce((n, k) => n + (Number(u[k]) || 0), 0);
@@ -284,6 +296,123 @@ export function observeModelCost(modelId, costUsd, usage) {
284
296
  return null; // no cost AND no tokens (e.g. an errored run) — no signal either way
285
297
  }
286
298
 
299
+ // ── per-model cost override (opt-in) ──────────────────────────────────────────
300
+ // The Claude CLI computes total_cost_usd from its OWN per-model price table keyed
301
+ // on the model NAME — so an on-prem/proxied endpoint (even one that returns no
302
+ // cost) still gets a fabricated dollar figure, which observeModelCost cannot
303
+ // distinguish from a real one once it is positive. A user who KNOWS a model's
304
+ // real price (or that it is free) can pin it in the GLOBAL catalog; that override
305
+ // then wins over whatever the CLI reports. Inspired by worca 0.x's cost_alias /
306
+ // worca.pricing.models mechanism. Opt-in: with no override the CLI value stands.
307
+ //
308
+ // It governs EVERY surface that books spend, because they share one windowed
309
+ // budget (cost-budget.mjs combinedWindowedSpendUsd): the orchestrator's result
310
+ // intake and sub-agent telemetry (orchestrator.mjs), an Ask Worca turn (the
311
+ // `resolveCost` hook ask/turn.mjs injects into the reducer), and the overview
312
+ // agent's telemetry row. Re-pricing only some of them would leave the phantom
313
+ // spend this exists to remove still inflating the budget from the others.
314
+
315
+ /** The explicit cost override governing a model, or null. Shape: {free:true} |
316
+ * {perMtok:{input?,output?,cacheRead?,cacheWrite?,cacheWrite1h?}} (USD per
317
+ * million tokens). Resolved with the SAME precedence as the rest of a model's
318
+ * configuration (§9.3, mirroring modelHasBaseUrlRouting): the user's GLOBAL
319
+ * entry wins outright, else the winning PLUGIN entry's manifest price — a
320
+ * plugin shipping a model on its own endpoint is exactly the case that needs
321
+ * one. Note a global entry shadows the plugin's price even when it pins none:
322
+ * taking over a model id means owning its pricing too, so the two layers can
323
+ * never half-merge. Built-ins carry none. Never throws. */
324
+ export function modelCostConfig(modelId) {
325
+ const id = typeof modelId === 'string' ? modelId.trim() : '';
326
+ if (!id) return null;
327
+ const lc = id.toLowerCase();
328
+ const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
329
+ if (entry) return entry.cost ?? null;
330
+ const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
331
+ return pm?.cost ?? null;
332
+ }
333
+
334
+ /** The four token classes read off a usage object, accepting BOTH spellings in
335
+ * play here: the RAW Claude result usage (`input_tokens`, …) that the pipeline
336
+ * path carries, and Ask Worca's normalized persisted shape (`input`, `output`,
337
+ * `cacheRead`, `cacheCreation` — ask/events.mjs normalizeUsage). Same tokens,
338
+ * two names; a missing field counts as 0. Only the raw shape ever carries the
339
+ * ephemeral cache-creation breakdown. */
340
+ function usageTokens(u) {
341
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
342
+ const pick = (snake, camel) => (u[snake] != null ? num(u[snake]) : num(u[camel]));
343
+ const cc = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
344
+ return {
345
+ input: pick('input_tokens', 'input'),
346
+ output: pick('output_tokens', 'output'),
347
+ cacheRead: pick('cache_read_input_tokens', 'cacheRead'),
348
+ cacheWrite: pick('cache_creation_input_tokens', 'cacheCreation'),
349
+ eph1h: cc ? num(cc.ephemeral_1h_input_tokens) : 0,
350
+ eph5m: cc ? num(cc.ephemeral_5m_input_tokens) : 0,
351
+ };
352
+ }
353
+
354
+ /** True when `usage` is an object that actually reports token counts — i.e. it
355
+ * can be priced. A result event that carried NO usage at all is unpriceable and
356
+ * must not be silently booked at $0 (resolveModelCost returns NaN for it); an
357
+ * object whose counts are genuinely all zero IS priceable, at $0. */
358
+ export function isPriceableUsage(usage) {
359
+ if (!usage || typeof usage !== 'object') return false;
360
+ return ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens',
361
+ 'cache_creation', 'input', 'output', 'cacheRead', 'cacheCreation'].some((k) => usage[k] != null);
362
+ }
363
+
364
+ /** Estimate USD from a `usage` object (either spelling, see usageTokens) and a
365
+ * per-million-token rate table (mirrors worca 0.x estimate_cost). Absent rates/
366
+ * fields count as 0. When the CLI breaks cache-creation into ephemeral 1h/5m
367
+ * buckets they are priced separately (1h falls back to the cacheWrite rate);
368
+ * otherwise the flat cache_creation_input_tokens total is priced at cacheWrite. */
369
+ export function estimateCost(usage, perMtok) {
370
+ if (!perMtok || typeof perMtok !== 'object') return 0;
371
+ const u = usage && typeof usage === 'object' ? usage : {};
372
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
373
+ const rate = (k) => num(perMtok[k]);
374
+ const t = usageTokens(u);
375
+ const cacheWriteCost = (t.eph1h || t.eph5m)
376
+ ? (t.eph5m * rate('cacheWrite')
377
+ + t.eph1h * (perMtok.cacheWrite1h != null ? rate('cacheWrite1h') : rate('cacheWrite'))) / 1e6
378
+ : t.cacheWrite * rate('cacheWrite') / 1e6;
379
+ return (
380
+ t.input * rate('input')
381
+ + t.output * rate('output')
382
+ + t.cacheRead * rate('cacheRead')
383
+ ) / 1e6 + cacheWriteCost;
384
+ }
385
+
386
+ /**
387
+ * The AUTHORITATIVE cost for a dispatched model: the CLI's own figure unless the
388
+ * model carries an explicit override, which then wins. {free} → $0; {perMtok} →
389
+ * recomputed from `usage`. This is what stops a CLI that prices an on-prem model
390
+ * by name from inflating the ledger. With no override, `cliCostUsd` is returned
391
+ * verbatim (finite or not — the caller already gates on Number.isFinite).
392
+ *
393
+ * ONLY call this on a genuinely cost-bearing event. A {free} model answers 0 for
394
+ * ANY input, so feeding it a non-result stream frame (whose `cliCostUsd` is NaN)
395
+ * would turn "nothing to record" into a real $0 the caller then books.
396
+ *
397
+ * A {perMtok} model is priced from tokens ALONE, so a result that carried no
398
+ * usage object at all is UNPRICEABLE: NaN comes back rather than a silent $0, so
399
+ * the caller's existing "no cost estimate" branch reports it instead of the
400
+ * operator quietly under-billing a model they explicitly asked to be priced.
401
+ * @param {string} modelId the dispatched model id
402
+ * @param {number} cliCostUsd the cost the CLI reported (may be NaN)
403
+ * @param {object} [usage] the result event's usage object (either spelling)
404
+ * @param {object|null} [costCfg] this model's already-looked-up cost override;
405
+ * `undefined` = look it up here (see observeModelCost's note on sharing it)
406
+ * @returns {number}
407
+ */
408
+ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined) {
409
+ const cost = costCfg !== undefined ? costCfg : modelCostConfig(modelId);
410
+ if (!cost) return cliCostUsd;
411
+ if (cost.free) return 0;
412
+ if (cost.perMtok) return isPriceableUsage(usage) ? estimateCost(usage, cost.perMtok) : NaN;
413
+ return cliCostUsd;
414
+ }
415
+
287
416
  /**
288
417
  * All selectable models for a project = the effective catalog (predefined ⊕
289
418
  * global ⊕ this project's legacy custom models). Legacy custom models
@@ -388,6 +517,27 @@ function inheritOr(next, prev) {
388
517
  return typeof prev === 'boolean' ? prev : undefined;
389
518
  }
390
519
 
520
+ /**
521
+ * Tri-state resolution for the STRING tunable (subagentModel), shared by setStep
522
+ * and setNodeModel. It is preserve-on-absent rather than replace-like model/effort
523
+ * on purpose: the field arrived after the write APIs shipped, so an older client
524
+ * (or any caller that only means to change the model) POSTs without it and must
525
+ * not silently wipe a configured sub-agent policy.
526
+ * a valid value -> that value
527
+ * '' or null -> undefined = cleared (an explicit "inherit again")
528
+ * absent -> the previous value
529
+ * An unknown non-empty string is treated as absent: the enum is validated at the
530
+ * API boundary, and a typo must not clear a working setting.
531
+ * @param {unknown} next
532
+ * @param {unknown} prev
533
+ * @returns {string|undefined}
534
+ */
535
+ function inheritOrSubagentModel(next, prev) {
536
+ if (isSubagentModelValue(next)) return next;
537
+ if (next === null || next === '') return undefined;
538
+ return isSubagentModelValue(prev) ? prev : undefined;
539
+ }
540
+
391
541
  /**
392
542
  * Set (or clear) the model + effort for one agent step. An empty model => inherit
393
543
  * the global/CLI default; an empty effort => model default. Effort must be supported
@@ -411,6 +561,11 @@ export async function setStep(projectDir, step, selection = {}) {
411
561
  }
412
562
  }
413
563
 
564
+ {
565
+ const issue = subagentModelIssue(selection.subagentModel);
566
+ if (issue) throw new Error(issue);
567
+ }
568
+
414
569
  const key = projectKey(projectDir);
415
570
  const cfg = readRaw(projectDir);
416
571
  const prev = cfg.steps[step] || {};
@@ -422,12 +577,16 @@ export async function setStep(projectDir, step, selection = {}) {
422
577
  // askQuestions mirrors fanOut: preserved when omitted (only the toggle sends
423
578
  // it), set when a boolean (spec 2026-07-11 §4), cleared on null.
424
579
  const askQuestions = inheritOr(selection.askQuestions, prev.askQuestions);
580
+ // subagentModel: the sub-agent model policy for a fan-out node. Preserved when
581
+ // omitted (see inheritOrSubagentModel), cleared on '' / null.
582
+ const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev.subagentModel);
425
583
 
426
584
  const steps = { ...cfg.steps };
427
- if (!model && !effort && fanOut === undefined && askQuestions === undefined) delete steps[step];
585
+ if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) delete steps[step];
428
586
  else steps[step] = {
429
587
  ...(model && { model }),
430
588
  ...(effort && { effort }),
589
+ ...(subagentModel && { subagentModel }),
431
590
  ...(fanOut !== undefined && { fanOut }),
432
591
  ...(askQuestions !== undefined && { askQuestions }),
433
592
  };
@@ -523,16 +682,19 @@ export async function promoteCustomModel(projectDir, id) {
523
682
  // nested shape from those rows. activeWorkflowId is project_config.active_workflow_id;
524
683
  // unknown top-level keys (e.g. webUiTesting) round-trip via project_config.extra.
525
684
 
526
- /** Coerce a per-node selection to a clean {model?,effort?,fanOut?,askQuestions?} or null (all empty). */
685
+ /** Coerce a per-node selection to a clean {model?,effort?,subagentModel?,fanOut?,askQuestions?}
686
+ * or null (all empty). */
527
687
  function cleanNodeSel(selection) {
528
688
  const model = typeof selection?.model === 'string' ? selection.model.trim() : '';
529
689
  const effort = typeof selection?.effort === 'string' ? selection.effort.trim() : '';
690
+ const subagentModel = isSubagentModelValue(selection?.subagentModel) ? selection.subagentModel : '';
530
691
  const fanOut = typeof selection?.fanOut === 'boolean' ? selection.fanOut : undefined;
531
692
  const askQuestions = typeof selection?.askQuestions === 'boolean' ? selection.askQuestions : undefined;
532
- if (!model && !effort && fanOut === undefined && askQuestions === undefined) return null;
693
+ if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) return null;
533
694
  return {
534
695
  ...(model && { model }),
535
696
  ...(effort && { effort }),
697
+ ...(subagentModel && { subagentModel }),
536
698
  ...(fanOut !== undefined && { fanOut }),
537
699
  ...(askQuestions !== undefined && { askQuestions }),
538
700
  };
@@ -547,17 +709,23 @@ function cleanNodeSel(selection) {
547
709
  */
548
710
  function readWorkflowsMap(key) {
549
711
  getDb();
550
- const workflows = {};
712
+ // NULL-prototype accumulator, deliberately: a stored workflow_id of
713
+ // '__proto__' would resolve truthy to Object.prototype on a plain `{}` and the
714
+ // next `.wires[id] =` would throw FOREVER for that project (MAJ-1). With no
715
+ // prototype, `workflows['__proto__'] = ...` is an ordinary own property, so a
716
+ // poisoned row degrades to a visible junk entry instead of a permanent 500.
717
+ const workflows = Object.create(null);
551
718
  const ensure = (wf) => {
552
- if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {} };
719
+ if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {}, wires: {} };
553
720
  return workflows[wf];
554
721
  };
555
722
  for (const r of prepare(
556
- 'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ?'
723
+ 'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ?'
557
724
  ).all(key)) {
558
725
  const sel = {};
559
726
  if (r.model) sel.model = r.model;
560
727
  if (r.effort) sel.effort = r.effort;
728
+ if (isSubagentModelValue(r.subagent_model)) sel.subagentModel = r.subagent_model;
561
729
  if (r.fan_out !== null && r.fan_out !== undefined) sel.fanOut = !!r.fan_out;
562
730
  if (r.ask_questions !== null && r.ask_questions !== undefined) sel.askQuestions = !!r.ask_questions;
563
731
  // Only attach a node entry that carries something (matches cleanNodeSel output).
@@ -568,7 +736,17 @@ function readWorkflowsMap(key) {
568
736
  ).all(key)) {
569
737
  ensure(r.workflow_id).feedbacks[r.fb_id] = { maxCycles: r.max_cycles };
570
738
  }
571
- return workflows;
739
+ // v23: per-loop-wire budgets (the graph twin of config_workflow_feedbacks).
740
+ for (const r of prepare(
741
+ 'SELECT workflow_id, wire_id, max_cycles FROM config_workflow_wires WHERE project_key = ?'
742
+ ).all(key)) {
743
+ ensure(r.workflow_id).wires[r.wire_id] = { maxCycles: r.max_cycles };
744
+ }
745
+ // Hand callers an ORDINARY object: spread copies with CreateDataProperty, so a
746
+ // '__proto__' key stays an own enumerable property (a plain assignment would
747
+ // have hit the setter and silently vanished) while the public shape — what
748
+ // JSON.stringify and every deepEqual in the suite see — is unchanged.
749
+ return { ...workflows };
572
750
  }
573
751
 
574
752
  /**
@@ -598,8 +776,8 @@ export async function readRunConfig(projectDir) {
598
776
  }
599
777
 
600
778
  /**
601
- * Set (or clear) the model+effort+fanOut+askQuestions for one node instance of a
602
- * workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
779
+ * Set (or clear) the model+effort+subagentModel+fanOut+askQuestions for one node
780
+ * instance of a workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
603
781
  * askQuestions are preserved when the caller omits them (read from the existing
604
782
  * row), set when a boolean, and cleared on an explicit null. Writes only the config_workflow_nodes table
605
783
  * (legacy view + extra untouched). Model/effort validate against the effective
@@ -609,7 +787,7 @@ export async function readRunConfig(projectDir) {
609
787
  * @param {string} projectDir
610
788
  * @param {string} workflowId
611
789
  * @param {string} nodeId
612
- * @param {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}} selection
790
+ * @param {{model?:string,effort?:string,subagentModel?:string,fanOut?:boolean,askQuestions?:boolean}} selection
613
791
  * @returns {Promise<void>}
614
792
  */
615
793
  export async function setNodeModel(projectDir, workflowId, nodeId, selection = {}) {
@@ -626,16 +804,22 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
626
804
  }
627
805
  }
628
806
 
807
+ {
808
+ const issue = subagentModelIssue(selection.subagentModel);
809
+ if (issue) throw new Error(issue);
810
+ }
811
+
629
812
  const key = projectKey(projectDir);
630
813
  getDb();
631
814
  const prev = prepare(
632
- 'SELECT fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
815
+ 'SELECT fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
633
816
  ).get(key, workflowId, nodeId);
634
817
  const prevFanOut = prev && prev.fan_out !== null && prev.fan_out !== undefined ? !!prev.fan_out : undefined;
635
818
  const fanOut = inheritOr(selection.fanOut, prevFanOut);
636
819
  const prevAsk = prev && prev.ask_questions !== null && prev.ask_questions !== undefined ? !!prev.ask_questions : undefined;
637
820
  const askQuestions = inheritOr(selection.askQuestions, prevAsk);
638
- const sel = cleanNodeSel({ model: selection.model, effort: selection.effort, fanOut, askQuestions });
821
+ const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev && prev.subagent_model);
822
+ const sel = cleanNodeSel({ model: selection.model, effort: selection.effort, subagentModel, fanOut, askQuestions });
639
823
 
640
824
  tx(() => {
641
825
  if (!sel) {
@@ -645,17 +829,19 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
645
829
  return;
646
830
  }
647
831
  prepare(`
648
- INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions)
649
- VALUES (?, ?, ?, ?, ?, ?, ?)
832
+ INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model)
833
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
650
834
  ON CONFLICT(project_key, workflow_id, node_id)
651
835
  DO UPDATE SET model = excluded.model, effort = excluded.effort,
652
- fan_out = excluded.fan_out, ask_questions = excluded.ask_questions
836
+ fan_out = excluded.fan_out, ask_questions = excluded.ask_questions,
837
+ subagent_model = excluded.subagent_model
653
838
  `).run(
654
839
  key, workflowId, nodeId,
655
840
  sel.model ?? null,
656
841
  sel.effort ?? null,
657
842
  sel.fanOut === undefined ? null : (sel.fanOut ? 1 : 0),
658
843
  sel.askQuestions === undefined ? null : (sel.askQuestions ? 1 : 0),
844
+ sel.subagentModel ?? null,
659
845
  );
660
846
  });
661
847
  }
@@ -681,6 +867,23 @@ export async function setFeedbackCycles(projectDir, workflowId, fbId, maxCycles)
681
867
  });
682
868
  }
683
869
 
870
+ /**
871
+ * Set the cycle budget for ONE loop wire of a v2 workflow. Coerced to an integer
872
+ * >= 1 (a loop runs at least once), exactly like setFeedbackCycles — this never
873
+ * throws, so a stale UI value cannot 500 a save. Writes only config_workflow_wires.
874
+ */
875
+ export async function setWireCycles(projectDir, workflowId, wireId, maxCycles) {
876
+ const n = Math.max(1, Math.floor(Number(maxCycles) || 0) || 1);
877
+ const key = projectKey(projectDir);
878
+ tx(() => {
879
+ prepare(`
880
+ INSERT INTO config_workflow_wires (project_key, workflow_id, wire_id, max_cycles)
881
+ VALUES (?, ?, ?, ?)
882
+ ON CONFLICT(project_key, workflow_id, wire_id) DO UPDATE SET max_cycles = excluded.max_cycles
883
+ `).run(key, workflowId, wireId, n);
884
+ });
885
+ }
886
+
684
887
  /**
685
888
  * Drop every per-project override for one workflow — the New-Pipeline accordion's
686
889
  * "Reset to defaults" (newpipeline-ux-design.md §4.5). Deletes the workflow's
@@ -706,6 +909,7 @@ export async function resetWorkflowConfig(projectDir, workflowId) {
706
909
  tx(() => {
707
910
  prepare('DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ?').run(key, id);
708
911
  prepare('DELETE FROM config_workflow_feedbacks WHERE project_key = ? AND workflow_id = ?').run(key, id);
912
+ prepare('DELETE FROM config_workflow_wires WHERE project_key = ? AND workflow_id = ?').run(key, id);
709
913
  if (clearLegacy) {
710
914
  prepare(`
711
915
  INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
@@ -746,6 +950,7 @@ export async function resolveRunConfig(projectDir, workflowId) {
746
950
  const wf = readWorkflowsMap(projectKey(projectDir))[workflowId] || {};
747
951
  return {
748
952
  nodes: wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {},
953
+ wires: wf.wires && typeof wf.wires === 'object' ? wf.wires : {},
749
954
  feedbacks: wf.feedbacks && typeof wf.feedbacks === 'object' ? wf.feedbacks : {},
750
955
  };
751
956
  }
@@ -864,16 +1069,34 @@ export async function removeGlobalModelAndRefs(id) {
864
1069
  if (!stepKeysByProject.has(r.projectKey)) continue;
865
1070
  const filtered = {};
866
1071
  for (const [k, v] of Object.entries(r.steps)) {
867
- if (v?.model && v.model.toLowerCase() === lc) { clearedSteps += 1; continue; }
1072
+ if (v?.model && v.model.toLowerCase() === lc) {
1073
+ // Clear ONLY the dangling ref (and the effort that travels with its
1074
+ // model); fanOut/askQuestions/subagentModel are not the removed
1075
+ // model's business and must survive — mirroring cleanNodeSel's
1076
+ // emptiness rule, the entry itself goes only when nothing is left.
1077
+ clearedSteps += 1;
1078
+ const { model: _m, effort: _e, ...rest } = v;
1079
+ if (Object.keys(rest).length) filtered[k] = rest;
1080
+ continue;
1081
+ }
868
1082
  filtered[k] = v;
869
1083
  }
870
1084
  prepare('UPDATE project_config SET steps = ? WHERE project_key = ?')
871
1085
  .run(JSON.stringify(filtered), r.projectKey);
872
1086
  }
873
1087
  for (const n of refs.nodes) {
874
- clearedNodes += prepare(
875
- 'DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
876
- ).run(n.projectKey, n.workflowId, n.nodeId).changes;
1088
+ // Same rule for node rows: NULL the model+effort, keep the other
1089
+ // tunables, and drop the row only once every column is NULL (matching
1090
+ // readWorkflowsMap, which would not surface an all-NULL row anyway).
1091
+ clearedNodes += prepare(`
1092
+ UPDATE config_workflow_nodes SET model = NULL, effort = NULL
1093
+ WHERE project_key = ? AND workflow_id = ? AND node_id = ?
1094
+ `).run(n.projectKey, n.workflowId, n.nodeId).changes;
1095
+ prepare(`
1096
+ DELETE FROM config_workflow_nodes
1097
+ WHERE project_key = ? AND workflow_id = ? AND node_id = ?
1098
+ AND fan_out IS NULL AND ask_questions IS NULL AND subagent_model IS NULL
1099
+ `).run(n.projectKey, n.workflowId, n.nodeId);
877
1100
  }
878
1101
  });
879
1102
  }
@@ -54,6 +54,33 @@ export function windowedSpendUsd(windowStartMs) {
54
54
  return roundUsd(row?.s || 0);
55
55
  }
56
56
 
57
+ /** Append one Ask Worca cost event (ask-cost-statistics-design.md §7.1). Same
58
+ * no-op gate as recordCostDelta: turns that ended before a `result` frame
59
+ * (amountUsd null, §6.2.8 of the ask spec) and $0 mock turns leave no row.
60
+ * messageId is the v20 backfill's idempotency key (db.mjs NOT EXISTS on
61
+ * l.message_id) — every live caller must pass it. */
62
+ export function recordAskCostDelta({ threadId, messageId = null, amountUsd,
63
+ tokens = null, model = null, tsMs = Date.now() }) {
64
+ if (!threadId || !Number.isFinite(amountUsd) || amountUsd <= 0) return;
65
+ prepare(`INSERT INTO ask_cost_ledger (thread_id, message_id, amount_usd, tokens, model, ts)
66
+ VALUES (?, ?, ?, ?, ?, ?)`).run(threadId, messageId, amountUsd, tokens, model, tsMs);
67
+ }
68
+
69
+ /** Windowed Ask Worca spend; toMs null = open-ended (budget windows), else ts < toMs. */
70
+ export function askWindowedSpendUsd(fromMs, toMs = null) {
71
+ const row = toMs == null
72
+ ? prepare('SELECT SUM(amount_usd) AS s FROM ask_cost_ledger WHERE ts >= ?').get(fromMs)
73
+ : prepare('SELECT SUM(amount_usd) AS s FROM ask_cost_ledger WHERE ts >= ? AND ts < ?').get(fromMs, toMs);
74
+ return roundUsd(row?.s || 0);
75
+ }
76
+
77
+ /** Pipeline + Ask Worca spend since windowStartMs — THE enforcement figure
78
+ * (count-everywhere, ask-cost-statistics-design.md D3). windowedSpendUsd /
79
+ * allTimeTotals stay pipeline-only for the Statistics split. */
80
+ export function totalWindowSpendUsd(windowStartMs) {
81
+ return roundUsd(windowedSpendUsd(windowStartMs) + askWindowedSpendUsd(windowStartMs));
82
+ }
83
+
57
84
  /** All-time spend + active time over ALL pipelines (archived included),
58
85
  * falling back to per-step sums when the row total is 0. */
59
86
  export function allTimeTotals() {
@@ -86,7 +113,7 @@ export function budgetStatus(now = new Date()) {
86
113
  const totalLimitUsd = totalCostLimitUsd();
87
114
  const windowStartMs = costWindowStart(now, resetPeriod).getTime();
88
115
  const windowEndMs = costWindowEnd(now, resetPeriod).getTime();
89
- const windowSpendUsd = windowedSpendUsd(windowStartMs);
116
+ const windowSpendUsd = totalWindowSpendUsd(windowStartMs);
90
117
  const blocked = totalLimitUsd != null && windowSpendUsd >= totalLimitUsd;
91
118
  return {
92
119
  pipelineLimitUsd,
@@ -96,7 +123,7 @@ export function budgetStatus(now = new Date()) {
96
123
  windowEndMs,
97
124
  msUntilReset: windowEndMs - now.getTime(),
98
125
  windowSpendUsd,
99
- allTimeSpendUsd: allTimeTotals().spendUsd,
126
+ allTimeSpendUsd: roundUsd(allTimeTotals().spendUsd + askWindowedSpendUsd(0)),
100
127
  remainingUsd: totalLimitUsd == null ? null : Math.max(0, roundUsd(totalLimitUsd - windowSpendUsd)),
101
128
  blocked,
102
129
  };