@worca/app 1.4.0-rc.1 → 1.5.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 (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -2404,6 +2404,61 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2404
2404
  return { el: rootEl };
2405
2405
  }
2406
2406
 
2407
+ /** The clone card (propose_clone_project): proposed → cloning → applied | failed, or declined. Every value is text. */
2408
+ function buildCloneCard(block) {
2409
+ const card = block.card || {};
2410
+ const summary = card.summary || 'clone a repository';
2411
+ if (block.state === 'declined') return { el: make('div', 'ask-card-stub', `Declined — ${summary}`) };
2412
+ const rootEl = make('div', `ask-card ask-mcard ask-clonecard is-${block.state}`);
2413
+ rootEl.setAttribute('data-ask-clonecard', block.state);
2414
+ const head = make('div', 'ask-mcard-head');
2415
+ const title = block.state === 'applied' ? 'Project cloned' : block.state === 'failed' ? 'Clone failed'
2416
+ : block.state === 'cloning' ? 'Cloning…' : 'Proposed project';
2417
+ head.appendChild(make('span', 'ask-mcard-title', title));
2418
+ head.appendChild(make('span', 'ask-mcard-kind', 'Clone'));
2419
+ rootEl.appendChild(head);
2420
+ const body = make('div', 'ask-mcard-body');
2421
+ const sum = make('div', 'ask-mcard-summary');
2422
+ if (block.state === 'applied') sum.appendChild(svgIcon(WF_ICO.check, 15, 2.4));
2423
+ sum.appendChild(make('span', null, summary));
2424
+ body.appendChild(sum);
2425
+ if (card.note) body.appendChild(make('div', 'ask-mcard-note', card.note));
2426
+ const rows = [['Repository', card.url], ['Branch', card.branch || 'default branch'], ['Folder', card.dir], ['GitHub', card.github]];
2427
+ const ul = make('ul', 'ask-mcard-changes');
2428
+ for (const [label, value] of rows) {
2429
+ if (!value) continue;
2430
+ const li = make('li');
2431
+ li.appendChild(make('span', 'ask-mcard-change-label', label));
2432
+ const val = make('span', 'ask-mcard-change-val');
2433
+ val.appendChild(make('span', 'ask-mcard-after', String(value)));
2434
+ li.appendChild(val);
2435
+ ul.appendChild(li);
2436
+ }
2437
+ body.appendChild(ul);
2438
+ const result = card.result || null;
2439
+ if (block.state === 'failed') body.appendChild(make('div', 'ask-mcard-failed', `Could not clone: ${block.error || (result && result.error) || 'unknown error'}`));
2440
+ else if (block.state === 'applied' && result && result.project && result.project.path) {
2441
+ body.appendChild(make('div', 'ask-mcard-detail', `Registered as ${result.project.name || card.name} at ${result.project.path}`));
2442
+ }
2443
+ rootEl.appendChild(body);
2444
+ rootEl.appendChild(make('div', 'ask-card-err'));
2445
+ if (block.state === 'proposed') {
2446
+ const actions = make('div', 'ask-mcard-actions');
2447
+ const btn = (cls, text, attr, icon) => {
2448
+ const b = make('button', cls, text); b.type = 'button'; b.setAttribute(attr, '');
2449
+ if (icon) b.prepend(svgIcon(icon, 12, 2.2));
2450
+ return b;
2451
+ };
2452
+ const decline = btn('ask-card-not-now', 'Decline', 'data-ask-clone-decline');
2453
+ decline.addEventListener('click', () => postCard(block, rootEl, { state: 'declined' }, decline));
2454
+ const apply = btn('ask-card-start', 'Clone', 'data-ask-clone-apply', WF_ICO.save);
2455
+ apply.addEventListener('click', () => postCard(block, rootEl, { state: 'applied' }, apply));
2456
+ actions.append(make('span', 'ask-card-actions-spacer'), decline, apply);
2457
+ rootEl.appendChild(actions);
2458
+ }
2459
+ return { el: rootEl };
2460
+ }
2461
+
2407
2462
  /** The model · effort picker (mockup §C): the panel's popover chrome, anchored under the chip. Rows are menuitems (PD28). */
