@worca/app 1.5.0 → 1.6.0-rc.2

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 +8 -2
  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 +56 -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 +202 -10
  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 +271 -54
  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 +216 -9
  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 +303 -244
  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 +110 -54
  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;
@@ -709,7 +773,7 @@ function paintBudget() {
709
773
  topAmt.classList.toggle('over', !!b.blocked);
710
774
  }
711
775
  applyBudgetToNewView();
712
- if (currentView() === 'settings') paintBudgetReadout();
776
+ if (currentView() === 'settings' && currentSettingsTab === 'runs') paintBudgetReadout();
713
777
  repaintCostBanners();
714
778
  }
715
779
 
@@ -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
  // ---------------------------------------------------------------------------
@@ -1000,7 +1066,7 @@ function handleServerMessage(msg) {
1000
1066
  if (currentView() === 'projects') paintProjectPolicyCells(true);
1001
1067
  if (currentView() === 'workspaces') paintWsPolicyLines(true);
1002
1068
  if (currentView() === 'team-policy' && !tpState.editing) loadTeamPolicyView();
1003
- if (currentView() === 'settings') paintTeamCapsReadout(true);
1069
+ if (currentView() === 'settings' && currentSettingsTab === 'runs') paintTeamCapsReadout(true);
1004
1070
  if (currentView() === 'settings' && currentSettingsTab === 'plugins') paintPluginsPolicy(true);
1005
1071
  if (currentView() === 'new') schedulePolicyLine();
1006
1072
  return;
@@ -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();
7488
+ }
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;
7420
7504
  }
7421
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 = [];
@@ -10795,7 +10953,7 @@ try {
10795
10953
  if (mq && typeof mq.addEventListener === 'function') mq.addEventListener('change', () => applyTheme(document.documentElement.dataset.theme));
10796
10954
  } catch { /* no media queries here */ }
10797
10955
 
10798
- // Settings › General › Scheduled runs: the defaults a new schedule inherits.
10956
+ // Settings › Runs › Scheduled runs: the defaults a new schedule inherits.
10799
10957
  function setSchedDefaultsMsg(text, kind) { setHintMsg('schedDefaultsMsg', text, kind); }
