@worca/app 1.0.0 → 1.2.0-rc.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 (143) hide show
  1. package/README.md +30 -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 +386 -56
  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 +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -4,8 +4,8 @@
4
4
  // History "Live logs" panel. Extracted so the narrowing semantics are
5
5
  // unit-testable without booting app.js (mirrors log-line.mjs).
6
6
  //
7
- // A filter is { source, level, step, cycle, search } where '' / undefined means
8
- // "all". Every axis composes with AND.
7
+ // A filter is { source, level, step, node, execution, cycle, search } where '' /
8
+ // undefined means "all". Every axis composes with AND.
9
9
  // - level: exact match; a record with no level counts as 'info' (the same
10
10
  // default logLineClass applies when rendering).
11
11
  // - source: matches the role itself AND its fanned-out sub-agents — a child
@@ -13,6 +13,10 @@
13
13
  // "planner ▸ research auth" but not "plannerX".
14
14
  // - step: matches String(rec.stepIndex). Lines with no step attribution
15
15
  // (preflight, orchestrator, ui notices) only show when no step is chosen.
16
+ // - node: matches String(rec.nodeId) — the v2 graph node the line was attributed
17
+ // to; attribution-less lines only show when no node is chosen.
18
+ // - execution: matches String(rec.executionId) — ONE execution of one node
19
+ // (x:<nodeId>:<ordinal>), so a loop's second cycle can be read alone.
16
20
  // - cycle: matches String(rec.cycle) — the feedback-loop rewind counter, which is
17
21
  // orthogonal to step (a re-run keeps its stepIndex and bumps its cycle). Same
18
22
  // attribution rule as step: cycle-less lines only show when no cycle is chosen.