2408
2463
  function openChipPicker(chip, nodeId, card, wf, handle) {
2409
2464
  const node = card.nodes && card.nodes[nodeId];
@@ -3067,7 +3122,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3067
3122
  function isProgressBlock(block) {
3068
3123
  const card = block.card || {};
3069
3124
  if (card.type === PROGRESS_CARD_TYPE) return true;
3070
- if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model') return false;
3125
+ if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone') return false;
3071
3126
  return block.state === 'started' || (block.state === 'failed' && !!block.runId);
3072
3127
  }
3073
3128
  function buildCard(block) {
@@ -3078,13 +3133,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3078
3133
  const isMetrics = !!(block.card && (block.card.type === 'metrics' || block.card.type === 'policy'));
3079
3134
  const isSchedule = !!(block.card && block.card.type === 'schedule');
3080
3135
  const isModel = !!(block.card && block.card.type === 'model');
3136
+ const isClone = !!(block.card && block.card.type === 'clone');
3081
3137
  const isProgress = isProgressBlock(block);
3082
- if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isProgress || block.state === 'proposed')) return cached.el;
3138
+ if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isClone || isProgress || block.state === 'proposed')) return cached.el;
3083
3139
  if (cached) disposeCardEntry(cached);
3084
3140
  const built = isWorkflow ? buildWorkflowCard(block, cached)
3085
3141
  : isMetrics ? buildMetricsCard(block)
3086
3142
  : isSchedule ? buildScheduleCard(block)
3087
3143
  : isModel ? buildModelCard(block)
3144
+ : isClone ? buildCloneCard(block)
3088
3145
  : isProgress ? buildProgressCard(block)
3089
3146
  : { el: block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block) };
3090
3147
  st.cardEls.set(block.id, { el: built.el, state: block.state, handle: built.handle || null, dispose: built.dispose || null, animate: !!built.animate, cancelAnim: null, lastW: -1 });
@@ -3704,7 +3761,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3704
3761
 
3705
3762
  function pushServerFrame(frame) {
3706
3763
  if (st.destroyed || !frame) return;
3707
- if (frame.type === 'ask-history-cleared') { onHistoryCleared(); return; }
3764
+ if (frame.type === 'ask-history-cleared') {
3765
+ // A shared deployment's clear names the threads it removed: only a tab showing one of them resets.
3766
+ if (Array.isArray(frame.threadIds) && !frame.threadIds.includes(st.threadId)) { scheduleThreadsRefresh(); return; }
3767
+ onHistoryCleared();
3768
+ return;
3769
+ }
3708
3770
  if (THREADS_REFRESH_FRAMES.has(frame.type)) scheduleThreadsRefresh();
3709
3771
  // Defence-in-depth: the model's own threadId filter is the real router — this early return only saves an apply() call and cannot be observed from tests (the model would drop the frame identically).
3710
3772
  if (!st.model || frame.threadId !== st.threadId) return;
@@ -15,9 +15,43 @@ function h(doc, tag, cls, text) {
15
15
  }
16
16
 
17
17
  /** Mirrors model-env.mjs PROVIDER_APIS — the server validates; this is the form. */
18
- export const PROVIDER_APIS = { copilot: ['anthropic', 'openai-chat'], openai: ['openai-chat'], anthropic: ['anthropic'] };
18
+ export const PROVIDER_APIS = { copilot: ['anthropic', 'openai-chat', 'openai-responses'], openai: ['openai-chat', 'openai-responses'], anthropic: ['anthropic'] };
19
+ /** Mirrors model-env.mjs TRANSLATED_APIS: the apis the bridge translates (no passthrough). */
20
+ export const TRANSLATED_APIS = ['openai-chat', 'openai-responses'];
19
21
  export const PROVIDER_LABELS = { copilot: 'GitHub Copilot', openai: 'OpenAI-compatible', anthropic: 'Anthropic-compatible' };
20
- export const API_LABELS = { anthropic: 'Anthropic Messages', 'openai-chat': 'OpenAI chat completions' };
22
+ export const API_LABELS = { anthropic: 'Anthropic Messages', 'openai-chat': 'OpenAI chat completions', 'openai-responses': 'OpenAI Responses' };
23
+ /** The import sheet's short API column. */
24
+ const API_SHORT = { anthropic: 'Messages', 'openai-chat': 'chat', 'openai-responses': 'Responses' };
25
+
26
+ /**
27
+ * The effort levels an imported model listed, stashed on the Connection block
28
+ * (spec §6.3) — only while the provider and the upstream model id are still the
29
+ * ones they came from — or null.
30
+ */
31
+ function storedReasoningEfforts(conn, provider) {
32
+ const raw = conn && conn.dataset ? conn.dataset.reasoningEfforts : '';
33
+ if (!raw || conn.dataset.reasoningEffortsProvider !== provider) return null;
34
+ const modelIn = conn.querySelector('.mv-conn-model');
35
+ if ((modelIn ? modelIn.value.trim() : '') !== (conn.dataset.reasoningEffortsModel || '')) return null;
36
+ try {
37
+ const list = JSON.parse(raw);
38
+ return Array.isArray(list) && list.length ? list : null;
39
+ } catch { return null; }
40
+ }
41
+
42
+ /** Stash (or clear) an upstream's effort levels on the Connection block: no form field edits them. */
43
+ function stashReasoningEfforts(conn, upstream) {
44
+ const list = upstream && upstream.capabilities && upstream.capabilities.reasoningEfforts;
45
+ if (Array.isArray(list) && list.length) {
46
+ conn.dataset.reasoningEfforts = JSON.stringify(list);
47
+ conn.dataset.reasoningEffortsProvider = upstream.provider;
48
+ conn.dataset.reasoningEffortsModel = String(upstream.model || '').trim();
49
+ } else {
50
+ delete conn.dataset.reasoningEfforts;
51
+ delete conn.dataset.reasoningEffortsProvider;
52
+ delete conn.dataset.reasoningEffortsModel;
53
+ }
54
+ }
21
55
  export const CAPABILITY_FLAGS = [['toolCalls', 'Tool calls'], ['vision', 'Vision'], ['reasoning', 'Reasoning']];
22
56
 
23
57
  /** The notice every Copilot sign-in must acknowledge (§8.2). One source of truth: the UI modal and the CLI print it. */
@@ -33,12 +67,15 @@ export const COPILOT_TERMS = Object.freeze({
33
67
  confirm: 'Continue',
34
68
  });
35
69
 
36
- /** One-line degradation copy for a translated (openai-chat) model, '' otherwise (§8.5). */
70
+ /** One-line degradation copy for a translated (openai-chat / openai-responses) model, '' otherwise (§8.5). */
37
71
  export function degradationLine(m) {
38
- if (!m || !m.upstream || m.upstream.api !== 'openai-chat') return '';
72
+ if (!m || !m.upstream || !TRANSLATED_APIS.includes(m.upstream.api)) return '';
39
73
  const caps = m.upstream.capabilities || {};
40
74
  const limit = caps.maxPromptTokens ? `, prompt limit ~${Math.round(caps.maxPromptTokens / 1000)}k tokens` : '';
41
- return `translated — no thinking blocks, no WebSearch/WebFetch${limit}`;
75
+ const lead = m.upstream.api === 'openai-responses'
76
+ ? 'translated — reasoning summaries only, no WebSearch/WebFetch'
77
+ : 'translated — no thinking blocks, no WebSearch/WebFetch';
78
+ return `${lead}${limit}`;
42
79
  }
43
80
 
44
81
  /** The `bridged: <provider>` badge for a catalog card, or null. */
@@ -195,7 +232,7 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
195
232
  : k.keyOptional ? h(doc, 'span', 'badge green', 'local — no key needed') : h(doc, 'span', 'badge grey', 'no key'));
