@worca/app 1.5.0 → 1.6.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 (101) hide show
  1. package/README.md +7 -1
  2. package/docker/compose.broker.yml +79 -0
  3. package/docker/compose.isolation.yml +40 -0
  4. package/package.json +2 -1
  5. package/src/broker/config.mjs +200 -0
  6. package/src/broker/copilot.mjs +124 -0
  7. package/src/broker/limits.mjs +88 -0
  8. package/src/broker/main.mjs +129 -0
  9. package/src/broker/scrub.mjs +45 -0
  10. package/src/broker/service.mjs +558 -0
  11. package/src/broker/slots.mjs +180 -0
  12. package/src/broker/store.mjs +171 -0
  13. package/src/broker/tokens.mjs +116 -0
  14. package/src/broker/ui/page.css +54 -0
  15. package/src/broker/ui/page.html +25 -0
  16. package/src/broker/ui/page.mjs +202 -0
  17. package/src/broker/ui-server.mjs +217 -0
  18. package/src/broker/usage.mjs +108 -0
  19. package/src/broker/vault.mjs +37 -0
  20. package/src/cli/models.mjs +46 -14
  21. package/src/cli/render.mjs +21 -0
  22. package/src/cli/runs.mjs +296 -0
  23. package/src/cli/worca-cc.mjs +14 -2
  24. package/src/core/agent-pool.mjs +76 -0
  25. package/src/core/artifacts.mjs +40 -8
  26. package/src/core/ask/events.mjs +11 -0
  27. package/src/core/ask/html-text.mjs +113 -0
  28. package/src/core/ask/limits.mjs +5 -0
  29. package/src/core/ask/mcp-stdio.mjs +74 -20
  30. package/src/core/ask/prompt.mjs +25 -6
  31. package/src/core/ask/spawn.mjs +55 -5
  32. package/src/core/ask/store.mjs +1 -1
  33. package/src/core/ask/tools.mjs +66 -1
  34. package/src/core/ask/turn.mjs +52 -3
  35. package/src/core/ask/web-access.mjs +26 -0
  36. package/src/core/ask/web-deps.mjs +56 -0
  37. package/src/core/ask/web-fetch.mjs +271 -0
  38. package/src/core/ask/web-proposal.mjs +76 -0
  39. package/src/core/auto/classify.mjs +20 -8
  40. package/src/core/auto/runnable.mjs +28 -0
  41. package/src/core/billing.mjs +53 -0
  42. package/src/core/bridge/errors.mjs +77 -5
  43. package/src/core/bridge/openrouter.mjs +59 -0
  44. package/src/core/bridge/provider-ops.mjs +165 -9
  45. package/src/core/bridge/providers/endpoint.mjs +112 -7
  46. package/src/core/bridge/registry.mjs +13 -0
  47. package/src/core/bridge/server.mjs +16 -3
  48. package/src/core/bridge/telemetry.mjs +44 -12
  49. package/src/core/bridge/translate/common.mjs +31 -0
  50. package/src/core/bridge/translate/request.mjs +4 -1
  51. package/src/core/bridge/translate/response.mjs +3 -0
  52. package/src/core/bridge/translate/schema-keywords.mjs +75 -0
  53. package/src/core/bridge/translate/stream.mjs +50 -4
  54. package/src/core/bridge/upstream.mjs +102 -17
  55. package/src/core/broker-boot.mjs +57 -0
  56. package/src/core/broker-client.mjs +206 -0
  57. package/src/core/broker-guard.mjs +112 -0
  58. package/src/core/broker-routing.mjs +138 -0
  59. package/src/core/claude-auth.mjs +29 -0
  60. package/src/core/claude-runner.mjs +200 -9
  61. package/src/core/config.mjs +9 -12
  62. package/src/core/failure-policy.mjs +4 -2
  63. package/src/core/git-info.mjs +49 -5
  64. package/src/core/github-credentials.mjs +44 -1
  65. package/src/core/graph/script-runner.mjs +3 -1
  66. package/src/core/list-prices.mjs +29 -0
  67. package/src/core/mcp-secrets.mjs +80 -0
  68. package/src/core/metrics/sync.mjs +2 -1
  69. package/src/core/model-env.mjs +72 -0
  70. package/src/core/model-test.mjs +17 -5
  71. package/src/core/onboarding.mjs +12 -6
  72. package/src/core/openrouter-free.mjs +159 -0
  73. package/src/core/orchestrator.mjs +100 -12
  74. package/src/core/policy/effective.mjs +18 -1
  75. package/src/core/policy/local.mjs +4 -1
  76. package/src/core/policy/registry.mjs +12 -2
  77. package/src/core/preflight.mjs +116 -0
  78. package/src/core/recoverable-error.mjs +95 -0
  79. package/src/core/recovery-backoff.mjs +84 -0
  80. package/src/core/redact.mjs +25 -0
  81. package/src/core/run-context.mjs +6 -0
  82. package/src/core/run-harness.mjs +112 -30
  83. package/src/core/run-report.mjs +2 -1
  84. package/src/core/settings.mjs +90 -5
  85. package/src/core/title.mjs +7 -2
  86. package/src/core/web-allowlist.mjs +95 -0
  87. package/ui/public/app.js +244 -43
  88. package/ui/public/ask-model.mjs +1 -0
  89. package/ui/public/ask-panel.mjs +160 -23
  90. package/ui/public/bridge-view.mjs +175 -6
  91. package/ui/public/chat-settings-view.mjs +52 -1
  92. package/ui/public/credential-badges.mjs +63 -0
  93. package/ui/public/credentials-view.mjs +57 -0
  94. package/ui/public/index.html +33 -6
  95. package/ui/public/models-view.mjs +19 -1
  96. package/ui/public/openrouter-free-view.mjs +118 -0
  97. package/ui/public/stats-view.mjs +56 -0
  98. package/ui/public/style.css +99 -50
  99. package/ui/public/team-policy-view.mjs +2 -1
  100. package/ui/public/ws-seq.mjs +24 -0
  101. package/ui/server.mjs +319 -23