10800
10958
  function paintScheduleSettings(data) {
10801
10959
  const d = data && data.schedule;
@@ -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;
@@ -12537,6 +12702,7 @@ async function loadModelsView() {
12537
12702
  const pdata = await safeJson(pres);
12538
12703
  mvState.providers = pres.ok ? pdata : null;
12539
12704
  renderModelsViewBody();
12705
+ void paintHelperModelCards();
12540
12706
  // "+ Add model…" from a picker on another page: the dialog opens once the catalog is here.
12541
12707
  if (mvState.openEditorOnLoad) { mvState.openEditorOnLoad = false; openModelEditorDialog(); }
12542
12708
  // Copilot's models feed the editor's upstream-id datalist; fetched in the
@@ -12551,6 +12717,18 @@ async function loadModelsView() {
12551
12717
  }
12552
12718
  }
12553
12719
 
12720
+ // Settings › Models › Title generation + Auto workflow model: both pickers list the catalog, so they
12721
+ // repaint whenever it loads — a model added a moment ago is selectable without leaving the tab.
12722
+ async function paintHelperModelCards() {
12723
+ try {
12724
+ const res = await fetch('/api/settings');
12725
+ if (!res.ok) return;
12726
+ const data = await safeJson(res);
12727
+ await paintTitleModelSettings(data);
12728
+ await paintAutoModelSettings(data);
12729
+ } catch { /* the cards keep their last paint */ }
12730
+ }
12731
+
12554
12732
  /** The Providers tab: the same card, on a page of its own (§8.1). */
12555
12733
  async function loadProvidersView() {
12556
12734
  if (!el.providersList) return;
@@ -12575,6 +12753,8 @@ function setProvidersMsg(text, kind) {
12575
12753
  function renderProvidersViewBody() {
12576
12754
  if (!el.providersList) return;
12577
12755
  el.providersList.replaceChildren(renderProvidersCard(mvState.providers, { signIn: mvState.signIn, split: true }));
12756
+ // OpenRouter's free-model allowance, read afresh each time the card opens.
12757
+ refreshFreeDaily({ force: true });
12578
12758
  }
12579
12759
 
12580
12760
  /** A page-level message, on whichever of the two tabs is showing. */
@@ -12727,7 +12907,9 @@ async function testProviderFlow(btn) {
12727
12907
  const unsaved = hasUnsavedProviderEdits(name, typed);
12728
12908
  if (data.ok) {
12729
12909
  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.' : ''}`);
12910
+ // `detail` is what the endpoint says about the key itself — OpenRouter's credit, free-model
12911
+ // allowance and rate limit (provider-ops formatOpenRouterKeyInfo); never the key.
12912
+ 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
12913
  } else {
12732
12914
  const why = data.message || data.error || `HTTP ${res.status}`;
12733
12915
  setProviderResult(name, 'err', 'Failed');
@@ -12838,20 +13020,21 @@ function applyImportFilter() {
12838
13020
  const sheet = el.mimpBody && el.mimpBody.querySelector('.mvi');
12839
13021
  if (!sheet) return;
12840
13022
  const q = (el.mimpFilter?.value || '').trim().toLowerCase();
12841
- let shown = 0;
13023
+ let shown = 0; let total = 0;
12842
13024
  for (const tr of sheet.querySelectorAll('tbody tr')) {
12843
- const hit = !q || tr.textContent.toLowerCase().includes(q) || String(tr.dataset.id || '').toLowerCase().includes(q);
13025
+ const hit = endpointRowMatches(tr, sheet, q); // text, plus a hosted sheet's Free / Tools / window
12844
13026
  tr.classList.toggle('is-filtered', !hit);
13027
+ total += 1;
12845
13028
  if (hit) shown += 1;
12846
13029
  }
12847
- const none = sheet.querySelector('.mimp-nohits');
12848
- if (!shown && q) {
13030
+ let none = sheet.querySelector('.mimp-nohits');
13031
+ if (!shown && total) {
12849
13032
  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);
13033
+ none = document.createElement('div');
13034
+ none.className = 'hist-empty mimp-nohits';
13035
+ sheet.appendChild(none);
12854
13036
  }
13037
+ none.textContent = q ? `Nothing here matches “${el.mimpFilter.value.trim()}”.` : 'Nothing here matches these filters.';
12855
13038
  } else if (none) none.remove();
12856
13039
  }
12857
13040
 
@@ -13131,7 +13314,11 @@ if (el.providersList) {
13131
13314
  const body = collectProviderRow(providerRoot(), t.dataset.provider);
13132
13315
  if (body) patchProviderFlow(t.dataset.provider, body);
13133
13316
  } else if (t.classList.contains('mv-pv-test')) testProviderFlow(t);
13134
- else if (t.classList.contains('mv-pv-browse')) {
13317
+ else if (t.classList.contains('mv-pv-preset')) {
13318
+ // Fills the fields only; the row's Test / Save do the rest, exactly as for a typed URL.
13319
+ applyProviderPreset(providerRoot(), t.dataset.provider, t.dataset.preset);
13320
+ setProviderMsg(t.dataset.provider, 'OpenRouter filled in. Set the key (or keep ${OPENROUTER_KEY} and export it where Worca starts), Test connection, then Save.');
13321
+ } else if (t.classList.contains('mv-pv-browse')) {
13135
13322
  // The import lands in the CATALOG, so it opens there — with this row's endpoint filled in.
13136
13323
  const row = t.closest('.mv-pv-row');
13137
13324
  const input = row && row.querySelector('.mv-pv-baseurl');
@@ -13290,7 +13477,7 @@ if (el.modelImportModal) {
13290
13477
  const sheet = t.closest('.mvi');
13291
13478
  // Select-all means what is ON SCREEN: ticking a row the filter hides would import a surprise.
13292
13479
  if (sheet) for (const c of sheet.querySelectorAll('tbody tr:not(.is-filtered) .mvi-cb')) { if (!c.disabled) c.checked = t.checked; }
13293
- }
13480
+ } else if (t && t.closest && t.closest('.mvi-or-filters')) applyImportFilter(); // Free / Tools / window
13294
13481
  });
13295
13482
  // Backdrop click and Escape close it, like every other overlay in this file.
13296
13483
  el.modelImportModal.addEventListener('mousedown', (ev) => { if (ev.target === el.modelImportModal) closeImportDialog(); });
@@ -13712,7 +13899,7 @@ if (runListEl) {
13712
13899
  if (runId) confirmCostOverride(runId, overrideBtn);
13713
13900
  return;
13714
13901
  }
13715
- if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings'; return; }
13902
+ if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings/runs'; return; }
13716
13903
  // Team-cap banner (team-policy design board 9): continue past, or open the page.
13717
13904
  const pastBtn = e.target.closest && e.target.closest('.cb-past-team-cap');
13718
13905
  if (pastBtn) {
@@ -14401,7 +14588,7 @@ if (tpSection) tpSection.addEventListener('click', async (e) => {
14401
14588
  await handlePolicyPluginClick(e);
14402
14589
  });
14403
14590
 
14404
- // Settings › Budget (board 7): each home's caps as a readout, the tightest as a chip on the labels.
14591
+ // Settings › Runs › Budget (board 7): each home's caps as a readout, the tightest as a chip on the labels.
14405
14592
  async function paintTeamCapsReadout(force = false) {
14406
14593
  if (!el.teamCapsReadout) return;
14407
14594
  const data = await loadTpScopes({ force });
@@ -15223,8 +15410,10 @@ function cssEscape(s) {
15223
15410
  // inserted BEFORE `.hist-open` so the chevron stays last in the aside.
15224
15411
  function resetPrCluster(card) {
15225
15412
  const aside = card.querySelector('.hist-aside');
15226
- if (!aside) return;
15227
- const freshPr = $('#hist-card-tpl').content.querySelector('.hist-pr').cloneNode(true);
15413
+ // A PR batch can land after its document is gone (a test harness swapping pages): nothing to patch.
15414
+ const tpl = $('#hist-card-tpl');
15415
+ if (!aside || !tpl) return;
15416
+ const freshPr = tpl.content.querySelector('.hist-pr').cloneNode(true);
15228
15417
  const curPr = aside.querySelector('.hist-pr, .hist-pr-link'); // button OR the swapped-in link
15229
15418
  if (curPr) curPr.replaceWith(freshPr);
15230
15419
  else aside.insertBefore(freshPr, aside.querySelector('.hist-open'));
@@ -15248,8 +15437,8 @@ function patchHistoryPr({ projectKey, id, pr }) {
15248
15437
  if (!card) return; // off-screen (filtered out) — model is enough
15249
15438
  resetPrCluster(card);
15250
15439
  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.
15440
+ // A MERGED enrichment hides only a LIVE diff pill (the merge emptied its
15441
+ // source...feature diff); a row with frozen counts (`diffFrozen`) keeps showing them.
15253
15442
  renderHistDiffPill(card.querySelector('.hist-diff-pill'), row || { id, projectKey, pr });
15254
15443
  // No setMergePill: clarification B — merged-or-not is shown by the link swap inside
15255
15444
  // setupPrButton (OPEN->"View PR", MERGED->"Merged"); the pill is detail-only now.
@@ -15991,14 +16180,18 @@ function renderHistCommentPill(pill, p) {
15991
16180
  pill.title = `${n} unresolved diff comment${n === 1 ? '' : 's'}`;
15992
16181
  }
15993
16182
 
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.
16183
+ // Diff pill: frozen counts (`diffFrozen`, the run's own results.json summary) always
16184
+ // show — merged PR and branch gone alike — as +A −R, or "no diff" when the run
16185
+ // really changed nothing. Without them (a live or legacy run) the counts are the
16186
+ // live source...feature diff: survived with changes -> +A −R; survived with none ->
16187
+ // "no diff"; merged PR or branch gone -> hidden (that diff is empty or unknowable).
15996
16188
  // NOTE: the minus glyph is U+2212 (−), not an ASCII hyphen; the jsdom test
15997
16189
  // asserts it byte-for-byte, so keep this exact character.
15998
16190
  function renderHistDiffPill(pill, p) {
15999
16191
  if (!pill) return;
16192
+ const frozen = !!(p && p.diffFrozen);
16000
16193
  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; }
16194
+ if (!p || (!frozen && (!p.survived || merged))) { pill.hidden = true; return; }
16002
16195
  pill.hidden = false;
16003
16196
  const added = Number.isFinite(+p.added) ? +p.added : 0;
16004
16197
  const removed = Number.isFinite(+p.removed) ? +p.removed : 0;
@@ -16012,7 +16205,9 @@ function renderHistDiffPill(pill, p) {
16012
16205
  const add = document.createElement('span'); add.className = 'diff-add'; add.textContent = `+${added}`;
16013
16206
  const del = document.createElement('span'); del.className = 'diff-del'; del.textContent = `−${removed}`; // U+2212
16014
16207
  diffEl.append(add, ' ', del);
16015
- pill.title = `${added} added, ${removed} removed vs ${p.sourceBranch || 'source'}`;
16208
+ pill.title = frozen
16209
+ ? `${added} added, ${removed} removed by this run`
16210
+ : `${added} added, ${removed} removed vs ${p.sourceBranch || 'source'}`;
16016
16211
  }
16017
16212
  }
16018
16213
 
@@ -17195,7 +17390,7 @@ function paintHdBanners(screen, record, data) {
17195
17390
  { pauseReason, pauseDetail, pipelineId: record.id, totalCostUsd: st.totalCostUsd },
17196
17391
  { budget: budgetState.budget || {}, fmt: { usd: fmtUsd, usd4: fmtUsd4, duration: fmtDuration, estTitle } });
17197
17392
  const settingsBtn = banner.querySelector('.cb-settings');
17198
- if (settingsBtn) settingsBtn.addEventListener('click', () => { location.hash = 'settings'; });
17393
+ if (settingsBtn) settingsBtn.addEventListener('click', () => { location.hash = 'settings/runs'; });
17199
17394
  const overrideBtn = banner.querySelector('.cb-override');
17200
17395
  if (overrideBtn) {
17201
17396
  overrideBtn.addEventListener('click', () => {
@@ -19757,6 +19952,8 @@ function rdStateCopy(r, stepName) {
19757
19952
  return `Paused on a recoverable error${why}. Once it clears, Resume retries the step — the worktree and progress are kept.`;
19758
19953
  }
19759
19954
  if (r.pauseReason === 'usage_limit') {
19955
+ // OpenRouter's daily free requests: the detail already says when they come back and what to do.
19956
+ if (/^OpenRouter's free-model requests/.test(r.pauseDetail || '')) return `Paused — ${r.pauseDetail}.`;
19760
19957
  return `Paused — session/usage limit reached${r.pauseDetail ? ` (${r.pauseDetail})` : ''}. Resume after the reset.`;
19761
19958
  }
19762
19959
  if (r.pauseReason && (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted')) {
@@ -20894,6 +21091,7 @@ function paintAutoBadge(el, stepper) {
20894
21091
  el.hidden = false;
20895
21092
  el.textContent = decided ? `Auto → ${name}` : 'Auto';
20896
21093
  el.title = !decided ? 'Auto is deciding the workflow'
21094
+ : auto.via === 'fallback' ? `The Auto classifier was unreachable${auto.reason ? ` (${auto.reason})` : ''} — running the default workflow "${name}"`
20897
21095
  : auto.via === 'reused' ? `Auto reused the saved workflow "${name}"` : `Auto created the workflow "${name}"`;
20898
21096
  el.classList.toggle('is-deciding', !decided);
20899
21097
  }
@@ -22352,7 +22550,7 @@ el.runDetail?.addEventListener('click', (e) => {
22352
22550
  if (!r) return;
22353
22551
  const override = e.target.closest && e.target.closest('.cb-override');
22354
22552
  if (override) { confirmCostOverride(r.runId, override); return; } // async, fire-and-forget
22355
- if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings'; return; }
22553
+ if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings/runs'; return; }
22356
22554
  const past = e.target.closest && e.target.closest('.cb-past-team-cap');
22357
22555
  if (past) { confirmPastTeamCap(r.runId, past); return; } // team-policy board 9
22358
22556
  if (e.target.closest && e.target.closest('.cb-policy-open')) { location.hash = 'team-policy'; return; }
@@ -22429,7 +22627,7 @@ function paintRdHeader(screen, r) {
22429
22627
  // Model bridge (model-bridge-design.md §8.6): a run that went through the
22430
22628
  // bridge shows its request count beside the dollars, so a "$0" reads as a
22431
22629
  // unit mismatch (Copilot bills requests), not as free.
22432
- ['rd-cost', fmtUsd(r.totalCostUsd || 0) + bridgeRequestsSuffix(r.steps), true],
22630
+ ['rd-cost', fmtUsd(r.totalCostUsd || 0) + (freeRequestsSuffix(r.steps) || bridgeRequestsSuffix(r.steps)), true],
22433
22631
  ['rd-step', stepText, false],
22434
22632
  ];
22435
22633
  segs.forEach(([cls, txt, strong]) => {
@@ -22438,7 +22636,8 @@ function paintRdHeader(screen, r) {
22438
22636
  const seg = document.createElement('span');
22439
22637
  seg.className = cls + (strong ? ' strong' : '');
22440
22638
  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.' : '');
22639
+ 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.';
22640
+ 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
22641
  meta.appendChild(seg);
22443
22642
  });
22444
22643
 
@@ -22816,10 +23015,10 @@ function gsEnsurePillHost() {
22816
23015
  return gsPillHost;
22817
23016
  }
22818
23017
 
22819
- async function loadOnboarding() {
23018
+ async function loadOnboarding({ recheck = false } = {}) {
22820
23019
  let data;
22821
23020
  try {
22822
- const res = await fetch('/api/onboarding');
23021
+ const res = await fetch(recheck ? '/api/onboarding?recheck=1' : '/api/onboarding');
22823
23022
  data = await safeJson(res);
22824
23023
  if (!res.ok) return;
22825
23024
  } catch { return; }
@@ -22926,10 +23125,13 @@ function paintClaudeSetupStatus() {
22926
23125
  if (!box || !gs.status) return;
22927
23126
  const c = gs.status.claude || {};
22928
23127
  const ok = !!gs.status.steps.claude;
23128
+ const bin = c.bin || 'claude';
22929
23129
  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).`);
23130
+ if (ok) box.textContent = c.auth === 'signed-in' ? `Found ${bin}, installed and signed in — you're set.` : `Found ${bin} — you're set.`;
23131
+ 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.`;
23132
+ 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).`;
23133
+ // The wizards' signed-out lines are stale once the CLI checks out.
23134
+ if (ok) for (const restore of [...claudeSignedOutHints.values()]) restore();
22933
23135
  }
22934
23136
  function openClaudeSetup() {
22935
23137
  const modal = document.getElementById('claude-setup-modal');
@@ -22944,7 +23146,7 @@ document.getElementById('claude-setup-modal')?.addEventListener('click', (e) =>
22944
23146
  document.getElementById('claude-setup-check')?.addEventListener('click', async () => {
22945
23147
  const btn = document.getElementById('claude-setup-check');
22946
23148
  btn.disabled = true;
22947
- try { await loadOnboarding(); } finally { btn.disabled = false; }
23149
+ try { await loadOnboarding({ recheck: true }); } finally { btn.disabled = false; }
22948
23150
  paintClaudeSetupStatus();
22949
23151
  });
22950
23152
  document.addEventListener('keydown', (e) => {
@@ -23465,11 +23667,11 @@ const VIEW_MIN_LEVEL = Object.freeze({
23465
23667
  'team-metrics': 'expert', 'team-policy': 'expert', agents: 'expert', scripts: 'expert',
23466
23668
  schedules: 'advanced',
23467
23669
  });
23468
- const SETTINGS_TAB_MIN_LEVEL = Object.freeze({ guardrails: 'advanced', plugins: 'advanced', memory: 'advanced', models: 'expert', providers: 'expert' });
23670
+ const SETTINGS_TAB_MIN_LEVEL = Object.freeze({ ask: 'advanced', guardrails: 'advanced', plugins: 'advanced', memory: 'advanced', models: 'expert', providers: 'expert' });
23469
23671
  const VIEW_TITLES = Object.freeze({
23470
23672
  stats: 'Statistics', composer: 'Workflow Composer', workspaces: 'Workspaces', 'workspace-create': 'Workspaces',
23471
23673
  'agent-create': 'Create agent', 'team-metrics': 'Team metrics', 'team-policy': 'Team policy', agents: 'Agents', scripts: 'Scripts',
23472
- guardrails: 'Guardrails', plugins: 'Plugins', memory: 'Memory', models: 'Models', providers: 'Providers',
23674
+ guardrails: 'Guardrails', plugins: 'Plugins', memory: 'Memory', models: 'Models', providers: 'Providers', ask: 'Ask Worca',
23473
23675
  schedules: 'Schedules',
23474
23676
  });
23475
23677
  function pageMinLevel() {
@@ -23526,7 +23728,10 @@ document.addEventListener('worca:level', () => {
23526
23728
  // The tab is the Settings view's hash param; a guardrail deep link nests its id
23527
23729
  // behind it (#settings/guardrails/<id>). parseHash splits on the FIRST '/' only,
23528
23730
  // so that is view 'settings', param 'guardrails/<id>' — no parseHash change.
23529
- const SETTINGS_TABS = ['general', 'guardrails', 'models', 'providers', 'plugins', 'memory'];
23731
+ const SETTINGS_TABS = ['general', 'runs', 'ask', 'guardrails', 'memory', 'plugins', 'models', 'providers'];
23732
+ // The tabs whose cards GET /api/settings paints (loadSettings paints every card, wherever it sits).
23733
+ // Models is not one: loadModelsView repaints its two helper-model cards with the catalog.
23734
+ const SETTINGS_FORM_TABS = ['general', 'runs', 'ask'];
23530
23735
  const settingsPanes = $$('[data-view="settings"] .settings-pane');
23531
23736
  // Old top-level hashes keep working. The hashchange listener DROPS any view it
23532
23737
  // does not know, so without this map a bookmark or an old in-app link would
@@ -23776,7 +23981,7 @@ function showSettingsTab(param = '') {
23776
23981
  // request, and re-entry refetches (which is what lets grvExitWizard's
23777
23982
  // '#settings/guardrails/<id>' -> '#settings/guardrails' hop reset the wizard).
23778
23983
  paintLevelBanner();
23779
- if (tab === 'general') loadSettings();
23984
+ if (SETTINGS_FORM_TABS.includes(tab)) loadSettings();
23780
23985
  if (tab === 'guardrails') loadGuardrailsView(sub);
23781
23986
  if (tab === 'models') loadModelsView(sub);
23782
23987
  if (tab === 'providers') loadProvidersView();
@@ -24094,6 +24299,17 @@ refreshAllCounts();
24094
24299
  refreshBudget();
24095
24300
  startBudgetTick();
24096
24301
  loadWhoami();
24302
+ // Credential broker badges ("your key / no key" on model pickers): fetched at boot and
24303
+ // again when the tab regains focus — people add keys on the key page in another tab.
24304
+ // Pickers already on screen repaint once the answer changes what they show.
24305
+ {
24306
+ const repaintPickers = () => {
24307
+ if (currentView() === 'new') refreshNewPipelinePickers();
24308
+ if (currentView() === 'settings' && currentSettingsTab === 'models') loadModelsView();
24309
+ };
24310
+ loadCredentials().then((d) => { if (d) repaintPickers(); });
24311
+ window.addEventListener('focus', () => { loadCredentials().then((d) => { if (d) repaintPickers(); }); });
24312
+ }
24097
24313
 
24098
24314
  // Ask Worca mount (§10.2 seam 1): a JS-built body-level overlay — index.html is
24099
24315
  // untouched so ui-shell's routed-view census stays at 11. No network happens here;
@@ -24112,6 +24328,7 @@ askPanel = createAskPanel({
24112
24328
  getPageContext,
24113
24329
  openNewPipeline,
24114
24330
  openComposer: (id) => { openComposerFromAsk(id); },
24331
+ openClaudeSetup: () => { openClaudeSetup(); },
24115
24332
  loadMarkdown: loadAskMarkdown,
24116
24333
  hljsLoader: diffHljsLoader,
24117
24334
  storage: window.localStorage,