196
233
  main.appendChild(rh);
197
234
  main.appendChild(h(doc, 'small', 'hint', name === 'openai'
198
- ? 'OpenAI, Azure, Ollama, vLLM, Groq, an in-house gateway — anything with a /chat/completions endpoint. Models run through the translation layer (no thinking blocks, no web tools).'
235
+ ? 'OpenAI, Azure, Ollama, vLLM, Groq, an in-house gateway — anything with a /chat/completions or /responses endpoint. Models run through the translation layer (no web tools).'
199
236
  : 'A gateway that already speaks the Anthropic Messages API but needs a key Worca holds for it. Calls pass through untouched.'));
200
237
  if (k.keySource === 'env') main.appendChild(h(doc, 'small', 'hint', `Key read from your shell env (${k.keyRef}).`));
201
238
  main.appendChild(h(doc, 'small', 'hint mv-pv-msg'));
@@ -294,7 +331,7 @@ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
294
331
  const root = h(doc, 'section', 'card mv-editor mvi');
295
332
  root.appendChild(h(doc, 'h3', 'mv-editor-title', 'Import from GitHub Copilot'));
296
333
  root.appendChild(h(doc, 'small', 'hint',
297
- 'These run through your Copilot subscription. Claude models keep extended thinking; other vendors run through a translation layer (no thinking blocks, no web tools). Imported entries are priced free — Copilot bills premium requests, not tokens — and Worca counts requests per run.'));
334
+ 'These run through your Copilot subscription. Claude models keep extended thinking; other vendors run through a translation layer (no web tools; reasoning arrives as summaries on the Responses API). Imported entries are priced free — Copilot bills premium requests, not tokens — and Worca counts requests per run.'));
298
335
  const list = Array.isArray(models) ? models : [];