package/ui/public/app.js CHANGED
@@ -68,6 +68,7 @@ const state = {
68
68
 
69
69
  import { logLineClass, logLineTime, serializeLog, cycleSeparatorBefore, newCycleState, projectLogRecord } from './log-line.mjs';
70
70
  import { logLineVisible, logFacets, compileLogFilter } from './log-filter.mjs';
71
+ import { alreadyApplied, noteBoot } from './ws-seq.mjs';
71
72
  import { decorFromState, applyDecor, isGraphManifest } from './graph/run-decor.mjs';
72
73
  import { mountRunGraph } from './graph/run-hosts.mjs';
73
74
  // Import list only — `statusChip`/`diffBadges`/`mergeFindings`/`reportResultControl`
@@ -106,7 +107,10 @@ import {
106
107
  renderConfigForm, collectConfigForm, renderConnectResult, renderDoctorReport, renderReferences409,
107
108
  renderOrphanList, channelBadge, renderAvailableList, renderMarketplaceList,
108
109
  } from './plugins-view.mjs';
109
- import { renderChatSettings, collectChatSettings, renderScriptToolsToggle, collectScriptToolsToggle } from './chat-settings-view.mjs';
110
+ import { renderChatSettings, collectChatSettings, renderScriptToolsToggle, collectScriptToolsToggle, renderAskWebFields, collectAskWebFields } from './chat-settings-view.mjs';
111
+ import { renderCredentials } from './credentials-view.mjs';
112
+ import { loadCredentials, credentialSuffix } from './credential-badges.mjs';
113
+ import { renderFreeDaily, freeRequestsSuffix, typicalFreeRun, newRunFreeWarning, providerFreeLine } from './openrouter-free-view.mjs';
110
114
  import { PORT_ID_RE, MAX_PORTS_PER_SIDE, PORT_TYPES, FLOW_LABEL, KEYED_KINDS } from '../../src/shared/graph/constants.mjs';
111
115
  import { FORM_ID_RE, validateFormDef, normalizeAskBlock } from '../../src/shared/forms/form-def.mjs';
112
116
  import { ASK_LIMITS } from '../../src/shared/forms/catalog.mjs';
@@ -121,6 +125,7 @@ import {
121
125
  } from './models-view.mjs';
122
126
  import {
123
127
  renderProvidersCard, collectProviderRow, renderImportSheet, renderEndpointSheet, collectImportSheet, applyImportSelectAll,
128
+ applyProviderPreset, endpointRowMatches,
124
129
  setModelUpstream, COPILOT_TERMS,
125
130
  } from './bridge-view.mjs';
126
131
  import {
@@ -301,6 +306,7 @@ const el = {
301
306
  // Wizard
302
307
  wizName: $('#wiz-name'),
303
308
  wizProjects: $('#wiz-projects'),
309
+ wizSelectAll: $('#wiz-select-all'),
304
310
  wizStep1Hint: $('#wiz-step1-hint'),
305
311
  wizStartScan: $('#wiz-start-scan'),
306
312
  wizStatus: $('#wiz-status'),
@@ -600,6 +606,7 @@ function setSidebarCollapsed(v) {
600
606
  updateNavCounts(); // Running's title/aria-label (both states)
601
607
  renderPipelineTabs(); // child rows <-> initials tiles (phase 3)
602
608
  paintBudget(); // spend block <-> budget ring (phase 4)
609
+ paintFreeDaily(); // the free-request line shows only in the full rail
603
610
  }
604
611
 
605
612
  $('#side-toggle')?.addEventListener('click', () => setSidebarCollapsed(!sidebarCollapsed));
@@ -672,7 +679,63 @@ let startSubmitInFlight = false;
672
679
  // budgetState.budget until a reload. Record the request instead and run one
673
680
  // trailing fetch per in-flight window — any number of requests made during a
674
681
  // fetch collapse into that single trailing fetch.
682
+ // ── OpenRouter free-model requests (openrouter-free-view.mjs) ────────────────
683
+ // Refreshed with the budget (every spend-moving event and its tick), at most every 20s:
684
+ // the server keeps its own reading of OpenRouter and lowers it per :free call.
685
+ const freeDailyState = { status: null, lastFetchMs: 0, fetching: false };
686
+
687
+ async function refreshFreeDaily({ force = false } = {}) {
688
+ if (freeDailyState.fetching || (!force && Date.now() - freeDailyState.lastFetchMs < 20_000)) return;
689
+ freeDailyState.fetching = true;
690
+ freeDailyState.lastFetchMs = Date.now();
691
+ try {
692
+ const res = await fetch(`/api/openrouter/free-daily${force ? '?refresh=1' : ''}`);
693
+ if (res.ok) freeDailyState.status = await safeJson(res);
694
+ } catch { /* transient: keep the last one */ } finally { freeDailyState.fetching = false; }
695
+ paintFreeDaily();
696
+ }
697
+
698
+ /** The free-request line rides the spend mount, under the spend block (full rail only). */
699
+ function paintFreeDaily() {
700
+ const mount = document.getElementById('side-spend');
701
+ if (!mount) return;
702
+ mount.querySelector('.free-ind')?.remove();
703
+ const node = sidebarCollapsed ? null : renderFreeDaily(freeDailyState.status);
704
+ if (node) mount.appendChild(node);
705
+ applyFreeDailyToNewView();
706
+ if (currentView() === 'settings' && currentSettingsTab === 'providers') paintProviderFreeDaily();
707
+ }
708
+
709
+ /** The new-run form: warn when a node's model is :free and fewer requests are left than a run takes. */
710
+ function applyFreeDailyToNewView() {
711
+ const note = document.getElementById('newFreeNote');
712
+ if (!note) return;
713
+ const s = freeDailyState.status;
714
+ const free = new Set((s && Array.isArray(s.models) ? s.models : []).map((m) => String(m).toLowerCase()));
715
+ const usesFree = Object.values(agentRowsById || {}).some((r) => r && r.model && free.has(String(r.model).toLowerCase()));
716
+ const msg = newRunFreeWarning(s, { typical: typicalFreeRun(recentRunsForFree()), usesFree });
717
+ note.hidden = !msg;
718
+ note.textContent = msg || '';
719
+ }
720
+
721
+ /** The runs this page holds, newest first, for the typical-run figure. */
722
+ function recentRunsForFree() {
723
+ return [...runs.values()].sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
724
+ }
725
+
726
+ /** The Providers card: the allowance under the OpenAI-compatible row, without pressing Test. */
727
+ function paintProviderFreeDaily() {
728
+ const line = providerFreeLine(freeDailyState.status);
729
+ const root = providerRoot();
730
+ const row = root && root.querySelector('.mv-pv-row[data-provider="openai"] .mv-pv-msg');
731
+ if (!row || !line || (row.textContent && !row.dataset.freeDaily)) return;
732
+ row.textContent = line;
733
+ row.dataset.freeDaily = '1';
734
+ row.className = 'hint mv-pv-msg';
735
+ }
736
+
675
737
  async function refreshBudget() {
738
+ refreshFreeDaily();
676
739
  if (budgetState.fetching) { budgetState.pending = true; return; }
677
740
  budgetState.fetching = true;
678
741
  try {
@@ -700,6 +763,7 @@ function paintBudget() {
700
763
  const render = sidebarCollapsed ? renderBudgetRing : renderBudgetIndicator;
701
764
  mount.replaceChildren(render(b,
702
765
  { fmt: { usd: fmtUsd, usd4: fmtUsd4, duration: fmtDuration, estTitle } }));
766
+ paintFreeDaily(); // the free-request line sits under the block this just replaced
703
767
  }
704
768
  if (topAmt) {
705
769
  topAmt.hidden = false;
@@ -855,7 +919,9 @@ function repaintPeople() {
855
919
  // <nav> that navLinks snapshots at boot — so route it from a container listener
856
920
  // rather than the [data-nav] delegation.
857
921
  document.getElementById('side-spend').addEventListener('click', (e) => {
858
- if (e.target.closest('.spend-ind')) location.hash = 'stats';
922
+ // The OpenRouter free-request line (same mount) opens the Providers tab, where its key lives.
923
+ if (e.target.closest('.free-ind')) location.hash = 'settings/providers';
924
+ else if (e.target.closest('.spend-ind')) location.hash = 'stats';
859
925
  });
860
926
 
861
927
  // ---------------------------------------------------------------------------
@@ -1084,6 +1150,9 @@ function handleServerMessage(msg) {
1084
1150
  // resurrect the phantom.)
1085
1151
  if ((msg.type === 'subagent' || msg.type === 'stepskills' || msg.type === 'stepgraphify' || msg.type === 'question-resolved') && !runs.has(msg.runId)) return;
1086
1152
  const r = upsertRun({ runId: msg.runId });
1153
+ // A reconnect re-subscribes and the server replays the run's buffer: skip what this page
1154
+ // already applied, or every earlier log line shows twice (ws-seq.mjs).
1155
+ if (alreadyApplied(r, msg)) return;
1087
1156
 
1088
1157
  switch (msg.type) {
1089
1158
  case 'log':
@@ -1147,6 +1216,7 @@ function handleServerMessage(msg) {
1147
1216
  function onHello(msg) {
1148
1217
  const ws = state.ws;
1149
1218
  const list = Array.isArray(msg.runs) ? msg.runs : [];
1219
+ noteBoot(state, msg.bootId, runs); // a restarted server numbers run events from 1 again
1150
1220
 
1151
1221
  if (!helloSeeded) {
1152
1222
  helloSeeded = true;
@@ -2785,7 +2855,7 @@ function renderModelEffortPair(modelSel, effortSel, caption, sel = {}) {
2785
2855
  const ambiguous = m.custom === 'plugin' && labelCounts.get((m.label || m.id).toLowerCase()) > 1;
2786
2856
  const viaSuffix = m.bridged && !(m.label || m.id).toLowerCase().includes(m.bridged) ? ` · ${m.bridged}` : '';
2787
2857
  og.appendChild(option(m.id,
2788
- m.label + (ambiguous ? ` (${m.plugin})` : '') + viaSuffix + (m.costUnreliable ? ' ⚠cost' : '') + (m.needsSignIn ? ' (needs sign-in)' : '')));
2858
+ m.label + (ambiguous ? ` (${m.plugin})` : '') + viaSuffix + (m.costUnreliable ? ' ⚠cost' : '') + (m.needsSignIn ? ' (needs sign-in)' : '') + credentialSuffix(m.id)));
2789
2859
  }
2790
2860
  modelSel.appendChild(og);
2791
2861
  };
@@ -3346,6 +3416,7 @@ function renderAgentRows(rows) {
3346
3416
  if (row.pinned) lockPinnedPair(modelSel, effortSel);
3347
3417
  host.appendChild(card);
3348
3418
  });
3419
+ applyFreeDailyToNewView(); // a :free model among the rows may need the allowance warning
3349
3420
  }
3350
3421
 
3351
3422
  // Fill a sub-agent model dropdown. '' is "unset" — the run resolves it to the
@@ -7368,11 +7439,7 @@ function renderWizardProjects() {
7368
7439
  const projects = Array.isArray(state.projects) ? state.projects : [];
7369
7440
  const usable = projects.filter((p) => p && p.exists);
7370
7441
 
7371
- if (el.wizStep1Hint) {
7372
- el.wizStep1Hint.textContent = usable.length < 2
7373
- ? 'Onboard at least two projects (in New Pipeline) to create a workspace.'
7374
- : 'Select two or more projects to scan their interconnections.';
7375
- }
7442
+ resetWizStep1Hint();
7376
7443
 
7377
7444
  projects.forEach((p) => {
7378
7445
  if (!p || !p.path) return;
@@ -7417,8 +7484,37 @@ function renderWizardProjects() {
7417
7484
  function syncWizardStartEnabled() {
7418
7485
  const next = document.getElementById('wiz-start-scan');
7419
7486
  if (next) next.disabled = state.wizard.selectedPaths.length < 2;
7487
+ syncWizardSelectAll();
7420
7488
  }
7421
7489
 
7490
+ // The enabled (existing) project checkboxes: the only rows Select all may touch.
7491
+ function wizardUsableBoxes() {
7492
+ return el.wizProjects ? [...el.wizProjects.querySelectorAll('.wiz-proj-cb:not(:disabled)')] : [];
7493
+ }
7494
+
7495
+ // Select all reads checked when every usable project is picked, indeterminate when some are.
7496
+ function syncWizardSelectAll() {
7497
+ const all = el.wizSelectAll;
7498
+ if (!all) return;
7499
+ const boxes = wizardUsableBoxes();
7500
+ const picked = boxes.filter((b) => b.checked).length;
7501
+ all.disabled = boxes.length < 2;
7502
+ all.checked = boxes.length > 0 && picked === boxes.length;
7503
+ all.indeterminate = picked > 0 && picked < boxes.length;
7504
+ }
7505
+
7506
+ // Check or clear every usable project at once; a missing project's selection is left as is.
7507
+ if (el.wizSelectAll) el.wizSelectAll.addEventListener('change', () => {
7508
+ const on = el.wizSelectAll.checked;
7509
+ const set = new Set(state.wizard.selectedPaths);
7510
+ for (const cb of wizardUsableBoxes()) {
7511
+ cb.checked = on;
7512
+ if (on) set.add(cb.value); else set.delete(cb.value);
7513
+ }
7514
+ state.wizard.selectedPaths = [...set];
7515
+ syncWizardStartEnabled();
7516
+ });
7517
+
7422
7518
  // The team-metrics home is no longer a wizard step: POST /api/workspaces adopts the one
7423
7519
  // member that already records (autoMetricsHome), and every other case is "Choose…" on the
7424
7520
  // workspace card — so creating a workspace never blocks on a feature most users have not
@@ -7465,7 +7561,7 @@ async function startWizardScan() {
7465
7561
  state.wizard.abort = null;
7466
7562
  setStatusText('');
7467
7563
  showWizardStep(1);
7468
- setWizStep1Error(data.error || `Scan failed (${res.status})`);
7564
+ setWizStep1Error(data.error || `Scan failed (${res.status})`, data.code);
7469
7565
  return;
7470
7566
  }
7471
7567
  state.wizard.scanId = data.scanId;
@@ -7479,8 +7575,39 @@ async function startWizardScan() {
7479
7575
  }
7480
7576
  }
7481
7577
 
7482
- function setWizStep1Error(message) {
7483
- if (el.wizStep1Hint) el.wizStep1Hint.textContent = `Scan error: ${message}`;
7578
+ function resetWizStep1Hint() {
7579
+ const hint = el.wizStep1Hint;
7580
+ if (!hint) return;
7581
+ const usable = (Array.isArray(state.projects) ? state.projects : []).filter((p) => p && p.exists);
7582
+ hint.classList.remove('err');
7583
+ claudeSignedOutHints.delete(hint);
7584
+ hint.textContent = usable.length < 2
7585
+ ? 'Onboard at least two projects (in New Pipeline) to create a workspace.'
7586
+ : 'Select two or more projects to scan their interconnections.';
7587
+ }
7588
+
7589
+ // A signed-out Claude CLI (409 code 'claude-signed-out') gets one short red line
7590
+ // whose link opens the Connect Claude Code dialog. `restore` repaints the hint's
7591
+ // normal text once Check again there finds the CLI signed in (paintClaudeSetupStatus).
7592
+ const claudeSignedOutHints = new Map(); // hint element -> restore()
7593
+ function showClaudeSignedOut(hint, restore) {
7594
+ const link = document.createElement('a');
7595
+ link.href = '#';
7596
+ link.textContent = 'Sign in…';
7597
+ link.addEventListener('click', (e) => { e.preventDefault(); openClaudeSetup(); });
7598
+ hint.classList.add('err');
7599
+ hint.replaceChildren("Claude Code isn't signed in. ", link);
7600
+ claudeSignedOutHints.set(hint, restore);
7601
+ }
7602
+
7603
+ // Any other refusal keeps the "Scan error: …" text.
7604
+ function setWizStep1Error(message, code) {
7605
+ const hint = el.wizStep1Hint;
7606
+ if (!hint) return;
7607
+ if (code === 'claude-signed-out') { showClaudeSignedOut(hint, resetWizStep1Hint); return; }
7608
+ claudeSignedOutHints.delete(hint);
7609
+ hint.classList.add('err');
7610
+ hint.textContent = `Scan error: ${message}`;
7484
7611
  }
7485
7612
 
7486
7613
  // Persist at Step 3 Save: new → POST /api/workspaces; re-scan → PATCH :id.
@@ -9825,7 +9952,7 @@ async function startAgentGenerate() {
9825
9952
  if (!res.ok || !data.genId) {
9826
9953
  state.agentWizard.abort = null;
9827
9954
  showAgentWizardStep(1);
9828
- if (el.agwStep1Hint) el.agwStep1Hint.textContent = `Generation error: ${data.error || res.status}`;
9955
+ setAgwStep1Error(data.error || res.status, data.code);
9829
9956
  return;
9830
9957
  }
9831
9958
  state.agentWizard.genId = data.genId;
@@ -9835,10 +9962,27 @@ async function startAgentGenerate() {
9835
9962
  if (err && err.name === 'AbortError') return;
9836
9963
  state.agentWizard.abort = null;
9837
9964
  showAgentWizardStep(1);
9838
- if (el.agwStep1Hint) el.agwStep1Hint.textContent = `Generation error: ${err.message}`;
9965
+ setAgwStep1Error(err.message);
9839
9966
  }
9840
9967
  }
9841
9968
 
9969
+ const AGW_STEP1_HINT = 'Name + purpose are required (or paste your own markdown).';
9970
+ function resetAgwStep1Hint() {
9971
+ const hint = el.agwStep1Hint;
9972
+ if (!hint) return;
9973
+ claudeSignedOutHints.delete(hint);
9974
+ hint.classList.remove('err');
9975
+ hint.textContent = AGW_STEP1_HINT;
9976
+ }
9977
+ function setAgwStep1Error(message, code) {
9978
+ const hint = el.agwStep1Hint;
9979
+ if (!hint) return;
9980
+ if (code === 'claude-signed-out') { showClaudeSignedOut(hint, resetAgwStep1Hint); return; }
9981
+ claudeSignedOutHints.delete(hint);
9982
+ hint.classList.add('err');
9983
+ hint.textContent = `Generation error: ${message}`;
9984
+ }
9985
+
9842
9986
  function onAgentGenEvent(msg) {
9843
9987
  if (!msg || !msg.genId || msg.genId !== state.agentWizard.genId) return; // stale/aborted gen
9844
9988
  if (msg.type === 'agentgen-progress') {
@@ -9863,7 +10007,7 @@ function onAgentGenEvent(msg) {
9863
10007
  state.agentWizard.abort = null;
9864
10008
  state.agentWizard.genId = '';
9865
10009
  showAgentWizardStep(1);
9866
- if (el.agwStep1Hint) el.agwStep1Hint.textContent = `Generation error: ${msg.message || 'failed'}`;
10010
+ setAgwStep1Error(msg.message || 'failed');
9867
10011
  }
9868
10012
  }
9869
10013
 
@@ -10414,6 +10558,7 @@ async function loadSettings() {
10414
10558
  paintTeamCapsReadout(); // team policy (design board 7): each home's caps, read-only
10415
10559
  refreshBudget();
10416
10560
  paintChatSettings(data.chat);
10561
+ paintCredentials();
10417
10562
  loadAskHistory();
10418
10563
  setSettingsMsg('');
10419
10564
  } catch (e) { setSettingsMsg(e.message, 'err'); }
@@ -10427,6 +10572,19 @@ function setChatSettingsMsg(text, cls) {
10427
10572
  el.chatSettingsMsg.className = `hint${cls ? ` ${cls}` : ''}`;
10428
10573
  }
10429
10574
 
10575
+ // Settings › My model credentials (credential broker, docs/credential-broker.md): the card
10576
+ // stays hidden unless worca runs with a broker. Status only; keys live on the key page.
10577
+ async function paintCredentials() {
10578
+ const card = document.getElementById('credentials-card');
10579
+ const host = document.getElementById('credentialsHost');
10580
+ if (!card || !host) return;
10581
+ let data;
10582
+ try { data = await safeJson(await fetch('/api/credentials')); } catch { data = { enabled: false }; }
10583
+ card.hidden = !data || data.enabled !== true;
10584
+ if (card.hidden) return;
10585
+ host.replaceChildren(renderCredentials(data));
10586
+ }
10587
+
10430
10588
  async function paintChatSettings(prefs) {
10431
10589
  if (!el.chatSettingsHost) return;
10432
10590
  let channels = [];
@@ -10839,6 +10997,9 @@ function paintAskSettings(data) {
10839
10997
  // the GET and from every save response without a second fetch.
10840
10998
  const scriptHost = document.getElementById('ask-script-tools-host');
10841
10999
  if (scriptHost) scriptHost.replaceChildren(renderScriptToolsToggle({ prefs: data.chat || {} }, { doc: document }));
11000
+ // Web access: repainted from every GET and save response, which also resets its dirty flag.
11001
+ const webHost = document.getElementById('ask-web-host');
11002
+ if (webHost) webHost.replaceChildren(renderAskWebFields({ askWeb: data.askWeb }, { doc: document }));
10842
11003
  }
10843
11004
  function postAskLimits(body) {
10844
11005
  return postSettingsCard(body, { setMsg: setAskLimitsMsg, paint: paintAskSettings });
@@ -10861,7 +11022,11 @@ function saveAskLimits() {
10861
11022
  askMaxBudgetUsd = b;
10862
11023
  }
10863
11024
  const scriptHost = document.getElementById('ask-script-tools-host');
10864
- postAskLimits({ askMaxTurns, askMaxBudgetUsd, ...(scriptHost ? { chat: collectScriptToolsToggle(scriptHost) } : {}) });
11025
+ const webHost = document.getElementById('ask-web-host');
11026
+ const askWebBody = webHost ? collectAskWebFields(webHost) : null; // null = the web fields were not touched
11027
+ postAskLimits({ askMaxTurns, askMaxBudgetUsd,
11028
+ ...(scriptHost ? { chat: collectScriptToolsToggle(scriptHost) } : {}),
11029
+ ...(askWebBody ? { askWeb: askWebBody } : {}) });
10865
11030
  }
10866
11031
  document.getElementById('askLimitsSave')?.addEventListener('click', saveAskLimits);
10867
11032
  document.getElementById('askLimitsReset')?.addEventListener('click', () => postAskLimits({ askMaxTurns: '', askMaxBudgetUsd: '' }));
@@ -12528,7 +12693,7 @@ async function loadModelsView() {
12528
12693
  if (!el.modelsList) return;
12529
12694
  setModelsMsg('');
12530
12695
  try {
12531
- const [res, pres] = await Promise.all([fetch('/api/models'), fetch('/api/providers')]);
12696
+ const [res, pres] = await Promise.all([fetch('/api/models'), fetch('/api/providers'), loadCredentials()]);
12532
12697
  const data = await safeJson(res);
12533
12698
  if (!res.ok) return setModelsMsg(data.error || `HTTP ${res.status}`, 'err');
12534
12699
  mvState.data = data;
@@ -12575,6 +12740,8 @@ function setProvidersMsg(text, kind) {
12575
12740
  function renderProvidersViewBody() {
12576
12741
  if (!el.providersList) return;
12577
12742
  el.providersList.replaceChildren(renderProvidersCard(mvState.providers, { signIn: mvState.signIn, split: true }));
12743
+ // OpenRouter's free-model allowance, read afresh each time the card opens.
12744
+ refreshFreeDaily({ force: true });
12578
12745
  }
12579
12746
 
12580
12747
  /** A page-level message, on whichever of the two tabs is showing. */
@@ -12727,7 +12894,9 @@ async function testProviderFlow(btn) {
12727
12894
  const unsaved = hasUnsavedProviderEdits(name, typed);
12728
12895
  if (data.ok) {
12729
12896
  setProviderResult(name, 'ok', `Reachable${data.models != null ? ` — ${data.models} model${data.models === 1 ? '' : 's'}` : ''}`);
12730
- setProviderMsg(name, `${where}${data.models != null ? ` answered with ${data.models} model${data.models === 1 ? '' : 's'}` : ' answered'}.${unsaved ? ' Press Save to keep these settings.' : ''}`);
12897
+ // `detail` is what the endpoint says about the key itself — OpenRouter's credit, free-model
12898
+ // allowance and rate limit (provider-ops formatOpenRouterKeyInfo); never the key.
12899
+ setProviderMsg(name, `${where}${data.models != null ? ` answered with ${data.models} model${data.models === 1 ? '' : 's'}` : ' answered'}.${data.detail ? ` Key: ${data.detail}.` : ''}${unsaved ? ' Press Save to keep these settings.' : ''}`);
12731
12900
  } else {
12732
12901
  const why = data.message || data.error || `HTTP ${res.status}`;
12733
12902
  setProviderResult(name, 'err', 'Failed');
@@ -12838,20 +13007,21 @@ function applyImportFilter() {
12838
13007
  const sheet = el.mimpBody && el.mimpBody.querySelector('.mvi');
12839
13008
  if (!sheet) return;
12840
13009
  const q = (el.mimpFilter?.value || '').trim().toLowerCase();
12841
- let shown = 0;
13010
+ let shown = 0; let total = 0;
12842
13011
  for (const tr of sheet.querySelectorAll('tbody tr')) {
12843
- const hit = !q || tr.textContent.toLowerCase().includes(q) || String(tr.dataset.id || '').toLowerCase().includes(q);
13012
+ const hit = endpointRowMatches(tr, sheet, q); // text, plus a hosted sheet's Free / Tools / window
12844
13013
  tr.classList.toggle('is-filtered', !hit);
13014
+ total += 1;
12845
13015
  if (hit) shown += 1;
12846
13016
  }
12847
- const none = sheet.querySelector('.mimp-nohits');
12848
- if (!shown && q) {
13017
+ let none = sheet.querySelector('.mimp-nohits');
13018
+ if (!shown && total) {
12849
13019
  if (!none) {
12850
- const d = document.createElement('div');
12851
- d.className = 'hist-empty mimp-nohits';
12852
- d.textContent = `Nothing here matches “${el.mimpFilter.value.trim()}”.`;
12853
- sheet.appendChild(d);
13020
+ none = document.createElement('div');
13021
+ none.className = 'hist-empty mimp-nohits';
13022
+ sheet.appendChild(none);
12854
13023
  }
13024
+ none.textContent = q ? `Nothing here matches “${el.mimpFilter.value.trim()}”.` : 'Nothing here matches these filters.';
12855
13025
  } else if (none) none.remove();
12856
13026
  }
12857
13027
 
@@ -13131,7 +13301,11 @@ if (el.providersList) {
13131
13301
  const body = collectProviderRow(providerRoot(), t.dataset.provider);
13132
13302
  if (body) patchProviderFlow(t.dataset.provider, body);
13133
13303
  } else if (t.classList.contains('mv-pv-test')) testProviderFlow(t);
13134
- else if (t.classList.contains('mv-pv-browse')) {
13304
+ else if (t.classList.contains('mv-pv-preset')) {
13305
+ // Fills the fields only; the row's Test / Save do the rest, exactly as for a typed URL.
13306
+ applyProviderPreset(providerRoot(), t.dataset.provider, t.dataset.preset);
13307
+ setProviderMsg(t.dataset.provider, 'OpenRouter filled in. Set the key (or keep ${OPENROUTER_KEY} and export it where Worca starts), Test connection, then Save.');
13308
+ } else if (t.classList.contains('mv-pv-browse')) {
13135
13309
  // The import lands in the CATALOG, so it opens there — with this row's endpoint filled in.
13136
13310
  const row = t.closest('.mv-pv-row');
13137
13311
  const input = row && row.querySelector('.mv-pv-baseurl');
@@ -13290,7 +13464,7 @@ if (el.modelImportModal) {
13290
13464
  const sheet = t.closest('.mvi');
13291
13465
  // Select-all means what is ON SCREEN: ticking a row the filter hides would import a surprise.
13292
13466
  if (sheet) for (const c of sheet.querySelectorAll('tbody tr:not(.is-filtered) .mvi-cb')) { if (!c.disabled) c.checked = t.checked; }
13293
- }
13467
+ } else if (t && t.closest && t.closest('.mvi-or-filters')) applyImportFilter(); // Free / Tools / window
13294
13468
  });
13295
13469
  // Backdrop click and Escape close it, like every other overlay in this file.
13296
13470
  el.modelImportModal.addEventListener('mousedown', (ev) => { if (ev.target === el.modelImportModal) closeImportDialog(); });
@@ -15223,8 +15397,10 @@ function cssEscape(s) {
15223
15397
  // inserted BEFORE `.hist-open` so the chevron stays last in the aside.
15224
15398
  function resetPrCluster(card) {
15225
15399
  const aside = card.querySelector('.hist-aside');
15226
- if (!aside) return;
15227
- const freshPr = $('#hist-card-tpl').content.querySelector('.hist-pr').cloneNode(true);
15400
+ // A PR batch can land after its document is gone (a test harness swapping pages): nothing to patch.
15401
+ const tpl = $('#hist-card-tpl');
15402
+ if (!aside || !tpl) return;
15403
+ const freshPr = tpl.content.querySelector('.hist-pr').cloneNode(true);
15228
15404
  const curPr = aside.querySelector('.hist-pr, .hist-pr-link'); // button OR the swapped-in link
15229
15405
  if (curPr) curPr.replaceWith(freshPr);
15230
15406
  else aside.insertBefore(freshPr, aside.querySelector('.hist-open'));
@@ -15248,8 +15424,8 @@ function patchHistoryPr({ projectKey, id, pr }) {
15248
15424
  if (!card) return; // off-screen (filtered out) — model is enough
15249
15425
  resetPrCluster(card);
15250
15426
  setupPrButton(card, row?.projectDir || null, row || { id, projectKey, pr }, state.ghAvailable);
15251
- // A MERGED enrichment retires the diff pill — merged work is already in the base
15252
- // branch, so its line counts stop being the story.
15427
+ // A MERGED enrichment hides only a LIVE diff pill (the merge emptied its
15428
+ // source...feature diff); a row with frozen counts (`diffFrozen`) keeps showing them.
15253
15429
  renderHistDiffPill(card.querySelector('.hist-diff-pill'), row || { id, projectKey, pr });
15254
15430
  // No setMergePill: clarification B — merged-or-not is shown by the link swap inside
15255
15431
  // setupPrButton (OPEN->"View PR", MERGED->"Merged"); the pill is detail-only now.
@@ -15991,14 +16167,18 @@ function renderHistCommentPill(pill, p) {
15991
16167
  pill.title = `${n} unresolved diff comment${n === 1 ? '' : 's'}`;
15992
16168
  }
15993
16169
 
15994
- // Diff pill: merged PR -> hidden ("the diff is no longer the story"); survived
15995
- // with changes -> +A −R; survived with none -> "no diff"; branch gone -> hidden.
16170
+ // Diff pill: frozen counts (`diffFrozen`, the run's own results.json summary) always
16171
+ // show — merged PR and branch gone alike — as +A −R, or "no diff" when the run
16172
+ // really changed nothing. Without them (a live or legacy run) the counts are the
16173
+ // live source...feature diff: survived with changes -> +A −R; survived with none ->
16174
+ // "no diff"; merged PR or branch gone -> hidden (that diff is empty or unknowable).
15996
16175
  // NOTE: the minus glyph is U+2212 (−), not an ASCII hyphen; the jsdom test
15997
16176
  // asserts it byte-for-byte, so keep this exact character.
15998
16177
  function renderHistDiffPill(pill, p) {
15999
16178
  if (!pill) return;
16179
+ const frozen = !!(p && p.diffFrozen);
16000
16180
  const merged = p && p.pr && typeof p.pr === 'object' && String(p.pr.state || '').toUpperCase() === 'MERGED';
16001
- if (!p || !p.survived || merged) { pill.hidden = true; return; }
16181
+ if (!p || (!frozen && (!p.survived || merged))) { pill.hidden = true; return; }
16002
16182
  pill.hidden = false;
16003
16183
  const added = Number.isFinite(+p.added) ? +p.added : 0;
16004
16184
  const removed = Number.isFinite(+p.removed) ? +p.removed : 0;
@@ -16012,7 +16192,9 @@ function renderHistDiffPill(pill, p) {
16012
16192
  const add = document.createElement('span'); add.className = 'diff-add'; add.textContent = `+${added}`;
16013
16193
  const del = document.createElement('span'); del.className = 'diff-del'; del.textContent = `−${removed}`; // U+2212
16014
16194
  diffEl.append(add, ' ', del);
16015
- pill.title = `${added} added, ${removed} removed vs ${p.sourceBranch || 'source'}`;
16195
+ pill.title = frozen
16196
+ ? `${added} added, ${removed} removed by this run`
16197
+ : `${added} added, ${removed} removed vs ${p.sourceBranch || 'source'}`;
16016
16198
  }
16017
16199
  }
16018
16200
 
@@ -19757,6 +19939,8 @@ function rdStateCopy(r, stepName) {
19757
19939
  return `Paused on a recoverable error${why}. Once it clears, Resume retries the step — the worktree and progress are kept.`;
19758
19940
  }
19759
19941
  if (r.pauseReason === 'usage_limit') {
19942
+ // OpenRouter's daily free requests: the detail already says when they come back and what to do.
19943
+ if (/^OpenRouter's free-model requests/.test(r.pauseDetail || '')) return `Paused — ${r.pauseDetail}.`;
19760
19944
  return `Paused — session/usage limit reached${r.pauseDetail ? ` (${r.pauseDetail})` : ''}. Resume after the reset.`;
19761
19945
  }
19762
19946
  if (r.pauseReason && (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted')) {
@@ -20894,6 +21078,7 @@ function paintAutoBadge(el, stepper) {
20894
21078
  el.hidden = false;
20895
21079
  el.textContent = decided ? `Auto → ${name}` : 'Auto';
20896
21080
  el.title = !decided ? 'Auto is deciding the workflow'
21081
+ : auto.via === 'fallback' ? `The Auto classifier was unreachable${auto.reason ? ` (${auto.reason})` : ''} — running the default workflow "${name}"`
20897
21082
  : auto.via === 'reused' ? `Auto reused the saved workflow "${name}"` : `Auto created the workflow "${name}"`;
20898
21083
  el.classList.toggle('is-deciding', !decided);
20899
21084
  }
@@ -22429,7 +22614,7 @@ function paintRdHeader(screen, r) {
22429
22614
  // Model bridge (model-bridge-design.md §8.6): a run that went through the
22430
22615
  // bridge shows its request count beside the dollars, so a "$0" reads as a
22431
22616
  // unit mismatch (Copilot bills requests), not as free.
22432
- ['rd-cost', fmtUsd(r.totalCostUsd || 0) + bridgeRequestsSuffix(r.steps), true],
22617
+ ['rd-cost', fmtUsd(r.totalCostUsd || 0) + (freeRequestsSuffix(r.steps) || bridgeRequestsSuffix(r.steps)), true],
22433
22618
  ['rd-step', stepText, false],
22434
22619
  ];
22435
22620
  segs.forEach(([cls, txt, strong]) => {
@@ -22438,7 +22623,8 @@ function paintRdHeader(screen, r) {
22438
22623
  const seg = document.createElement('span');
22439
22624
  seg.className = cls + (strong ? ' strong' : '');
22440
22625
  seg.textContent = txt;
22441
- if (cls === 'rd-cost') seg.title = estTitle(r.totalCostUsd || 0) + (bridgeRequestsSuffix(r.steps) ? ' Requests: calls this run initiated through the model bridge (Copilot bills premium requests, not tokens); tool-loop continuations are not counted.' : '');
22626
+ if (cls === 'rd-cost' && freeRequestsSuffix(r.steps)) seg.title = estTitle(r.totalCostUsd || 0) + ' Free requests: calls this run made to OpenRouter :free models (every call, tool-loop continuations too), out of the day\'s free allowance.';
22627
+ else if (cls === 'rd-cost') seg.title = estTitle(r.totalCostUsd || 0) + (bridgeRequestsSuffix(r.steps) ? ' Requests: calls this run initiated through the model bridge (Copilot bills premium requests, not tokens); tool-loop continuations are not counted.' : '');
22442
22628
  meta.appendChild(seg);
22443
22629
  });
22444
22630
 
@@ -22816,10 +23002,10 @@ function gsEnsurePillHost() {
22816
23002
  return gsPillHost;
22817
23003
  }
22818
23004
 
22819
- async function loadOnboarding() {
23005
+ async function loadOnboarding({ recheck = false } = {}) {
22820
23006
  let data;
22821
23007
  try {
22822
- const res = await fetch('/api/onboarding');
23008
+ const res = await fetch(recheck ? '/api/onboarding?recheck=1' : '/api/onboarding');
22823
23009
  data = await safeJson(res);
22824
23010
  if (!res.ok) return;
22825
23011
  } catch { return; }
@@ -22926,10 +23112,13 @@ function paintClaudeSetupStatus() {
22926
23112
  if (!box || !gs.status) return;
22927
23113
  const c = gs.status.claude || {};
22928
23114
  const ok = !!gs.status.steps.claude;
23115
+ const bin = c.bin || 'claude';
22929
23116
  box.className = `ob-claude-status ${ok ? 'ok' : 'err'}`;
22930
- box.textContent = ok
22931
- ? `Found ${c.bin || 'claude'} — you're set.`
22932
- : (c.hint || `"${c.bin || 'claude'}" is not on the PATH of the Worca server. Install it, then check again (restart the UI if PATH changed).`);
23117
+ if (ok) box.textContent = c.auth === 'signed-in' ? `Found ${bin}, installed and signed in — you're set.` : `Found ${bin} — you're set.`;
23118
+ else if (c.auth === 'signed-out') box.textContent = `Found ${bin}, installed but not signed in yet. Run claude in a terminal, type /login, then check again.`;
23119
+ else box.textContent = c.hint || `"${bin}" is not on the PATH of the Worca server. Install it, then check again (restart the UI if PATH changed).`;
23120
+ // The wizards' signed-out lines are stale once the CLI checks out.
23121
+ if (ok) for (const restore of [...claudeSignedOutHints.values()]) restore();
22933
23122
  }
22934
23123
  function openClaudeSetup() {
22935
23124
  const modal = document.getElementById('claude-setup-modal');
@@ -22944,7 +23133,7 @@ document.getElementById('claude-setup-modal')?.addEventListener('click', (e) =>
22944
23133
  document.getElementById('claude-setup-check')?.addEventListener('click', async () => {
22945
23134
  const btn = document.getElementById('claude-setup-check');
22946
23135
  btn.disabled = true;
22947
- try { await loadOnboarding(); } finally { btn.disabled = false; }
23136
+ try { await loadOnboarding({ recheck: true }); } finally { btn.disabled = false; }
22948
23137
  paintClaudeSetupStatus();
22949
23138
  });
22950
23139
  document.addEventListener('keydown', (e) => {
@@ -24094,6 +24283,17 @@ refreshAllCounts();
24094
24283
  refreshBudget();
24095
24284
  startBudgetTick();
24096
24285
  loadWhoami();
24286
+ // Credential broker badges ("your key / no key" on model pickers): fetched at boot and
24287
+ // again when the tab regains focus — people add keys on the key page in another tab.
24288
+ // Pickers already on screen repaint once the answer changes what they show.
24289
+ {
24290
+ const repaintPickers = () => {
24291
+ if (currentView() === 'new') refreshNewPipelinePickers();
24292
+ if (currentView() === 'settings' && currentSettingsTab === 'models') loadModelsView();
24293
+ };
24294
+ loadCredentials().then((d) => { if (d) repaintPickers(); });
24295
+ window.addEventListener('focus', () => { loadCredentials().then((d) => { if (d) repaintPickers(); }); });
24296
+ }
24097
24297
 
24098
24298
  // Ask Worca mount (§10.2 seam 1): a JS-built body-level overlay — index.html is
24099
24299
  // untouched so ui-shell's routed-view census stays at 11. No network happens here;
@@ -24112,6 +24312,7 @@ askPanel = createAskPanel({
24112
24312
  getPageContext,
24113
24313
  openNewPipeline,
24114
24314
  openComposer: (id) => { openComposerFromAsk(id); },
24315
+ openClaudeSetup: () => { openClaudeSetup(); },
24115
24316
  loadMarkdown: loadAskMarkdown,
24116
24317
  hljsLoader: diffHljsLoader,
24117
24318
  storage: window.localStorage,
@@ -190,6 +190,7 @@ export function createThreadModel({ threadId }) {
190
190
  row.text = live.text;
191
191
  row.status = 'error';
192
192
  row.errorMessage = frame.message || 'unknown error';
193
+ row.errorCode = frame.code || null;
193
194
  live = null;
194
195
  inFlight = null;
195
196
  dirty.structure = true;