@@ -31,6 +35,10 @@ export function compileLogFilter(filter) {
31
35
  const source = filter.source || '';
32
36
  const hasStep = filter.step !== undefined && filter.step !== '';
33
37
  const step = hasStep ? String(filter.step) : '';
38
+ const hasNode = filter.node !== undefined && filter.node !== '';
39
+ const node = hasNode ? String(filter.node) : '';
40
+ const hasExec = filter.execution !== undefined && filter.execution !== '';
41
+ const execution = hasExec ? String(filter.execution) : '';
34
42
  const hasCycle = filter.cycle !== undefined && filter.cycle !== '';
35
43
  const cycle = hasCycle ? String(filter.cycle) : '';
36
44
  const term = filter.search ? String(filter.search).toLowerCase() : '';
@@ -41,6 +49,8 @@ export function compileLogFilter(filter) {
41
49
  if (src !== source && !src.startsWith(source + SUB_SEP)) return false;
42
50
  }
43
51
  if (hasStep && (rec.stepIndex == null || String(rec.stepIndex) !== step)) return false;
52
+ if (hasNode && (rec.nodeId == null || String(rec.nodeId) !== node)) return false;
53
+ if (hasExec && (rec.executionId == null || String(rec.executionId) !== execution)) return false;
44
54
  if (hasCycle && (rec.cycle == null || String(rec.cycle) !== cycle)) return false;
45
55
  if (term && !String(rec.text || '').toLowerCase().includes(term)) return false;
46
56
  return true;
@@ -54,13 +64,17 @@ export function logLineVisible(rec, filter) {
54
64
  // Distinct facet values the filter dropdowns offer, from the lines seen so far:
55
65
  // sources collapsed to their parent role (sub-agents fold into the role they
56
66
  // belong to), levels defaulted to 'info', steps = the stepIndex values present,
57
- // cycles = the cycle values present. All sorted (steps/cycles numerically) for
58
- // stable dropdowns. `search` is free text and has no facet.
67
+ // cycles = the cycle values present, nodes = the v2 nodeId values present,
68
+ // executions = the v2 executionId values present. All sorted (steps/cycles
69
+ // numerically, ids lexically) for stable dropdowns. `search` is free text and
70
+ // has no facet.
59
71
  export function logFacets(lines) {
60
72
  const sources = new Set();
61
73
  const levels = new Set();
62
74
  const steps = new Set();
63
75
  const cycles = new Set();
76
+ const nodes = new Set();
77
+ const executions = new Set();
64
78
  for (const rec of lines || []) {
65
79
  if (!rec) continue;
66
80
  const src = rec.source || '';
@@ -71,11 +85,15 @@ export function logFacets(lines) {
71
85
  levels.add(rec.level || 'info');
72
86
  if (rec.stepIndex != null) steps.add(rec.stepIndex);
73
87
  if (rec.cycle != null) cycles.add(rec.cycle);
88
+ if (rec.nodeId != null) nodes.add(rec.nodeId);
89
+ if (rec.executionId != null) executions.add(rec.executionId);
74
90
  }
75
91
  return {
76
92
  sources: [...sources].sort(),
77
93
  levels: [...levels].sort(),
78
94
  steps: [...steps].sort((a, b) => a - b),
79
95
  cycles: [...cycles].sort((a, b) => a - b),
96
+ nodes: [...nodes].sort(),
97
+ executions: [...executions].sort(),
80
98
  };
81
99
  }
@@ -36,12 +36,11 @@ export function logLineText(rec) {
36
36
  * needs the dashes. */
37
37
  export function serializeLog(recs) {
38
38
  const out = [];
39
- let prevCycle = null;
39
+ const state = newCycleState();
40
40
  for (const rec of recs || []) {
41
41
  if (!rec) continue;
42
- const sep = cycleSeparatorBefore(prevCycle, rec);
42
+ const sep = cycleSeparatorBefore(state, rec);
43
43
  if (sep) out.push(`── ${sep} ──`);
44
- if (rec.cycle != null) prevCycle = rec.cycle;
45
44
  out.push(logLineText(rec));
46
45
  }
47
46
  return out.join('\n');
@@ -52,35 +51,62 @@ export function serializeLog(recs) {
52
51
  * and the History replay must agree on which fields survive, or an axis works
53
52
  * in one pane while silently dead in the other (`cycle` was dropped by one of
54
53
  * three hand-rolled copies of this projection). `cycle` drives the cycle
55
- * picker AND the "── Cycle N ──" separators; `stream` is stderr provenance.
56
- * Absent attribution stays ABSENT (the filters test `!= null`). */
54
+ * picker AND the "── Cycle N ──" separators; `stream` is stderr provenance;
55
+ * `nodeId` / `executionId` (v2 graph runs) drive the node select and the
56
+ * execution chip. Absent attribution stays ABSENT (the filters test `!= null`). */
57
57
  export function projectLogRecord(rec) {
58
58
  return {
59
59
  source: rec.source, level: rec.level, text: rec.text, ts: rec.ts, sub: !!rec.sub,
60
60
  ...(rec.stepIndex != null ? { stepIndex: rec.stepIndex } : {}),
61
61
  ...(rec.cycle != null ? { cycle: rec.cycle } : {}),
62
+ ...(rec.nodeId != null ? { nodeId: rec.nodeId } : {}),
63
+ ...(rec.executionId != null ? { executionId: rec.executionId } : {}),
62
64
  ...(rec.stream ? { stream: rec.stream } : {}),
63
65
  };
64
66
  }
65
67
 
68
+ /** The separator cursor: nodeId -> the HIGHEST ordinal rendered for that node
69
+ * so far ('' buckets a record with no nodeId — orchestrator notices, and every
70
+ * v1-shaped log). One per pane, carried across a filter repaint so a live
71
+ * append after one agrees with it. */
72
+ export function newCycleState() {
73
+ return new Map();
74
+ }
75
+
66
76
  /**
67
- * The separator label to draw BEFORE `rec`, or null for none.
77
+ * The separator label to draw BEFORE `rec`, or null for none — and the ONE
78
+ * place `state` advances.
79
+ *
80
+ * In v2 `cycle` is the PER-NODE ordinal (`orchestrator.mjs` stamps
81
+ * `cycle: ctx.ordinal` beside `nodeId`), NOT v1's pipeline-wide rewind counter.
82
+ * Two nodes stream concurrently at unrelated ordinals, so comparing `rec.cycle`
83
+ * to "the last cycle rendered by ANY node" drew a rule on nearly every
84
+ * alternation (MIN-37). The rule belongs where a NODE re-runs: `rec.cycle`
85
+ * strictly exceeds the highest ordinal that node has already rendered.
86
+ *
87
+ * Consequences that are deliberate:
88
+ * · a node's FIRST line never gets a header, whatever its ordinal — so a node
89
+ * that starts late at ordinal 1 draws nothing;
90
+ * · alternation between nodes draws nothing;
91
+ * · a cycle-less notice (artifact event, git line) landing exactly at a
92
+ * boundary neither draws nor masks a rule — it does not touch the state.
68
93
  *
69
- * `cycle` is the feedback-loop rewind counter: when a reviewer returns blocking
70
- * issues the pipeline rewinds and re-runs the same steps with cycle+1, so a
71
- * re-run is otherwise indistinguishable from its first pass. A rule drawn at the
72
- * boundary makes that legible without the reader having to filter for it.
94
+ * The state must be advanced from RENDERED records only, never from the model,
95
+ * so a filter that hides an entire cycle cannot orphan a separator.
73
96
  *
74
- * `prevCycle` is the cycle of the last RENDERED record that HAD one — the
75
- * caller carries it past cycle-less notices (artifact events, git/orchestrator
76
- * lines), which land exactly at rewind boundaries and must not mask them. It
77
- * must come from rendered records, not the model, so a filter that hides an
78
- * entire cycle cannot orphan a separator. null (no cycled record rendered yet)
79
- * yields null: no leading "Cycle 1" header.
97
+ * @param {Map<string, number>} state from newCycleState(); mutated in place
98
+ * @param {object|null} rec
99
+ * @returns {string|null}
80
100
  */
81
- export function cycleSeparatorBefore(prevCycle, rec) {
101
+ export function cycleSeparatorBefore(state, rec) {
82
102
  if (!rec || rec.cycle == null) return null;
83
- if (prevCycle == null) return null;
84
- if (String(prevCycle) === String(rec.cycle)) return null;
103
+ const seen = state instanceof Map ? state : null;
104
+ if (!seen) return null;
105
+ const key = rec.nodeId == null ? '' : String(rec.nodeId);
106
+ const n = Number(rec.cycle);
107
+ if (!Number.isFinite(n)) return null;
108
+ const last = seen.get(key);
109
+ seen.set(key, last == null ? n : Math.max(last, n));
110
+ if (last == null || n <= last) return null;
85
111
  return `Cycle ${rec.cycle}`;
86
112
  }
@@ -29,6 +29,48 @@ export function envSummary(env) {
29
29
  return keys.includes('ANTHROPIC_BASE_URL') ? `${n} · routes via base URL` : n;
30
30
  }
31
31
 
32
+ /** The per-Mtok rate keys, in editor order, with their display labels. Mirrors
33
+ * settings.mjs COST_RATE_KEYS — the server is the validator, this is the form. */
34
+ export const COST_RATES = [
35
+ ['input', 'Input'],
36
+ ['output', 'Output'],
37
+ ['cacheRead', 'Cache read'],
38
+ ['cacheWrite', 'Cache write (5m)'],
39
+ ['cacheWrite1h', 'Cache write (1h)'],
40
+ ];
41
+
42
+ /** One-line pricing summary for a card, or '' when the model has no override
43
+ * (the overwhelming default — the CLI's own figure is trusted). */
44
+ export function costSummary(cost) {
45
+ if (!cost || typeof cost !== 'object') return '';
46
+ if (cost.free) return 'priced free';
47
+ const p = cost.perMtok;
48
+ if (!p || typeof p !== 'object') return '';
49
+ const set = COST_RATES.filter(([k]) => p[k] != null);
50
+ if (!set.length) return '';
51
+ return `${set.map(([k, lab]) => `${lab.toLowerCase()} $${p[k]}`).join(' · ')} /Mtok`;
52
+ }
53
+
54
+ /**
55
+ * A free id for a duplicate of `id`: `<id>-copy`, then `-copy-2`, `-copy-3`…
56
+ * Compared case-insensitively against EVERY id the catalog knows (global,
57
+ * plugin, built-in, legacy) — the add would be rejected for colliding with any
58
+ * of them, and a suggestion the server refuses is worse than no suggestion.
59
+ * @param {string} id the source model's id
60
+ * @param {Iterable<string>} takenIds
61
+ * @returns {string}
62
+ */
63
+ export function suggestDuplicateId(id, takenIds = []) {
64
+ const taken = new Set([...takenIds].map((t) => String(t).toLowerCase()));
65
+ const base = `${id}-copy`;
66
+ if (!taken.has(base.toLowerCase())) return base;
67
+ for (let n = 2; n < 1000; n += 1) {
68
+ const c = `${base}-${n}`;
69
+ if (!taken.has(c.toLowerCase())) return c;
70
+ }
71
+ return base; // 998 copies of one model: let the server reject the duplicate
72
+ }
73
+
32
74
  /**
33
75
  * The Models view body: global entries (editable), the selected project's
34
76
  * legacy custom models (promotable), and the built-in catalog (read-only).
@@ -60,10 +102,15 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
60
102
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
61
103
  if (predefLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides built-in'));
62
104
  else if (pluginLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides plugin'));
63
- if (m.costUnreliable) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
105
+ // The §4.6 "unreliable" badge is meaningless once an override GOVERNS this
106
+ // model's spend — and the backend only lifts the stored flag on the model's
107
+ // next result event, so suppress it here the moment pricing is pinned.
108
+ if (m.costUnreliable && !m.cost) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
109
+ if (m.cost) head.appendChild(h(doc, 'span', 'badge violet mv-cost-pinned', m.cost.free ? 'free' : 'priced'));
64
110
  body.appendChild(head);
65
- const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env)].filter(Boolean);
111
+ const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env), costSummary(m.cost)].filter(Boolean);
66
112
  body.appendChild(h(doc, 'small', 'mv-summary hint', bits.join(' — ')));
113
+ body.appendChild(h(doc, 'small', 'mv-test-result hint')); // app.js paints the Test outcome here
67
114
  card.appendChild(body);
68
115
  const del = h(doc, 'button', 'btn-ghost mv-delete', 'Delete');
69
116
  del.type = 'button'; del.dataset.id = m.id;
@@ -71,6 +118,15 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
71
118
  const edit = h(doc, 'button', 'btn-ghost mv-edit', 'Edit');
72
119
  edit.type = 'button'; edit.dataset.id = m.id;
73
120
  card.appendChild(edit);
121
+ // Duplicate opens a CREATE editor seeded from this entry (app.js fetches the
122
+ // raw env values first — the card only ever holds masked ones). The point is
123
+ // deriving a sibling: same routing, one parameter changed.
124
+ const dup = h(doc, 'button', 'btn-ghost mv-duplicate', 'Duplicate');
125
+ dup.type = 'button'; dup.dataset.id = m.id;
126
+ card.appendChild(dup);
127
+ const tst = h(doc, 'button', 'btn-ghost mv-test', 'Test');
128
+ tst.type = 'button'; tst.dataset.id = m.id;
129
+ card.appendChild(tst);
74
130
  yours.appendChild(card);
75
131
  }
76
132
  root.appendChild(yours);
@@ -113,18 +169,30 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
113
169
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
114
170
  head.appendChild(h(doc, 'span', 'badge waiting mv-origin', `plugin: ${m.plugin}`));
115
171
  if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
116
- if (m.costUnreliable) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
172
+ // Same rule as a global card: a manifest-pinned price governs the spend,
173
+ // so the §4.6 "unreliable" flag says nothing about it.
174
+ if (m.costUnreliable && !m.cost) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
175
+ if (m.cost) head.appendChild(h(doc, 'span', 'badge violet mv-cost-pinned', m.cost.free ? 'free' : 'priced'));
117
176
  body.appendChild(head);
118
- const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env)].filter(Boolean);
177
+ const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env), costSummary(m.cost)].filter(Boolean);
119
178
  body.appendChild(h(doc, 'small', 'mv-summary hint', bits.join(' — ')));
120
179
  for (const s of m.secrets || []) {
121
180
  body.appendChild(h(doc, 'small', `mv-secret hint${s.set ? '' : ' err'}`,
122
181
  s.set ? `secret ${s.key}: set` : `secret ${s.key}: NOT SET — configure it in the plugin's settings`));
123
182
  }
183
+ body.appendChild(h(doc, 'small', 'mv-test-result hint')); // app.js paints the Test outcome here
124
184
  card.appendChild(body);
125
185
  const copy = h(doc, 'button', 'btn-ghost mv-copy', 'Edit a copy');
126
186
  copy.type = 'button'; copy.dataset.id = m.id; copy.dataset.plugin = m.plugin;
127
187
  card.appendChild(copy);
188
+ const tst = h(doc, 'button', 'btn-ghost mv-test', 'Test');
189
+ tst.type = 'button'; tst.dataset.id = m.id; tst.dataset.plugin = m.plugin;
190
+ if ((m.secrets || []).some((s) => !s.set)) {
191
+ // A test would only prove what the NOT SET line already says.
192
+ tst.disabled = true;
193
+ tst.title = 'set the plugin secret first';
194
+ }
195
+ card.appendChild(tst);
128
196
  plug.appendChild(card);
129
197
  }
130
198
  root.appendChild(plug);
@@ -205,7 +273,7 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
205
273
  idInput.placeholder = 'claude-opus-4-8, glm-4.7, a fine-tune id…';
206
274
  idInput.value = editing ? model.id : '';
207
275
  idInput.disabled = editing; // the id IS the reference — delete + re-add to rename
208
- grid.appendChild(field('Model id (passed to claude --model)', idInput,
276
+ grid.appendChild(field('Model id (worca’s handle; sent to claude --model unless ANTHROPIC_MODEL overrides)', idInput,
209
277
  editing ? '' : 'Use a built-in id to override that built-in.'));
210
278
 
211
279
  const labelInput = h(doc, 'input', 'input mv-label');
@@ -232,7 +300,7 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
232
300
  const add = h(doc, 'button', 'btn-ghost mv-env-add', '+ env var');
233
301
  add.type = 'button';
234
302
  grid.appendChild(field('Routing env (merged into the claude spawn for this model)', envWrap,
235
- 'e.g. ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. Stored values show masked; leave masked to keep. WORCA_* and process keys are reserved.'));
303
+ 'e.g. ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. ANTHROPIC_MODEL sets the wire id sent to --model (the id above stays worca’s handle). Stored values show masked; leave masked to keep. WORCA_* and process keys are reserved.'));
236
304
  const envBtns = h(doc, 'div', 'mv-env-btns');
237
305
  envBtns.appendChild(add);
238
306
  if (rows.length) {
@@ -245,7 +313,48 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
245
313
  }
246
314
  grid.appendChild(envBtns);
247
315
 
316
+ // ── Pricing (opt-in per-model cost override, config.mjs resolveModelCost) ──
317
+ // The CLI computes total_cost_usd from its OWN table keyed on the model NAME,
318
+ // so an on-prem/proxied endpoint gets a fabricated figure worca cannot tell
319
+ // from a real one. Three mutually exclusive modes; the default is unchanged
320
+ // behavior. Rendered detached like everything else here — app.js wires the
321
+ // mode change to applyCostMode.
322
+ const costWrap = h(doc, 'div', 'mv-cost-edit');
323
+ const modes = h(doc, 'div', 'mv-cost-modes');
324
+ const groupName = `mv-cost-mode-${editing ? model.id : 'new'}`;
325
+ for (const [value, text] of [
326
+ ['cli', 'Trust the CLI'],
327
+ ['free', 'Free ($0)'],
328
+ ['perMtok', 'Per million tokens'],
329
+ ]) {
330
+ const lab = h(doc, 'label', 'mv-cost-mode');
331
+ const rb = h(doc, 'input', 'mv-cost-mode-rb');
332
+ rb.type = 'radio'; rb.name = groupName; rb.value = value;
333
+ lab.appendChild(rb);
334
+ lab.appendChild(h(doc, 'span', null, text));
335
+ modes.appendChild(lab);
336
+ }
337
+ costWrap.appendChild(modes);
338
+
339
+ const rates = h(doc, 'div', 'mv-cost-rates');
340
+ for (const [key, labelText] of COST_RATES) {
341
+ const lab = h(doc, 'label', 'mv-cost-rate');
342
+ lab.appendChild(h(doc, 'span', 'mv-cost-rate-label', labelText));
343
+ const inp = h(doc, 'input', 'input mv-cost-rate-in');
344
+ inp.type = 'number'; inp.min = '0'; inp.step = '0.01'; inp.placeholder = '0';
345
+ inp.dataset.rate = key;
346
+ lab.appendChild(inp);
347
+ lab.appendChild(h(doc, 'span', 'mv-cost-rate-unit', '$/Mtok'));
348
+ rates.appendChild(lab);
349
+ }
350
+ costWrap.appendChild(rates);
351
+ grid.appendChild(field('Pricing', costWrap,
352
+ 'Only for an endpoint the CLI prices by NAME rather than from what the endpoint reports — an on-prem or proxied model. '
353
+ + 'Trust the CLI is the default and leaves spend exactly as today. Blank rates count as $0, except Cache write (1h), '
354
+ + 'which falls back to the 5m rate when blank.'));
355
+
248
356
  root.appendChild(grid);
357
+ setModelCost(root, editing ? model.cost : null); // grid is attached now — the block is reachable from root
249
358
  const msg = h(doc, 'p', 'form-msg mv-editor-msg');
250
359
  msg.setAttribute('aria-live', 'polite');
251
360
  root.appendChild(msg);
@@ -260,6 +369,41 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
260
369
  return root;
261
370
  }
262
371
 
372
+ /**
373
+ * Load a `cost` override into an editor: pick the matching mode and fill the
374
+ * rates. The ONE place that maps a stored cost onto the form, shared by the
375
+ * initial render and by "Edit a copy"'s prefill of a plugin model's pricing —
376
+ * a second implementation of this mapping is exactly how the two would drift.
377
+ * Safe on an editor with no pricing block. Pass null/undefined for no override.
378
+ * @param {Element} rootEl the .mv-editor root
379
+ * @param {{free?:boolean, perMtok?:Record<string,number>}|null} [cost]
380
+ */
381
+ export function setModelCost(rootEl, cost) {
382
+ const rates = rootEl && rootEl.querySelector('.mv-cost-rates');
383
+ if (!rates) return;
384
+ const mode = !cost ? 'cli' : (cost.free ? 'free' : 'perMtok');
385
+ for (const rb of rootEl.querySelectorAll('.mv-cost-mode-rb')) rb.checked = rb.value === mode;
386
+ const p = cost && cost.perMtok && typeof cost.perMtok === 'object' ? cost.perMtok : {};
387
+ for (const inp of rootEl.querySelectorAll('.mv-cost-rate-in')) {
388
+ const v = p[inp.dataset.rate];
389
+ inp.value = v == null ? '' : String(v);
390
+ }
391
+ applyCostMode(rootEl);
392
+ }
393
+
394
+ /**
395
+ * Show the per-Mtok rate inputs only in that mode. The single place that knows
396
+ * the rule, so the initial render and the delegated `change` handler in app.js
397
+ * can never drift apart. Safe on an editor with no pricing block.
398
+ * @param {Element} rootEl the .mv-editor root
399
+ */
400
+ export function applyCostMode(rootEl) {
401
+ const rates = rootEl && rootEl.querySelector('.mv-cost-rates');
402
+ if (!rates) return;
403
+ const picked = rootEl.querySelector('.mv-cost-mode-rb:checked');
404
+ rates.hidden = (picked ? picked.value : 'cli') !== 'perMtok';
405
+ }
406
+
263
407
  /** app.js exposes envRow creation to the delegated mv-env-add handler. */
264
408
  export function makeEnvRow({ doc = globalThis.document } = {}) {
265
409
  return envRow(doc);
@@ -294,12 +438,30 @@ export function collectModelEditor(rootEl) {
294
438
  }
295
439
  }
296
440
 
441
+ // Pricing. The editor renders the STORED override, so it round-trips: a user
442
+ // who came to change a label leaves it untouched. 'cli' is therefore an
443
+ // explicit CLEAR (null), not an omission — it is what the form now shows.
444
+ // Rate values are sent raw for the server to validate (settings.mjs
445
+ // assertModelCost owns the rules; duplicating them here would let them drift).
446
+ const mode = rootEl.querySelector('.mv-cost-mode-rb:checked')?.value || 'cli';
447
+ let cost = null;
448
+ if (mode === 'free') cost = { free: true };
449
+ else if (mode === 'perMtok') {
450
+ const perMtok = {};
451
+ for (const inp of rootEl.querySelectorAll('.mv-cost-rate-in')) {
452
+ const raw = (inp.value ?? '').trim();
453
+ if (raw !== '') perMtok[inp.dataset.rate] = Number(raw);
454
+ }
455
+ cost = { perMtok }; // empty -> the server rejects it by name, surfaced in the form
456
+ }
457
+
297
458
  const body = {
298
459
  ...(editing ? {} : { id }),
299
460
  label,
300
461
  // All boxes checked = the full set = store the default (empty).
301
462
  efforts: efforts.length === allCount ? [] : efforts,
302
463
  env,
464
+ cost,
303
465
  };
304
466
  return { id: editing ? id : null, body };
305
467
  }
@@ -331,13 +493,13 @@ export function renderExportWizard(globals, { doc = globalThis.document } = {})
331
493
 
332
494
  const pick = step('1 — Models to include', 'Only your global models can be shared.');
333
495
  for (const m of globals) {
334
- const lab = h(doc, 'label', 'mvx-model');
496
+ const lab = h(doc, 'label', 'mvx-model opt-row');
335
497
  const cb = h(doc, 'input', 'mvx-model-cb');
336
498
  cb.type = 'checkbox'; cb.value = m.id;
337
499
  cb.dataset.envKeys = Object.keys(m.env || {}).join('\n');
338
500
  lab.appendChild(cb);
339
- lab.appendChild(h(doc, 'span', null, `${m.label || m.id} `));
340
- lab.appendChild(h(doc, 'small', 'hint', m.id));
501
+ lab.appendChild(h(doc, 'span', 'opt-name', `${m.label || m.id} `));
502
+ lab.appendChild(h(doc, 'small', 'hint opt-sub', m.id));
341
503
  pick.appendChild(lab);
342
504
  }
343
505
  if (!globals.length) pick.appendChild(h(doc, 'div', 'hist-empty', 'No global models to share yet.'));
@@ -57,17 +57,33 @@ export function renderPluginList(plugins, { doc = globalThis.document, channelSt
57
57
  head.appendChild(h(doc, 'b', 'pl-name', p.name));
58
58
  head.appendChild(h(doc, 'span', 'pl-version mono', p.version || sha7(p.pinnedSha)));
59
59
  if (p.linked) head.appendChild(h(doc, 'span', 'badge waiting pl-linked', 'linked'));
60
- if (p.broken) head.appendChild(h(doc, 'span', 'badge red pl-broken', 'broken'));
60
+ if (p.apiMismatch) head.appendChild(h(doc, 'span', 'badge amber pl-api-mismatch', 'needs update'));
61
+ else if (p.broken) head.appendChild(h(doc, 'span', 'badge red pl-broken', 'broken'));
62
+ // Enabling a plugin acts on a live system the moment it flips, so it reads
63
+ // as a switch. `.switch` MUST be the input's immediate next sibling — the
64
+ // `.sw-input:checked + .switch` rule is what paints the on state.
61
65
  const toggle = h(doc, 'label', 'pl-enable');
62
- const cb = h(doc, 'input', 'pl-toggle');
66
+ const cb = h(doc, 'input', 'sw-input pl-toggle');
63
67
  cb.type = 'checkbox';
64
68
  cb.checked = p.enabled !== false;
65
69
  cb.dataset.name = p.name;
70
+ cb.setAttribute('aria-label', `Enable ${p.name}`);
66
71
  toggle.appendChild(cb);
72
+ toggle.appendChild(h(doc, 'span', 'switch switch-sm'));
67
73
  toggle.appendChild(h(doc, 'span', '', p.enabled !== false ? 'enabled' : 'disabled'));
68
74
  head.appendChild(toggle);
69
75
  card.appendChild(head);
70
76
  card.appendChild(h(doc, 'small', 'pl-contrib hint', contribSummary(p.contributions)));
77
+ if (p.apiMismatch) card.appendChild(h(doc, 'small', 'pl-api-note hint err', p.apiMismatch.message || ''));
78
+ // Contributions worca refused to load. Same note treatment as the API note:
79
+ // the contributions line above counts what the plugin SHIPS, so without this
80
+ // the card claims an agent or a template that exists nowhere.
81
+ if (p.ignored && p.ignored.length) {
82
+ const n = p.ignored.length;
83
+ card.appendChild(h(doc, 'small', 'pl-ignored-note hint err',
84
+ `${n} contribution${n > 1 ? 's' : ''} ignored: `
85
+ + p.ignored.map((i) => `${i.file} — ${i.reason}`).join('; ')));
86
+ }
71
87
  if (p.repo || p.marketplaceName) {
72
88
  const prov = [p.marketplaceName, p.repo].filter(Boolean).join(' · ');
73
89
  card.appendChild(h(doc, 'small', 'pl-provenance hint mono',
@@ -234,6 +250,37 @@ export function renderUpdatePreview(preview, { doc = globalThis.document } = {})
234
250
  return root;
235
251
  }
236
252
 
253
+ // The roster row for a multiProfile source: which configuration is being
254
+ // edited, plus add/remove. app.js owns the actions (each is a server round trip
255
+ // that re-opens the pane), so this only renders the controls and marks them.
256
+ function profileBar(doc, src) {
257
+ const bar = h(doc, 'div', 'pl-profile-bar');
258
+ bar.dataset.sourceId = src.id || '';
259
+ bar.appendChild(h(doc, 'label', '', 'Profile'));
260
+ const sel = h(doc, 'select', 'select pl-profile-sel');
261
+ sel.dataset.sourceId = src.id || '';
262
+ for (const p of src.profiles || []) {
263
+ const id = typeof p === 'string' ? p : String(p.id || '');
264
+ const label = typeof p === 'string' ? '' : (p.label || '');
265
+ const opt = h(doc, 'option', '', label ? `${label} (${id})` : id);
266
+ opt.value = id;
267
+ sel.appendChild(opt);
268
+ }
269
+ if (src.profile) sel.value = src.profile;
270
+ if (!(src.profiles || []).length) sel.disabled = true;
271
+ bar.appendChild(sel);
272
+ const add = h(doc, 'button', 'btn btn-ghost btn-mini pl-profile-add', 'Add');
273
+ add.type = 'button';
274
+ add.dataset.sourceId = src.id || '';
275
+ bar.appendChild(add);
276
+ const del = h(doc, 'button', 'btn btn-ghost btn-mini pl-profile-del', 'Remove');
277
+ del.type = 'button';
278
+ del.dataset.sourceId = src.id || '';
279
+ if (!src.profile) del.disabled = true;
280
+ bar.appendChild(del);
281
+ return bar;
282
+ }
283
+
237
284
  // renderConfigForm(sections) — one <form.pl-config-form>
238
285
  // per task source. secret:true fields (text-only per normalizeManifest) render
239
286
  // type=password, NEVER prefilled; a stored value arrives redacted as {set:true}
@@ -250,12 +297,25 @@ export function renderConfigForm(sections, { doc = globalThis.document } = {}) {
250
297
  ...channels.map((x) => ({ ...x, _kind: 'channel' })),
251
298
  ];
252
299
  for (const src of rows) {
300
+ // A multiProfile source is a ROSTER of configurations, not one form: the
301
+ // bar picks which profile the form below edits. Rendered before the form so
302
+ // "which instance am I editing" is answered above the fields, not after.
303
+ if (src._kind === 'source' && src.multiProfile) {
304
+ root.appendChild(profileBar(doc, src));
305
+ // Nothing to write into: creating the profile is a separate call, and the
306
+ // server rejects a save without one. Offer the add button, not a form.
307
+ if (!src.profile) {
308
+ root.appendChild(h(doc, 'p', 'hint', 'No profiles yet — add one to configure this source.'));
309
+ continue;
310
+ }
311
+ }
253
312
  const form = h(doc, 'form', 'pl-config-form');
254
313
  if (src._kind === 'channel') {
255
314
  form.dataset.channelId = src.id || '';
256
315
  form.appendChild(h(doc, 'div', 'pl-config-h', `${src.displayName || src.id} (${src.platform || 'chat'} channel)`));
257
316
  } else {
258
317
  form.dataset.sourceId = src.id || '';
318
+ if (src.multiProfile) form.dataset.profile = src.profile;
259
319
  }
260
320
  for (const f of src.schema || []) {
261
321
  const field = h(doc, 'div', 'field');
@@ -291,7 +351,9 @@ export function renderConfigForm(sections, { doc = globalThis.document } = {}) {
291
351
  return root;
292
352
  }
293
353
 
294
- // collectConfigForm(formEl) -> { sourceId | channelId, values }. An untouched
354
+ // collectConfigForm(formEl) -> { sourceId | channelId, values } (+ profile for
355
+ // a multiProfile source; the key is ABSENT otherwise, so a single-profile save
356
+ // stays byte-identical to what it sent before profiles existed). An untouched
295
357
  // {set:true} secret (data-set="1", still empty) is OMITTED — saving never
296
358
  // wipes a secret. Channel forms carry data-channel-id instead of data-source-id.
297
359
  export function collectConfigForm(formEl) {
@@ -301,7 +363,47 @@ export function collectConfigForm(formEl) {
301
363
  values[input.dataset.key] = input.value;
302
364
  }
303
365
  if (formEl.dataset.channelId) return { channelId: formEl.dataset.channelId, values };
304
- return { sourceId: formEl.dataset.sourceId || '', values };
366
+ const out = { sourceId: formEl.dataset.sourceId || '', values };
367
+ if (formEl.dataset.profile) out.profile = formEl.dataset.profile;
368
+ return out;
369
+ }
370
+
371
+ // renderConnectResult(result) — outcome of a source's validateConfig, rendered
372
+ // under the settings form. Three states, because setup can legitimately be
373
+ // mid-flight: connected (ok), waiting (ok:false + pending — the caller keeps
374
+ // polling), or failed (ok:false + errors[], each pinned to its config field).
375
+ // A connector may return no envelope at all on a transport error; `result`
376
+ // then carries { error: { kind, message } } from the /api/sources/call frame.
377
+ export function renderConnectResult(result, { doc = globalThis.document } = {}) {
378
+ const r = result || {};
379
+ const root = h(doc, 'div', 'pl-connect-result');
380
+ if (r.ok) {
381
+ root.appendChild(h(doc, 'div', 'badge green', 'connected'));
382
+ if (r.identity) root.appendChild(h(doc, 'span', 'hint', `signed in as ${r.identity}`));
383
+ // Which server/project the connector actually resolved — a mistyped sample
384
+ // URL otherwise connects successfully to the wrong instance.
385
+ if (r.instance && r.instance.baseUrl) {
386
+ const where = r.instance.project ? `${r.instance.baseUrl} · ${r.instance.project}` : r.instance.baseUrl;
387
+ root.appendChild(h(doc, 'div', 'hint mono', where));
388
+ }
389
+ return root;
390
+ }
391
+ if (r.pending) {
392
+ root.appendChild(h(doc, 'div', 'badge', 'waiting'));
393
+ root.appendChild(h(doc, 'span', 'hint', r.message || 'Waiting…'));
394
+ return root;
395
+ }
396
+ root.appendChild(h(doc, 'div', 'badge red', 'not connected'));
397
+ const errors = Array.isArray(r.errors) && r.errors.length
398
+ ? r.errors
399
+ : [{ message: (r.error && r.error.message) || r.message || 'connection failed' }];
400
+ for (const e of errors) {
401
+ const row = h(doc, 'div', 'pl-connect-err');
402
+ if (e.field) row.appendChild(h(doc, 'span', 'mono', e.field));
403
+ row.appendChild(h(doc, 'span', 'hint err', e.message || ''));
404
+ root.appendChild(row);
405
+ }
406
+ return root;
305
407
  }
306
408
 
307
409
  // renderDoctorReport(report: {ok, checks:[{id,ok,detail}]}) — row per check.