299
336
  if (!list.length) {
300
337
  root.appendChild(h(doc, 'div', 'hist-empty', 'Copilot returned no chat models for this account.'));
@@ -306,7 +343,7 @@ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
306
343
  const all = h(doc, 'input', 'mvi-all'); all.type = 'checkbox'; all.title = 'Select all importable';
307
344
  allTh.appendChild(all);
308
345
  hr.appendChild(allTh);
309
- for (const t of ['Model', 'Vendor', 'Context', 'Tools', 'Vision', 'Reasoning', 'Status']) hr.appendChild(h(doc, 'th', null, t));
346
+ for (const t of ['Model', 'Vendor', 'Context', 'Tools', 'Vision', 'Reasoning', 'API', 'Status']) hr.appendChild(h(doc, 'th', null, t));
310
347
  thead.appendChild(hr);
311
348
  tbl.appendChild(thead);
312
349
  const tbody = h(doc, 'tbody');
@@ -328,6 +365,7 @@ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
328
365
  tr.appendChild(h(doc, 'td', null, yesNo(m.toolCalls)));
329
366
  tr.appendChild(h(doc, 'td', null, yesNo(m.vision)));
330
367
  tr.appendChild(h(doc, 'td', null, yesNo(m.reasoning)));
368
+ tr.appendChild(h(doc, 'td', 'mvi-api', API_SHORT[m.api] || '—'));
331
369
  const status = [];
332
370
  if (m.inCatalog) status.push('in catalog ✓');
333
371
  if (m.preview) status.push('preview');
@@ -450,6 +488,7 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
450
488
  const groupName = `mv-conn-mode-${editing ? model.id : 'new'}`;
451
489
  const wrap = h(doc, 'div', 'mv-conn');
452
490
  wrap.dataset.provider = upstream ? upstream.provider : '';
491
+ stashReasoningEfforts(wrap, upstream);
453
492
 
454
493
  const modes = h(doc, 'div', 'mv-conn-modes');
455
494
  const startMode = upstream ? 'provider' : (editing && model.env && Object.keys(model.env).length ? 'env' : 'direct');
@@ -611,28 +650,43 @@ export function applyConnectionMode(connEl) {
611
650
  }
612
651
  const note = conn.querySelector('.mv-conn-note');
613
652
  if (note) {
653
+ const copilotPricing = provider === 'copilot' ? ' Copilot bills premium requests, so Pricing defaults to Free.' : '';
614
654
  note.textContent = mode !== 'provider' ? '' : api === 'openai-chat'
615
- ? `Translated: no thinking blocks, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${provider === 'copilot' ? ' Copilot bills premium requests, so Pricing defaults to Free.' : ''}`
616
- : provider === 'copilot' ? 'Copilot’s native Anthropic endpoint: thinking blocks and cache accounting arrive intact. Copilot bills premium requests, so Pricing defaults to Free.'
617
- : 'Passthrough: the request reaches the endpoint untouched; only auth and headers are added.';
655
+ ? `Translated: no thinking blocks, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
656
+ : api === 'openai-responses'
657
+ ? `Translated to the Responses API: reasoning summaries arrive as thinking, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
658
+ : provider === 'copilot' ? 'Copilot’s native Anthropic endpoint: thinking blocks and cache accounting arrive intact. Copilot bills premium requests, so Pricing defaults to Free.'
659
+ : 'Passthrough: the request reaches the endpoint untouched; only auth and headers are added.';
618
660
  }
619
661
  const reasoning = !!conn.querySelector('.mv-conn-cap-cb[data-cap="reasoning"]')?.checked;
620
662
  // Efforts live in the editor grid beside this block.
621
663
  const editor = conn.closest ? conn.closest('.mv-editor') : null;
622
664
  const effCbs = editor ? [...editor.querySelectorAll('.mv-effort-cb')] : [];
623
665
  const effHint = editor ? editor.querySelector('.mv-efforts-hint') : null;
624
- const translatedNoReasoning = mode === 'provider' && api === 'openai-chat' && !reasoning;
666
+ const translated = mode === 'provider' && TRANSLATED_APIS.includes(api);
667
+ const translatedNoReasoning = translated && !reasoning;
668
+ // The model's own effort levels (carried from Import): offer only the Worca efforts it lists.
669
+ const levels = translated && reasoning ? storedReasoningEfforts(conn, provider) : null;
670
+ const offered = levels ? effCbs.map((cb) => cb.value).filter((v) => levels.includes(v)) : null;
671
+ const allowed = offered && !offered.length ? ['medium'] : offered;
625
672
  for (const cb of effCbs) {
626
673
  if (translatedNoReasoning) {
627
674
  cb.disabled = cb.value !== 'medium';
628
675
  if (cb.value !== 'medium') cb.checked = false; else cb.checked = true;
676
+ } else if (allowed) {
677
+ cb.disabled = !allowed.includes(cb.value);
678
+ if (cb.disabled) cb.checked = false;
629
679
  } else cb.disabled = false;
630
680
  }
681
+ // Every stored effort fell outside the model's levels: offer its levels rather than
682
+ // save an empty list (the server reads empty as every effort, the disabled ones too).
683
+ if (allowed && !effCbs.some((cb) => cb.checked)) for (const cb of effCbs) if (allowed.includes(cb.value)) cb.checked = true;
631
684
  if (effHint) {
632
685
  effHint.textContent = translatedNoReasoning
633
686
  ? 'This model has no reasoning control; effort would be ignored — only medium is offered.'
634
- : mode === 'provider' && api === 'openai-chat' ? 'Maps to reasoning_effort low / medium / high — xhigh and max are not distinct.'
635
- : 'All checked = every effort (the default).';
687
+ : levels ? `Maps to this model's effort levels: ${levels.join(', ')}.`
688
+ : translated ? 'Maps to reasoning_effort low / medium / high — xhigh and max are not distinct.'
689
+ : 'All checked = every effort (the default).';
636
690
  }
637
691
  }
