@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
@@ -2,7 +2,8 @@
2
2
  // Pure DOM renderers for the Settings "Chat notifications" card
3
3
  // (chat-connectivity-design.md §4.8). Same contract as plugins-view.mjs:
4
4
  // detached elements, no fetch, no listeners — app.js owns I/O and mounting;
5
- // node:test drives these via jsdom.
5
+ // node:test drives these via jsdom. (One exception: the Ask web fields keep their own
6
+ // dirty flag with input/change listeners on their detached wrapper — no I/O.)
6
7
 
7
8
  function h(doc, tag, cls, text) {
8
9
  const n = doc.createElement(tag);
@@ -115,3 +116,53 @@ export function collectScriptToolsToggle(root) {
115
116
  const cb = root && typeof root.querySelector === 'function' ? root.querySelector('input.ask-script-tools') : null;
116
117
  return { scriptTools: cb ? cb.checked : true };
117
118
  }
119
+
120
+ /** Ask Worca → Web access (docs/guardrails.md "Web access"): off by default; the allowlist is enforced by worca's server. */
121
+ export function renderAskWebFields({ askWeb } = {}, { doc = globalThis.document } = {}) {
122
+ const w = askWeb || { enabled: false, anyHost: false, allowedDomains: [], search: null };
123
+ const wrap = h(doc, 'div', 'ask-web');
124
+ wrap.append(h(doc, 'div', 'label-row', 'Web access'));
125
+ const row = h(doc, 'label', 'check-row'); row.setAttribute('for', 'askWebEnabled');
126
+ const cb = h(doc, 'input'); cb.type = 'checkbox'; cb.id = 'askWebEnabled'; cb.checked = w.enabled === true;
127
+ row.append(cb, doc.createTextNode(' Let Ask Worca read web pages'));
128
+ const anyRow = h(doc, 'label', 'check-row'); anyRow.setAttribute('for', 'askWebAnyHost');
129
+ const any = h(doc, 'input'); any.type = 'checkbox'; any.id = 'askWebAnyHost'; any.checked = w.anyHost === true;
130
+ anyRow.append(any, doc.createTextNode(' Any site, without asking'));
131
+ const anyHint = h(doc, 'small', 'hint', 'Risky: a web page or a file Ask reads can then make it send data to any site in a URL. Leave off to approve each new site from the chat.');
132
+ const domains = h(doc, 'textarea', 'input'); domains.id = 'askWebDomains'; domains.rows = 4;
133
+ domains.placeholder = 'docs.python.org\n*.mozilla.org'; domains.value = (w.allowedDomains || []).join('\n');
134
+ const dHint = h(doc, 'small', 'hint', 'Sites Ask may read without asking. One host per line: example.com, or *.example.com for its subdomains. For any other site Ask shows a card in the chat: allow it for that chat, always (it is added here), or deny. https only. A site sees every URL Ask requests from it, so only allow sites you trust.');
135
+ const field = (id, label, placeholder, value) => {
136
+ const box = h(doc, 'div', 'field');
137
+ const l = h(doc, 'label', null, label); l.setAttribute('for', id);
138
+ const i = h(doc, 'input', 'input'); i.id = id; i.placeholder = placeholder; i.value = value || '';
139
+ box.append(l, i); return box;
140
+ };
141
+ const s = w.search || {};
142
+ // Dirty flag: the card posts askWeb only after the user touched a web field, so saving the other
143
+ // Ask limits never rewrites the stored web settings.
144
+ const markDirty = () => { wrap.dataset.dirty = '1'; };
145
+ wrap.addEventListener('input', markDirty);
146
+ wrap.addEventListener('change', markDirty);
147
+ wrap.append(row, anyRow, anyHint, domains, dHint,
148
+ field('askWebSearchUrl', 'Search endpoint (optional)', 'https://api.search.brave.com/res/v1/web/search?q={query}', s.url),
149
+ field('askWebSearchKey', 'Search key variable', '${BRAVE_API_KEY}', s.key),
150
+ field('askWebSearchHeader', 'Key header', 'X-Subscription-Token', s.keyHeader),
151
+ field('askWebSearchPrefix', 'Key prefix', 'Bearer ', s.keyPrefix),
152
+ h(doc, 'small', 'hint', 'Any GET search API that returns JSON. Use {query} (and optionally {key}) in the URL; the key is always a ${VAR} read from worca\'s environment.'));
153
+ return wrap;
154
+ }
155
+
156
+ /** The askWeb POST value, or null when the user did not touch the web fields (nothing to send). */
157
+ export function collectAskWebFields(root) {
158
+ if (root.querySelector('.ask-web')?.dataset.dirty !== '1') return null;
159
+ const q = (id) => root.querySelector(`#${id}`);
160
+ const val = (id) => (q(id) ? q(id).value.trim() : '');
161
+ const url = val('askWebSearchUrl');
162
+ return {
163
+ enabled: !!q('askWebEnabled')?.checked,
164
+ anyHost: !!q('askWebAnyHost')?.checked,
165
+ allowedDomains: (q('askWebDomains')?.value || '').split('\n').map((x) => x.trim()).filter(Boolean),
166
+ search: url ? { url, key: val('askWebSearchKey'), keyHeader: val('askWebSearchHeader'), keyPrefix: q('askWebSearchPrefix') ? q('askWebSearchPrefix').value : '' } : null,
167
+ };
168
+ }
@@ -0,0 +1,63 @@
1
+ // ui/public/credential-badges.mjs
2
+ // "Your key / No key" badges on model pickers (credential broker, docs/credential-broker.md).
3
+ // One cache of GET /api/credentials — which slot each catalog model spends from, and the
4
+ // signed-in person's key state per slot — that every picker asks for a model's badge.
5
+ // Nothing here without a broker: credentialBadge() returns null and no picker changes.
6
+
7
+ let data = null;
8
+ let pending = null;
9
+ const listeners = new Set();
10
+
11
+ /** Replace the cached payload (app.js after a fetch; tests directly). */
12
+ export function setCredentialData(d) {
13
+ data = d && d.enabled ? d : null;
14
+ for (const fn of listeners) { try { fn(); } catch { /* a listener must not break the rest */ } }
15
+ }
16
+
17
+ /** Repaint hook: called whenever the cached payload changes. */
18
+ export function onCredentialsChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
19
+
20
+ /** Fetch /api/credentials once at a time; never throws (no broker or no answer = no badges). */
21
+ export function loadCredentials(fetchImpl = globalThis.fetch) {
22
+ if (pending) return pending;
23
+ pending = (async () => {
24
+ try {
25
+ const r = await fetchImpl('/api/credentials');
26
+ setCredentialData(r.ok ? await r.json() : null);
27
+ } catch { setCredentialData(null); } finally { pending = null; }
28
+ return data;
29
+ })();
30
+ return pending;
31
+ }
32
+
33
+ /** The key page URL, when the broker has one. */
34
+ export function keyPageUrl() { return data?.keyPage || null; }
35
+
36
+ /**
37
+ * The badge for one model, or null (no broker, unknown model, nobody signed in).
38
+ * @returns {{text:string, tone:'green'|'blue'|'red', title:string, missing:boolean}|null}
39
+ */
40
+ export function credentialBadge(modelId, d = data) {
41
+ if (!d || !d.enabled || !d.models) return null;
42
+ const m = d.models[modelId];
43
+ if (!m) return null;
44
+ if (m.keyless) return { text: 'local', tone: 'blue', title: 'A local model: no key needed.', missing: false };
45
+ if (m.error) return { text: 'no route', tone: 'red', title: m.error, missing: true };
46
+ if (d.mode === 'multi' && !d.person) return null;
47
+ const s = (d.slots || []).find((x) => x.id === m.slot);
48
+ const label = s?.label || m.slot;
49
+ const where = d.keyPage ? ' on the key page' : ' to the credential broker';
50
+ switch (s?.state) {
51
+ case 'set': return { text: 'your key', tone: 'green', title: `Runs with your ${label}.`, missing: false };
52
+ case 'operator': return { text: 'team key', tone: 'blue', title: `Runs with the team's ${label}.`, missing: false };
53
+ case 'keyless': return { text: 'local', tone: 'blue', title: 'No key needed.', missing: false };
54
+ case 'invalid': return { text: 'key rejected', tone: 'red', title: `The provider rejected your ${label}: replace it${where}.`, missing: true };
55
+ default: return { text: 'no key', tone: 'red', title: `Add your ${label}${where} to use this model.`, missing: true };
56
+ }
57
+ }
58
+
59
+ /** A short suffix for a plain <option> label (a select can't hold a styled badge). */
60
+ export function credentialSuffix(modelId, d = data) {
61
+ const b = credentialBadge(modelId, d);
62
+ return b && b.missing ? ` · ${b.text}` : '';
63
+ }
@@ -0,0 +1,57 @@
1
+ // ui/public/credentials-view.mjs
2
+ // Pure DOM renderer for the Settings "My model credentials" card
3
+ // (docs/credential-broker.md). Same contract as chat-settings-view.mjs: a detached
4
+ // element, no fetch, no listeners — app.js owns I/O and mounting; node:test drives
5
+ // it via jsdom. Status only: which of the signed-in person's credential slots hold a
6
+ // key, as the credential broker reports it (GET /api/credentials). Keys are entered
7
+ // on the broker's own key page, never here.
8
+
9
+ function h(doc, tag, cls, text) {
10
+ const n = doc.createElement(tag);
11
+ if (cls) n.className = cls;
12
+ if (text != null) n.textContent = text;
13
+ return n;
14
+ }
15
+
16
+ export const CREDENTIAL_STATE_TEXT = Object.freeze({
17
+ set: 'Set', missing: 'Not set', invalid: 'Rejected', keyless: 'No key needed', operator: 'Provided by the team',
18
+ });
19
+ const STATE_TONE = Object.freeze({ set: 'green', missing: 'slate', invalid: 'red', keyless: 'blue', operator: 'blue' });
20
+
21
+ /**
22
+ * @param {{enabled:boolean, mode?:string, keyPage?:string|null, person?:string|null, slots?:object[], error?:string}} data
23
+ * @param {Document} [doc]
24
+ * @returns {HTMLElement}
25
+ */
26
+ export function renderCredentials(data, doc = document) {
27
+ const box = h(doc, 'div', 'cred-view');
28
+ if (data?.error) {
29
+ box.append(h(doc, 'p', 'hint err', `The credential broker didn't answer: ${data.error}`));
30
+ return box;
31
+ }
32
+ if (data?.mode === 'single') {
33
+ box.append(h(doc, 'p', 'hint', 'Model keys are held by the credential broker. To change them, update the broker\'s secrets and restart it.'));
34
+ } else if (!data?.person) {
35
+ box.append(h(doc, 'p', 'hint', 'Sign in through your team\'s sign-in page to see your credentials.'));
36
+ return box;
37
+ }
38
+ const list = h(doc, 'ul', 'cred-list');
39
+ for (const s of data.slots || []) {
40
+ const text = s.state === 'set' && s.suffix ? `Set ••••${s.suffix}` : (CREDENTIAL_STATE_TEXT[s.state] || s.state);
41
+ const row = h(doc, 'li', 'cred-row');
42
+ row.append(h(doc, 'span', 'cred-name', s.label || s.id), h(doc, 'span', `cred-chip tone-${STATE_TONE[s.state] || 'slate'}`, text));
43
+ list.append(row);
44
+ }
45
+ box.append(list);
46
+ if (data.mode === 'multi' && data.keyPage) {
47
+ const missing = (data.slots || []).some((s) => s.state === 'missing' || s.state === 'invalid');
48
+ const actions = h(doc, 'div', 'cred-actions');
49
+ const a = h(doc, 'a', `btn ${missing ? 'btn-primary' : 'btn-ghost'} btn-mini`, 'Manage keys');
50
+ a.href = data.keyPage;
51
+ a.target = '_blank';
52
+ a.rel = 'noopener';
53
+ actions.append(a, h(doc, 'small', 'hint', 'Opens the key page in a new tab. Reopen Settings afterwards to see the change.'));
54
+ box.append(actions);
55
+ }
56
+ return box;
57
+ }
@@ -408,6 +408,8 @@
408
408
 
409
409
  <!-- Actions -->
410
410
  <div class="hint err" id="newBlockedNote" hidden></div>
411
+ <!-- OpenRouter free requests left today vs what a run here takes (openrouter-free-view.mjs). -->
412
+ <div class="hint warn" id="newFreeNote" hidden></div>
411
413
  <div class="actions" style="justify-content:flex-end">
412
414
  <!-- A time picked BEFORE the task (Schedules › Schedule a run, or Change… here):
413
415
  it waits on the form and Start run becomes Schedule. -->
@@ -988,7 +990,11 @@
988
990
  <input id="wiz-name" class="input" type="text" placeholder="e.g. IoT SP Platform" spellcheck="false" />
989
991
  </div>
990
992
  <div class="field">
991
- <label>Projects <span class="opt">(select 2 or more)</span></label>
993
+ <div class="wiz-proj-head">
994
+ <label class="label">Projects <span class="opt">(select 2 or more)</span></label>
995
+ <!-- Outside #wiz-projects so every checkbox in the list stays a project. -->
996
+ <label class="wiz-select-all"><input type="checkbox" id="wiz-select-all" /> Select all</label>
997
+ </div>
992
998
  <div class="wiz-proj-list" id="wiz-projects"></div>
993
999
  <small class="hint" id="wiz-step1-hint">Onboard at least two projects to create a workspace.</small>
994
1000
  </div>
@@ -1377,6 +1383,20 @@
1377
1383
  <small class="hint" id="themeMsg"></small>
1378
1384
  </section>
1379
1385
 
1386
+ <!-- Credential broker (docs/credential-broker.md): shown only when worca runs with one. -->
1387
+ <section class="card settings-card" id="credentials-card" data-min-level="simple" hidden>
1388
+ <div class="card-head">
1389
+ <div class="label-row">
1390
+ <h2>My model credentials</h2>
1391
+ <button type="button" class="info-tip" aria-label="About model credentials">i<span class="tip-content hidden">
1392
+ Model keys are held by the credential broker, not by Worca. Agents never see them.
1393
+ Each person uses their own keys, and what a run costs is charged to whoever started it.
1394
+ </span></button>
1395
+ </div>
1396
+ </div>
1397
+ <div id="credentialsHost"></div>
1398
+ </section>
1399
+
1380
1400
  <!-- Interface mode (docs/ui-levels.md): the same setting as the sidebar item, same dialog. -->
1381
1401
  <section class="card settings-card" id="mode-settings-card" data-min-level="simple">
1382
1402
  <div class="card-head">
@@ -1506,24 +1526,27 @@
1506
1526
  <div class="label-row">
1507
1527
  <label for="askMaxTurns">Turn limit</label>
1508
1528
  <button type="button" class="info-tip" aria-label="About Turn limit">i<span class="tip-content hidden">
1509
- Maximum assistant/tool turns one chat message may use (1&ndash;500). Leave empty to restore the default of 40.
1529
+ Maximum assistant/tool turns one chat message may use (1&ndash;500). Leave empty to restore the default of 400.
1510
1530
  </span></button>
1511
1531
  </div>
1512
- <input id="askMaxTurns" class="input" type="number" min="1" max="500" step="1" inputmode="numeric" placeholder="40" autocomplete="off" />
1532
+ <input id="askMaxTurns" class="input" type="number" min="1" max="500" step="1" inputmode="numeric" placeholder="400" autocomplete="off" />
1513
1533
  </div>
1514
1534
  <div class="field">
1515
1535
  <div class="label-row">
1516
1536
  <label for="askMaxBudgetUsd">Per-turn cost cap (USD)</label>
1517
1537
  <button type="button" class="info-tip" aria-label="About Per-turn cost cap">i<span class="tip-content hidden">
1518
- Stop a chat turn when its estimated cost reaches this amount (0.1&ndash;100). Leave empty to restore the default of $2, or tick No cap to disable the guard.
1538
+ There is no cap by default. Untick No cap and enter an amount (0.1&ndash;100) to turn the guard on: a chat turn then stops when its estimated cost reaches it. Leaving the amount empty, or pressing Use defaults, means no cap.
1519
1539
  </span></button>
1520
1540
  </div>
1521
- <input id="askMaxBudgetUsd" class="input" type="number" min="0.1" max="100" step="0.1" inputmode="decimal" placeholder="2" autocomplete="off" />
1541
+ <input id="askMaxBudgetUsd" class="input" type="number" min="0.1" max="100" step="0.1" inputmode="decimal" placeholder="No cap" autocomplete="off" />
1522
1542
  <label class="check-row" for="askNoCap"><input id="askNoCap" type="checkbox" /> No cap</label>
1523
1543
  </div>
1524
1544
  <!-- Scripts workbench W20: the chat's save_script / test_script switch. Body
1525
1545
  rendered by chat-settings-view.mjs; saved with this card's Save button. -->
1526
1546
  <div class="field" id="ask-script-tools-host"></div>
1547
+ <!-- Ask web access (off by default): rendered by chat-settings-view.mjs; saved with this
1548
+ card's Save button, and only when one of its fields was touched. -->
1549
+ <div class="field" id="ask-web-host"></div>
1527
1550
  <div class="add-project-actions" style="margin-top:14px">
1528
1551
  <button type="button" id="askLimitsReset" class="btn btn-ghost btn-mini">Use defaults</button>
1529
1552
  <button type="button" id="askLimitsSave" class="btn btn-primary btn-mini">Save</button>
@@ -2235,7 +2258,8 @@
2235
2258
  </div>
2236
2259
 
2237
2260
  <!-- Connect Claude Code: the one checklist step with no control to ring. The
2238
- tick is derived (the CLI resolves on PATH); "Check again" refetches it. -->
2261
+ tick is derived (the CLI resolves on PATH and is not signed out); "Check
2262
+ again" refetches it with a fresh sign-in check. -->
2239
2263
  <div id="claude-setup-modal" class="viewer-modal hidden" role="dialog" aria-modal="true" aria-labelledby="claude-setup-title">
2240
2264
  <div class="card ob-claude">
2241
2265
  <div class="card-head">
@@ -2245,6 +2269,9 @@
2245
2269
  <p class="ob-claude-lead">Worca drives the <strong>Claude Code</strong> CLI. Real runs need it on your <code>PATH</code>; mock runs need nothing.</p>
2246
2270
  <pre class="ob-claude-cmd"><code>npm install -g @anthropic-ai/claude-code</code></pre>
2247
2271
  <p class="ob-claude-note">On native Windows the <a href="https://docs.anthropic.com/en/docs/claude-code/setup" target="_blank" rel="noopener noreferrer">native installer</a> works too. A binary elsewhere: set <code>WORCA_CLAUDE_BIN</code> to its full path and restart the UI.</p>
2272
+ <p class="ob-claude-lead ob-claude-step"><strong>Then sign it in.</strong> Start it once in a terminal and type <code>/login</code>:</p>
2273
+ <pre class="ob-claude-cmd"><code>claude</code></pre>
2274
+ <p class="ob-claude-note">Signing in to the Claude desktop app does not carry over — the CLI keeps its own sign-in.</p>
2248
2275
  <div class="ob-claude-status" id="claude-setup-status" aria-live="polite"></div>
2249
2276
  <div class="stop-actions">
2250
2277
  <button type="button" id="claude-setup-check" class="btn btn-primary btn-mini">Check again</button>
@@ -8,6 +8,7 @@
8
8
  // delegated listener on the list container.
9
9
 
10
10
  import { bridgedBadge, needsSignInPill, degradationLine, renderConnectionSection, collectConnection, applyConnectionMode } from './bridge-view.mjs';
11
+ import { credentialBadge } from './credential-badges.mjs';
11
12
 
12
13
  function h(doc, tag, cls, text) {
13
14
  const n = doc.createElement(tag);
@@ -17,6 +18,15 @@ function h(doc, tag, cls, text) {
17
18
  }
18
19
 
19
20
  /** "medium · high" or "all efforts" (the full set carries no signal). */
21
+ /** Credential broker: "your key / no key" for the signed-in person, after a model's name. */
22
+ function keyBadge(parent, m, doc) {
23
+ const b = credentialBadge(m.id);
24
+ if (!b) return;
25
+ const el = h(doc, 'span', `badge ${b.tone} mv-key-badge`, b.text);
26
+ el.title = b.title;
27
+ parent.appendChild(el);
28
+ }
29
+
20
30
  export function effortsSummary(efforts, allEfforts) {
21
31
  const list = Array.isArray(efforts) ? efforts : [];
22
32
  if (!list.length || (allEfforts && list.length === allEfforts.length)) return 'all efforts';
@@ -112,7 +122,10 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
112
122
  const btn = h(doc, 'button', 'mv-sec-toggle');
113
123
  btn.type = 'button';
114
124
  btn.dataset.section = key;
115
- btn.appendChild(h(doc, 'span', 'mv-sec-caret', '▾'));
125
+ // The shared disclosure chevron (style.css .adv-chev), turned down while the group is open.
126
+ const caret = h(doc, 'span', 'mv-sec-caret adv-chev');
127
+ caret.setAttribute('aria-hidden', 'true');
128
+ btn.appendChild(caret);
116
129
  btn.appendChild(h(doc, 'h3', 'mv-section-title', title));
117
130
  btn.appendChild(h(doc, 'span', 'mv-sec-count', ''));
118
131
  head.appendChild(btn);
@@ -185,6 +198,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
185
198
  const body = h(doc, 'div', 'mv-body');
186
199
  const head = h(doc, 'div', 'mv-head');
187
200
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
201
+ keyBadge(head, m, doc);
188
202
  if (predefLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides built-in'));
189
203
  else if (pluginLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides plugin'));
190
204
  const rb = routedBadge(m);
@@ -238,6 +252,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
238
252
  const body = h(doc, 'div', 'mv-body');
239
253
  const head = h(doc, 'div', 'mv-head');
240
254
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
255
+ keyBadge(head, m, doc);
241
256
  head.appendChild(h(doc, 'span', 'badge waiting mv-origin', 'project (legacy)'));
242
257
  body.appendChild(head);
243
258
  body.appendChild(h(doc, 'small', 'mv-summary hint', m.id));
@@ -262,6 +277,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
262
277
  const body = h(doc, 'div', 'mv-body');
263
278
  const head = h(doc, 'div', 'mv-head');
264
279
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
280
+ keyBadge(head, m, doc);
265
281
  head.appendChild(h(doc, 'span', 'badge waiting mv-origin', `plugin: ${m.plugin}`));
266
282
  if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
267
283
  const prb = routedBadge(m);
@@ -311,6 +327,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
311
327
  const body = h(doc, 'div', 'mv-body');
312
328
  const head = h(doc, 'div', 'mv-head');
313
329
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
330
+ keyBadge(head, m, doc);
314
331
  const badge = h(doc, 'span', 'badge blue mv-origin', 'policy');
315
332
  badge.title = `Team policy on ${m.home}`;
316
333
  head.appendChild(badge);
@@ -339,6 +356,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
339
356
  const row = h(doc, 'div', 'mv-builtin');
340
357
  row.dataset.id = m.id;
341
358
  row.appendChild(h(doc, 'b', 'mv-name', m.label));
359
+ keyBadge(row, m, doc);
342
360
  if (globalLc.has(m.id.toLowerCase()) || pluginLc.has(m.id.toLowerCase())) {
343
361
  row.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden'));
344
362
  }
@@ -0,0 +1,118 @@
1
+ // ui/public/openrouter-free-view.mjs
2
+ // OpenRouter's daily allowance of `:free` requests (src/core/openrouter-free.mjs), where it
3
+ // comes up: the sidebar line under the spend block, a run's cost pill, the new-run form's
4
+ // warning and the Providers card. Pure helpers plus one renderer; app.js fetches
5
+ // /api/openrouter/free-daily and mounts the result.
6
+
7
+ /** Below this share of the day's allowance the line turns amber. */
8
+ export const FREE_LOW_AT = 0.1;
9
+ /** A run's typical free requests when this install has no history yet (a small pipeline run). */
10
+ export const FREE_RUN_DEFAULT = 90;
11
+
12
+ /** "3h 12m" / "12m" / "under a minute". Pure. */
13
+ export function untilText(ms) {
14
+ const min = Math.floor(Math.max(0, ms) / 60_000);
15
+ if (min < 1) return 'under a minute';
16
+ const h = Math.floor(min / 60);
17
+ return h ? `${h}h ${min % 60}m` : `${min}m`;
18
+ }
19
+
20
+ /** "00:00 UTC". Pure. */
21
+ export function utcClock(iso) {
22
+ const t = Date.parse(iso || '');
23
+ return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 16)} UTC` : '';
24
+ }
25
+
26
+ /** 'ok' | 'low' | 'out' for a known status. Pure. */
27
+ export function freeLevel(s) {
28
+ if (!s || !s.known || !(s.limit > 0)) return 'ok';
29
+ if (s.remaining <= 0) return 'out';
30
+ return s.remaining / s.limit < FREE_LOW_AT ? 'low' : 'ok';
31
+ }
32
+
33
+ /** The requests a run spent on OpenRouter `:free` models (its steps' bridgeFreeCalls). Pure. */
34
+ export function runFreeRequests(steps) {
35
+ return (Array.isArray(steps) ? steps : []).reduce((n, s) => n + (Number.isFinite(s?.bridgeFreeCalls) ? s.bridgeFreeCalls : 0), 0);
36
+ }
37
+
38
+ /** " · 87 free requests" for a cost pill, '' when the run used none. Pure. */
39
+ export function freeRequestsSuffix(steps) {
40
+ const n = runFreeRequests(steps);
41
+ return n > 0 ? ` · ${n} free request${n === 1 ? '' : 's'}` : '';
42
+ }
43
+
44
+ /**
45
+ * What a run usually takes on this install: the median of the last `max` finished runs that
46
+ * used free requests, else FREE_RUN_DEFAULT. Pure.
47
+ */
48
+ export function typicalFreeRun(runs, { max = 10 } = {}) {
49
+ const done = new Set(['completed', 'failed', 'stopped']);
50
+ const counts = (Array.isArray(runs) ? runs : [])
51
+ .filter((r) => r && done.has(r.status))
52
+ .map((r) => runFreeRequests(r.steps))
53
+ .filter((n) => n > 0)
54
+ .slice(0, max)
55
+ .sort((a, b) => a - b);
56
+ if (!counts.length) return FREE_RUN_DEFAULT;
57
+ const mid = Math.floor(counts.length / 2);
58
+ return counts.length % 2 ? counts[mid] : Math.round((counts[mid - 1] + counts[mid]) / 2);
59
+ }
60
+
61
+ /**
62
+ * The new-run form's warning when a chosen model is `:free` and fewer requests are left than a
63
+ * run usually takes; null otherwise. Pure.
64
+ */
65
+ export function newRunFreeWarning(s, { typical = FREE_RUN_DEFAULT, usesFree = false, now = Date.now() } = {}) {
66
+ if (!usesFree || !s || !s.known || s.remaining >= typical) return null;
67
+ const reset = Date.parse(s.resetAt || '');
68
+ const when = Number.isFinite(reset) ? ` (resets ${utcClock(s.resetAt)}, in ${untilText(reset - now)})` : '';
69
+ if (s.remaining <= 0) return `No OpenRouter free requests left today${when}: a free model would pause at its first call. Pick a paid model, or start after the reset.`;
70
+ return `${s.remaining} OpenRouter free request${s.remaining === 1 ? '' : 's'} left today${when}; a run here usually needs ~${typical}.`;
71
+ }
72
+
73
+ /** The Providers card's line about the allowance, '' when unknown. Pure. */
74
+ export function providerFreeLine(s, { now = Date.now() } = {}) {
75
+ if (!s || !s.known) return '';
76
+ const reset = Date.parse(s.resetAt || '');
77
+ const when = Number.isFinite(reset) ? ` · resets ${utcClock(s.resetAt)} (in ${untilText(reset - now)})` : '';
78
+ return `Free-model requests today: ${s.remaining} of ${s.limit} left${when}.`;
79
+ }
80
+
81
+ /**
82
+ * The sidebar block: "OpenRouter free · 941 / 1000 today", a meter of what is left, amber
83
+ * below 10%, red at 0, the reset on hover. null when there is nothing to show.
84
+ */
85
+ export function renderFreeDaily(s, { doc = globalThis.document, now = Date.now() } = {}) {
86
+ if (!s || !s.enabled || !s.known) return null;
87
+ const level = freeLevel(s);
88
+ const btn = doc.createElement('button');
89
+ btn.type = 'button';
90
+ btn.className = `spend-ind free-ind${level === 'low' ? ' warn' : level === 'out' ? ' over' : ''}`;
91
+ const reset = Date.parse(s.resetAt || '');
92
+ btn.title = `OpenRouter free-model requests left today: ${s.remaining} of ${s.limit}` +
93
+ (Number.isFinite(reset) ? ` · resets ${utcClock(s.resetAt)}, in ${untilText(reset - now)}` : '') +
94
+ ' — every model call on a :free model is one request';
95
+ const row = doc.createElement('span');
96
+ row.className = 'spend-ind-row';
97
+ const label = doc.createElement('span');
98
+ label.className = 'spend-ind-label';
99
+ label.textContent = 'OpenRouter free today';
100
+ const amt = doc.createElement('span');
101
+ amt.className = 'spend-ind-amt mono';
102
+ amt.textContent = `${s.remaining} / ${s.limit}`;
103
+ row.append(label, amt);
104
+ const meter = doc.createElement('span');
105
+ meter.className = 'spend-ind-meter';
106
+ const fill = doc.createElement('span');
107
+ fill.className = 'spend-ind-meter-fill';
108
+ fill.style.width = `${s.limit > 0 ? Math.max(0, Math.min(100, (s.remaining / s.limit) * 100)) : 0}%`;
109
+ meter.appendChild(fill);
110
+ btn.append(row, meter);
111
+ if (level === 'out') {
112
+ const sub = doc.createElement('small');
113
+ sub.className = 'spend-ind-sub';
114
+ sub.textContent = 'used up · free models pause until the reset';
115
+ btn.appendChild(sub);
116
+ }
117
+ return btn;
118
+ }
@@ -678,6 +678,57 @@ export function renderRunsChart(spec, { doc = globalThis.document, fmt = DEFAULT
678
678
  }
679
679
 
680
680
  /** Full Statistics body: KPI row + the two chart cards (or empty notes). */
681
+ const SLOT_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', openrouter: 'OpenRouter', copilot: 'Copilot', local: 'Local' };
682
+ const compactCount = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
683
+
684
+ /**
685
+ * "By person" (credential broker, docs/credential-broker.md): model spend per person in the
686
+ * stats window, as the broker metered it — every call is charged to whoever caused it.
687
+ * @param {{people?:object[], error?:string}} byPerson
688
+ */
689
+ export function renderPeopleCard(byPerson, rangeLabel, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
690
+ const card = chartCard(doc, 'By person', rangeLabel);
691
+ card.classList.add('people-card');
692
+ if (byPerson?.error) {
693
+ card.appendChild(h(doc, 'p', 'hint err', `The credential broker didn't answer: ${byPerson.error}`));
694
+ return card;
695
+ }
696
+ const people = byPerson?.people || [];
697
+ if (!people.length) {
698
+ card.appendChild(h(doc, 'p', 'chart-empty hint', 'No model calls in this period.'));
699
+ return card;
700
+ }
701
+ const top = Math.max(...people.map((p) => p.usd), 0);
702
+ const total = people.reduce((a, p) => a + p.usd, 0);
703
+ const table = h(doc, 'table', 'people-table');
704
+ const head = h(doc, 'tr', null);
705
+ for (const t of ['Person', 'Spend', 'Requests', 'Tokens in / out', 'Providers']) head.appendChild(h(doc, 'th', null, t));
706
+ const thead = h(doc, 'thead', null); thead.appendChild(head); table.appendChild(thead);
707
+ const tbody = h(doc, 'tbody', null);
708
+ for (const p of people) {
709
+ const tr = h(doc, 'tr', null);
710
+ tr.appendChild(h(doc, 'td', 'people-name', p.person === 'local' ? 'No signed-in person' : p.person));
711
+ const spend = h(doc, 'td', 'people-spend');
712
+ const bar = h(doc, 'span', 'people-bar');
713
+ const fill = h(doc, 'span', 'people-bar-fill');
714
+ fill.style.width = `${top > 0 ? Math.max(2, Math.round((p.usd / top) * 100)) : 0}%`;
715
+ bar.appendChild(fill);
716
+ spend.appendChild(h(doc, 'span', 'people-usd', fmt.usd(p.usd)));
717
+ spend.appendChild(bar);
718
+ tr.appendChild(spend);
719
+ tr.appendChild(h(doc, 'td', 'num', compactCount(p.requests)));
720
+ tr.appendChild(h(doc, 'td', 'num', `${compactCount(p.inputTokens + (p.cacheReadTokens || 0))} / ${compactCount(p.outputTokens)}`));
721
+ tr.appendChild(h(doc, 'td', 'people-slots', [...new Set(p.slots.map((s) => `${SLOT_NAMES[s.slot] || s.slot}${s.plan === 'subscription' ? ' (subscription)' : ''}`))].join(', ')));
722
+ tbody.appendChild(tr);
723
+ }
724
+ table.appendChild(tbody);
725
+ const scroll = h(doc, 'div', 'people-scroll');
726
+ scroll.appendChild(table);
727
+ card.appendChild(scroll);
728
+ card.appendChild(h(doc, 'small', 'hint', `${fmt.usd(total)} in total, metered by the credential broker. Each call is charged to the person whose action caused it.`));
729
+ return card;
730
+ }
731
+
681
732
  export function renderStatsBody(model, opts = {}) {
682
733
  const { doc = globalThis.document } = opts;
683
734
  const wrap = h(doc, 'div', null);
@@ -706,5 +757,10 @@ export function renderStatsBody(model, opts = {}) {
706
757
  currentBucketStartMs, rangeLabel }, opts));
707
758
  }
708
759
  wrap.appendChild(grid);
760
+ if (model.byPerson) {
761
+ const people = h(doc, 'div', 'charts-grid people-grid');
762
+ people.appendChild(renderPeopleCard(model.byPerson, rangeLabel, opts));
763
+ wrap.appendChild(people);
764
+ }
709
765
  return wrap;
710
766
  }