638
692
 
@@ -640,6 +694,7 @@ export function applyConnectionMode(connEl) {
640
694
  export function setModelUpstream(connEl, upstream) {
641
695
  const conn = connEl.classList && connEl.classList.contains('mv-conn') ? connEl : connEl.querySelector('.mv-conn');
642
696
  if (!conn) return;
697
+ stashReasoningEfforts(conn, upstream);
643
698
  const mode = upstream ? 'provider' : 'direct';
644
699
  for (const rb of conn.querySelectorAll('.mv-conn-mode-rb')) rb.checked = rb.value === mode;
645
700
  if (upstream) {
@@ -689,6 +744,9 @@ export function collectConnection(connEl) {
689
744
  const v = (inp.value || '').trim();
690
745
  if (v !== '') capabilities[inp.dataset.limit] = Number(v);
691
746
  }
747
+ // The model's effort levels ride along unedited while it stays a translated reasoning model on the same provider.
748
+ const levels = storedReasoningEfforts(conn, provider);
749
+ if (levels && capabilities.reasoning && TRANSLATED_APIS.includes(api)) capabilities.reasoningEfforts = levels;
692
750
  upstream.capabilities = capabilities;
693
751
  return { upstream };
694
752
  }
@@ -110,6 +110,9 @@
110
110
  </nav>
111
111
 
112
112
  <div class="side-foot">
113
+ <!-- Who this browser is signed in as (GET /api/whoami). Filled only behind an
114
+ identity proxy or an operator-set name; a local install never shows it. -->
115
+ <div class="side-who" id="side-who" hidden><span class="side-who-label">Signed in as</span> <span class="side-who-name"></span></div>
113
116
  <div id="side-spend"></div>
114
117
  <button type="button" class="side-toggle" id="side-toggle"
115
118
  aria-expanded="true" aria-controls="side-rail"
@@ -509,6 +512,7 @@
509
512
  <div class="rc-meta">
510
513
  <span class="rc-status-word"></span>
511
514
  <span class="rc-seg"><span class="rc-dot">·</span><span class="rm-text"></span></span>
515
+ <span class="rc-seg rc-by" hidden><span class="rc-dot">·</span><span class="person-ini" aria-hidden="true"></span><span class="rc-by-text"></span></span>
512
516
  <span class="rc-seg rc-prog" hidden data-min-level="advanced"><span class="rc-dot">·</span><span class="rc-prog-text"></span></span>
513
517
  <span class="rc-seg"><span class="rc-dot">·</span><span class="run-time mono"></span></span>
514
518
  <span class="rc-seg"><span class="rc-dot">·</span><span class="run-cost mono"></span></span>
@@ -665,6 +669,7 @@
665
669
  <span class="hm-seg hist-clock-seg"><span class="hm-dot">·</span><span class="hist-clock"></span></span>
666
670
  <span class="hm-seg hist-time-seg"><span class="hm-dot">·</span><span class="hist-time mono"></span></span>
667
671
  <span class="hm-seg hist-total-seg"><span class="hm-dot">·</span><span class="hist-total mono"></span></span>
672
+ <span class="hm-seg hist-by-seg"><span class="hm-dot">·</span><span class="hist-by"></span></span>
668
673
  <span class="hist-diff-pill" hidden data-min-level="advanced">
669
674
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 3h8l4 4v14H6z" stroke-linejoin="round"></path><path d="M14 3v4h4" stroke-linejoin="round"></path></svg>
670
675
  <span class="hist-diff mono" aria-label="lines changed"></span>
@@ -857,6 +862,10 @@
857
862
  </div>
858
863
  <div class="tm-filter">
859
864
  <div class="select-wrap tm-scope-wrap"><select id="tm-scope" class="select" aria-label="Scope"></select></div>
865
+ <div class="seg tm-tabs" id="tm-tabs" role="tablist" aria-label="Team metrics view">
866
+ <button type="button" role="tab" data-tm-tab="overview" aria-selected="true" class="on">Overview</button>
867
+ <button type="button" role="tab" data-tm-tab="timeline" aria-selected="false">Timeline</button>
868
+ </div>
860
869
  <div class="seg" id="tm-range" role="group" aria-label="Time range">
861
870
  <button type="button" data-range="this-month">This month</button>
862
871
  <button type="button" data-range="last-month">Last month</button>
@@ -871,6 +880,7 @@
871
880
  </div>
872
881
  <div class="tm-active-filters" id="tm-filters" hidden></div>
873
882
  <div id="tm-body"><small class="hint">Loading…</small></div>
883
+ <div id="tm-timeline" hidden></div>
874
884
  <div id="tm-tip" class="chart-tip" hidden></div>
875
885
  </section>
876
886
  <!-- /view team-metrics -->
@@ -2269,18 +2279,41 @@
2269
2279
  <h2 id="proj-add-title">Add project</h2>
2270
2280
  <button type="button" id="proj-add-cancel" class="btn btn-mini">Close</button>
2271
2281
  </div>
2272
- <div class="field">
2273
- <label for="proj-add-name">Name</label>
2274
- <input id="proj-add-name" class="input" type="text" spellcheck="false" placeholder="Project name" />
2282
+ <!-- Two ways in: a folder already on this machine, or a repository cloned into the
2283
+ projects folder by the server (POST /api/projects/clone). Clone is the default
2284
+ where no folder picker exists (a container or hosted worca). -->
2285
+ <div class="md-tabs proj-add-tabs" id="proj-add-tabs" role="tablist" aria-label="How to add the project">
2286
+ <button type="button" class="md-tab" role="tab" id="proj-add-tab-folder" data-mode="folder" aria-selected="true" aria-controls="proj-add-folder-pane">Folder</button>
2287
+ <button type="button" class="md-tab" role="tab" id="proj-add-tab-clone" data-mode="clone" aria-selected="false" aria-controls="proj-add-clone-pane">Clone from URL</button>
2275
2288
  </div>
2276
- <div class="field">
2277
- <label for="proj-add-path">Folder</label>
2278
- <div class="path-row">
2279
- <input id="proj-add-path" class="input" type="text" spellcheck="false" placeholder="/absolute/path/to/project" />
2280
- <button type="button" id="proj-add-browse" class="btn btn-ghost btn-mini" title="Pick the project folder">Choose folder&hellip;</button>
2289
+ <div id="proj-add-folder-pane" role="tabpanel" aria-labelledby="proj-add-tab-folder">
2290
+ <div class="field">
2291
+ <label for="proj-add-name">Name</label>
2292
+ <input id="proj-add-name" class="input" type="text" spellcheck="false" placeholder="Project name" />
2293
+ </div>
2294
+ <div class="field">
2295
+ <label for="proj-add-path">Folder</label>
2296
+ <div class="path-row">
2297
+ <input id="proj-add-path" class="input" type="text" spellcheck="false" placeholder="/absolute/path/to/project" />
2298
+ <button type="button" id="proj-add-browse" class="btn btn-ghost btn-mini" title="Pick the project folder">Choose folder&hellip;</button>
2299
+ </div>
2300
+ </div>
2301
+ </div>
2302
+ <div id="proj-add-clone-pane" role="tabpanel" aria-labelledby="proj-add-tab-clone" hidden>
2303
+ <div class="field">
2304
+ <label for="proj-clone-url">Repository URL</label>
2305
+ <input id="proj-clone-url" class="input" type="url" spellcheck="false" autocomplete="off" placeholder="https://github.com/owner/repo" />
2306
+ </div>
2307
+ <div class="field">
2308
+ <label for="proj-clone-branch">Branch <span class="opt">(optional)</span></label>
2309
+ <input id="proj-clone-branch" class="input" type="text" spellcheck="false" autocomplete="off" placeholder="the repository's default branch" />
2310
+ </div>
2311
+ <div class="field">
2312
+ <label for="proj-clone-name">Folder name <span class="opt">(optional)</span></label>
2313
+ <input id="proj-clone-name" class="input" type="text" spellcheck="false" autocomplete="off" placeholder="the repository name" />
2281
2314
  </div>
2282
2315
  </div>
2283
- <small class="hint" id="proj-add-msg"></small>
2316
+ <small class="hint" id="proj-add-msg" aria-live="polite"></small>
2284
2317
  <div class="confirm-actions">
2285
2318
  <button type="button" id="proj-add-save" class="btn btn-primary btn-mini">Add project</button>
2286
2319
  </div>
@@ -100,6 +100,19 @@ export function createSchedulesView({ tabsHost = null, feedHost, onceHost, repea
100
100
  }
101
101
 
102
102
  // ── rows ───────────────────────────────────────────────────────────────────
103
+ // Who made / last changed it (step 2's display rule lives in app.js: deps.personLabel/personShown
104
+ // return '' unless the deployment has per-person sign-ins; 'you' for the viewer).
105
+ const label = (v) => (typeof deps.personLabel === 'function' ? deps.personLabel(v) : '');
106
+ const shown = (v) => (typeof deps.personShown === 'function' ? deps.personShown(v) : '');
107
+ /** The card's meta segment: "· (AL) by ada@…" / "by you" for the creator; null when nobody is shown. */
108
+ function bySeg(item) {
109
+ const who = label(item.createdBy);
110
+ if (!who) return null;
111
+ const full = shown(item.createdBy);
112
+ const ini = typeof deps.personIni === 'function' ? deps.personIni(full, `Created by ${full}`) : null;
113
+ return h('span', { class: 'rc-seg sched-by', title: `Created by ${full}` }, h('span', { class: 'rc-dot', text: '·' }), ini, h('span', { class: 'sched-by-text', text: `by ${who}` }));
114
+ }
115
+
103
116
  function metaLine(item) {
104
117
  const bits = [deps.targetLabel(item), deps.workflowLabel(item.summary.workflowId)];
105
118
  if (item.summary.sourceBranch) bits.push(`from ${item.summary.sourceBranch}`);
@@ -114,6 +127,8 @@ export function createSchedulesView({ tabsHost = null, feedHost, onceHost, repea
114
127
  const src = item.summary.source && item.summary.source.type === 'plugin' ? item.summary.source : null;
115
128
  body.append(...[
116
129
  row('Task', src ? `${src.plugin} · ${src.taskId} — fetched when the run starts` : (item.summary.prompt || '—')),
130
+ row('Created by', shown(item.createdBy)),
131
+ shown(item.updatedBy) && shown(item.updatedBy) !== shown(item.createdBy) ? row('Changed by', shown(item.updatedBy)) : null,
117
132
  item.after ? row('After', `${(item.after.title || item.after.id.slice(0, 8))} · ${afterWord(item.after)}`) : null,
118
133
  item.after ? row('If it fails', item.after.policy === 'any' ? 'Start anyway' : 'Do not start') : null,
119
134
  item.sourceFromPrevious ? row('Source branch', 'the run before it') : null,
@@ -206,7 +221,8 @@ export function createSchedulesView({ tabsHost = null, feedHost, onceHost, repea
206
221
  h('div', { class: 'rc-title', text: t.title || 'Scheduled run' }),
207
222
  h('div', { class: 'rc-meta' },
208
223
  h('span', { class: `rc-status-word st-${family}`, text: statusWord }),
209
- h('span', { class: 'rc-seg' }, h('span', { class: 'rc-dot', text: '·' }), h('span', { class: 'sched-when', 'data-at': missed || chained ? '' : t.runAt, text: timeText }))),
224
+ h('span', { class: 'rc-seg' }, h('span', { class: 'rc-dot', text: '·' }), h('span', { class: 'sched-when', 'data-at': missed || chained ? '' : t.runAt, text: timeText })),
225
+ bySeg(t)),
210
226
  h('div', { class: 'sched-target', text: `${t.scheduleId ? 'Repeating' : 'Once'} · ${metaLine(t)}` }),
211
227
  missed && t.failReason ? h('div', { class: 'sched-reason', text: t.failReason }) : null)),
212
228
  footer(t, acts, details), details);
@@ -257,7 +273,8 @@ export function createSchedulesView({ tabsHost = null, feedHost, onceHost, repea
257
273
  h('div', { class: 'rc-title', text: s.title || 'Repeating schedule' }),
258
274
  h('div', { class: 'rc-meta' },
259
275
  h('span', { class: `rc-status-word st-${family}`, text: statusWord }),
260
- h('span', { class: 'rc-seg' }, h('span', { class: 'rc-dot', text: '·' }), h('span', { class: 'sched-rule', text: s.sentence }))),
276
+ h('span', { class: 'rc-seg' }, h('span', { class: 'rc-dot', text: '·' }), h('span', { class: 'sched-rule', text: s.sentence })),
277
+ bySeg(s)),
261
278
  h('div', { class: 'sched-target', text: metaLine(s) }),
262
279
  (next || last) ? h('div', { class: 'sched-sub' }, next, last) : null),
263
280
  ended ? null : sw),
@@ -416,6 +433,8 @@ export function createSchedulesView({ tabsHost = null, feedHost, onceHost, repea
416
433
 
417
434
  return {
418
435
  load, loadFeed, tickCountdowns, showTab,
436
+ /** Repaint from the loaded model (the viewer arrived after the first paint). */
437
+ repaint() { if (model.loaded) paintList(); },
419
438
  get tab() { return model.tab; },
420
439
  get defaults() { return model.defaults; },
421
440
  get counts() { return model.counts; },
@@ -0,0 +1,77 @@
1
+ // Session expiry behind an identity proxy (docs/remote-access.md). When the
2
+ // Cloudflare Access session runs out, every same-origin request is redirected
3
+ // to the proxy's sign-in page on another origin: fetch() fails with a bare
4
+ // TypeError (CORS) and the WebSocket cannot reconnect, so the UI would just look
5
+ // broken. This module tells that case apart from "server down" and shows one
6
+ // banner with a reload button. Locally (no proxy) it never fires: a down
7
+ // server fails the probe too, and worca itself never redirects /api/health.
8
+
9
+ const PROBE_MIN_MS = 10_000;
10
+
11
+ /**
12
+ * Wraps `win.fetch` once. Returns `{ check }`: call it when something failed in
13
+ * a way that could be an expired session (e.g. the WebSocket closed).
14
+ */
15
+ export function installSessionGuard({ win = window, doc = document, reload = () => win.location.reload() } = {}) {
16
+ if (win.__worcaSessionGuard) return win.__worcaSessionGuard;
17
+ const origFetch = win.fetch.bind(win);
18
+ let lastProbe = 0;
19
+ let probing = null;
20
+ let shown = false;
21
+
22
+ const sameOrigin = (input) => {
23
+ try {
24
+ const url = new URL(typeof input === 'string' ? input : input?.url ?? String(input), win.location.href);
25
+ return url.origin === win.location.origin;
26
+ } catch { return false; }
27
+ };
28
+
29
+ function showBanner(reason) {
30
+ if (shown) return;
31
+ shown = true;
32
+ const bar = doc.createElement('div');
33
+ bar.className = 'session-expired';
34
+ bar.setAttribute('role', 'alert');
35
+ bar.dataset.reason = reason;
36
+ const text = doc.createElement('span');
37
+ text.className = 'se-text';
38
+ text.textContent = reason === 'unauthorized'
39
+ ? 'Worca could not confirm your sign-in.'
40
+ : 'Your sign-in has expired.';
41
+ const btn = doc.createElement('button');
42
+ btn.type = 'button';
43
+ btn.className = 'se-reload';
44
+ btn.textContent = 'Sign in again';
45
+ btn.addEventListener('click', () => reload());
46
+ bar.append(text, btn);
47
+ doc.body.prepend(bar);
48
+ }
49
+
50
+ // A manual-redirect probe of an unauthenticated route: an opaque redirect
51
+ // can only come from a proxy in front of worca asking for a new sign-in.
52
+ function check() {
53
+ if (shown || probing) return probing || Promise.resolve();
54
+ const now = Date.now();
55
+ if (now - lastProbe < PROBE_MIN_MS) return Promise.resolve();
56
+ lastProbe = now;
57
+ probing = origFetch('/api/health', { redirect: 'manual', cache: 'no-store', credentials: 'same-origin' })
58
+ .then((r) => { if (r.type === 'opaqueredirect' || r.status === 401) showBanner('expired'); })
59
+ .catch(() => { /* server unreachable: not a session problem */ })
60
+ .finally(() => { probing = null; });
61
+ return probing;
62
+ }
63
+
64
+ win.fetch = async (input, init) => {
65
+ try {
66
+ const res = await origFetch(input, init);
67
+ if (res.status === 401 && sameOrigin(input)) showBanner('unauthorized');
68
+ return res;
69
+ } catch (err) {
70
+ if (sameOrigin(input) && err?.name !== 'AbortError') check();
71
+ throw err;
72
+ }
73
+ };
74
+
75
+ win.__worcaSessionGuard = { check };
76
+ return win.__worcaSessionGuard;
77
+ }