@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
@@ -190,6 +190,7 @@ export function createThreadModel({ threadId }) {
190
190
  row.text = live.text;
191
191
  row.status = 'error';
192
192
  row.errorMessage = frame.message || 'unknown error';
193
+ row.errorCode = frame.code || null;
193
194
  live = null;
194
195
  inFlight = null;
195
196
  dirty.structure = true;
@@ -7,6 +7,7 @@
7
7
  import { openScheduleSheet, browserTimeZone } from './schedule-sheet.mjs';
8
8
  import { formatInstant, describeRule } from '../../src/shared/schedule/recurrence.mjs';
9
9
  import { createThreadModel } from './ask-model.mjs';
10
+ import { credentialBadge } from './credential-badges.mjs';
10
11
  import { createMarkdownRenderer } from './ask-markdown.mjs';
11
12
  import { createThinkingOrb } from './thinking-orb.mjs';
12
13
  import { workflowPickerLabel } from './results-view.mjs';
@@ -164,21 +165,22 @@ const PILL_MORPH_IN_MS = 520;
164
165
  const PILL_MORPH_OUT_MS = 800;
165
166
  const PILL_SETTLE_FALLBACK_MS = PILL_MORPH_OUT_MS + 150;
166
167
 
167
- export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, openComposer = null, loadMarkdown, hljsLoader, storage, raf, now, runStore = null }) {
168
- const storedPick = readStoredModel(); // hoisted declaration (defined below); null when nothing is stored
168
+ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, openComposer = null, openClaudeSetup = null, loadMarkdown, hljsLoader, storage, raf, now, runStore = null }) {
169
+ const homePick = browserPick(); // hoisted declaration (defined below)
169
170
  const st = {
170
171
  open: false,
171
172
  threadId: null,
172
173
  model: null, // createThreadModel for the active thread (Task 4+)
173
- picker: {
174
- model: storedPick && storedPick.model ? storedPick.model : FALLBACK_PICK.model,
175
- effort: storedPick ? storedPick.effort : FALLBACK_PICK.effort,
176
- },
174
+ picker: homePick.picker,
177
175
  // D11 provenance, tracked per slot: only a MODEL the user actually picked
178
176
  // outranks the backend default. An effort-only record leaves the model slot
179
177
  // unclaimed, so a later change to ASK_LIMITS.defaultModel still reaches here.
180
- pickerFromStore: !!(storedPick && storedPick.model),
181
- effortFromStore: storedPick !== null,
178
+ pickerFromStore: homePick.pickerFromStore,
179
+ effortFromStore: homePick.effortFromStore,
180
+ // The picker shows the open thread's own last pick (its row's model/effort).
181
+ // It outranks the backend default like a stored pick, but it is the chat's,
182
+ // not the browser's: nothing sourced from a thread reaches worca-cc.ask.model.
183
+ pickerFromThread: false,
182
184
  catalog: null,
183
185
  // #397: the thread's project/workspace scope. pinned:false = Auto (follow the
184
186
  // page — today's behaviour). label caches the display name once resolved.
@@ -237,11 +239,28 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
237
239
  } catch { /* storage unavailable */ }
238
240
  return null; // no stored pick — the catalog decides (D5/D6/D11)
239
241
  }
242
+ /** The browser-level pick — what a new chat starts with: the stored record over the cold-start literal, with each slot's provenance. */
243
+ function browserPick() {
244
+ const stored = readStoredModel();
245
+ return {
246
+ picker: {
247
+ model: stored && stored.model ? stored.model : FALLBACK_PICK.model,
248
+ effort: stored ? stored.effort : FALLBACK_PICK.effort,
249
+ },
250
+ pickerFromStore: !!(stored && stored.model),
251
+ effortFromStore: stored !== null,
252
+ };
253
+ }
240
254
  function storeModel() {
241
255
  // Provenance travels with the record: writing st.picker.model when the user never
242
256
  // chose one would pin the cold-start literal (or a default they merely saw), and
243
- // the backend would be authoritative exactly once per browser.
244
- const rec = { model: st.pickerFromStore ? st.picker.model : null, effort: st.picker.effort };
257
+ // the backend would be authoritative exactly once per browser. A thread's model
258
+ // is not the user's browser-level choice either: an effort picked on it leaves
259
+ // the record's model slot as it was.
260
+ const model = st.pickerFromThread
261
+ ? (readStoredModel() || { model: null }).model
262
+ : (st.pickerFromStore ? st.picker.model : null);
263
+ const rec = { model, effort: st.picker.effort };
245
264
  try { storage.setItem('worca-cc.ask.model', JSON.stringify(rec)); } catch { /* ignore */ }
246
265
  }
247
266
  function readStoredThread() { try { return storage.getItem('worca-cc.ask.thread') || null; } catch { return null; } }
@@ -1231,15 +1250,20 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1231
1250
  }
1232
1251
 
1233
1252
  function applyCatalogToPicker() {
1253
+ const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
1254
+ // A thread's model the catalog no longer has (a removed user model) falls back
1255
+ // to the browser-level pick, then the backend default. The thread is not
1256
+ // patched: its next send stores whatever is used.
1257
+ if (st.pickerFromThread && list.length && !catalogEntry(st.picker.model)) { restoreBrowserPick(); return; }
1234
1258
  const fallback = catalogDefault();
1235
1259
  // Each slot is decided by its own provenance: a stored MODEL outranks the backend
1236
1260
  // default, and a stored EFFORT survives even when the model comes from the default.
1237
1261
  // (effortFromStore ⊇ pickerFromStore — a stored model always carries its effort.)
1262
+ // A thread's pick claims both slots.
1238
1263
  const wanted = {
1239
- model: st.pickerFromStore ? st.picker.model : fallback.model,
1240
- effort: st.effortFromStore ? st.picker.effort : fallback.effort,
1264
+ model: st.pickerFromThread || st.pickerFromStore ? st.picker.model : fallback.model,
1265
+ effort: st.pickerFromThread || st.effortFromStore ? st.picker.effort : fallback.effort,
1241
1266
  };
1242
- const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
1243
1267
  const wantedEntry = catalogEntry(wanted.model);
1244
1268
  // Unknown stored/default id -> the backend default -> the first model we do
1245
1269
  // have that is not a hidden built-in (#422; a hidden id is still a valid pick).
@@ -1250,11 +1274,43 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1250
1274
  const changed = next.model !== st.picker.model || next.effort !== st.picker.effort;
1251
1275
  st.picker = next;
1252
1276
  // D11: persist ONLY a repair of a pick the user actually made. Writing the
1253
- // backend default here would make it authoritative exactly once, ever.
1254
- if (changed && st.pickerFromStore) storeModel();
1277
+ // backend default here would make it authoritative exactly once, ever — and a
1278
+ // thread's pick is never the browser's.
1279
+ if (changed && st.pickerFromStore && !st.pickerFromThread) storeModel();
1255
1280
  updatePickerButton();
1256
1281
  }
1257
1282
 
1283
+ /** Back to the browser-level pick (new chat, a thread with no model of its own). */
1284
+ function restoreBrowserPick() {
1285
+ const home = browserPick();
1286
+ st.picker = home.picker;
1287
+ st.pickerFromStore = home.pickerFromStore;
1288
+ st.effortFromStore = home.effortFromStore;
1289
+ st.pickerFromThread = false;
1290
+ if (st.catalog) applyCatalogToPicker();
1291
+ else updatePickerButton();
1292
+ }
1293
+
1294
+ /** A thread SWITCH shows that thread's last model/effort; a row without one gets the browser-level pick. */
1295
+ function applyThreadPick(thread) {
1296
+ const t = thread && typeof thread === 'object' ? thread : null;
1297
+ if (!t || typeof t.model !== 'string' || !t.model || typeof t.effort !== 'string' || !t.effort) { restoreBrowserPick(); return; }
1298
+ st.picker = { model: t.model, effort: t.effort };
1299
+ st.pickerFromThread = true;
1300
+ if (st.catalog) applyCatalogToPicker(); // effort coerced against the entry; a dropped model falls back
1301
+ else updatePickerButton(); // the raw id shows until the catalog lands
1302
+ }
1303
+
1304
+ /** Keep the pick on the open thread, so switching away and back finds it before any send. */
1305
+ function persistThreadPick() {
1306
+ const id = st.threadId;
1307
+ if (!id) return; // a brand-new chat: the first send stores it
1308
+ const body = JSON.stringify({ model: st.picker.model, effort: st.picker.effort });
1309
+ Promise.resolve()
1310
+ .then(() => fetch(`/api/ask/threads/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body }))
1311
+ .catch(() => { /* the next message stores the pick anyway */ });
1312
+ }
1313
+
1258
1314
  function loadCatalog() {
1259
1315
  if (st.catalog) return Promise.resolve(st.catalog);
1260
1316
  if (st.catalogLoading) return st.catalogLoading;
@@ -1313,7 +1369,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1313
1369
  st.picker = { model: id, effort: coerceEffort(catalogEntry(id), st.picker.effort) };
1314
1370
  st.pickerFromStore = true; // an explicit model choice claims the slot (D11)
1315
1371
  st.effortFromStore = true;
1372
+ st.pickerFromThread = false; // the user's own pick now, not the thread's
1316
1373
  storeModel();
1374
+ persistThreadPick();
1317
1375
  updatePickerButton();
1318
1376
  closePopover({ focusTrigger: false });
1319
1377
  focusComposer();
@@ -1323,6 +1381,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1323
1381
  st.picker = { ...st.picker, effort };
1324
1382
  st.effortFromStore = true; // the effort only — the model slot is untouched (D11)
1325
1383
  storeModel();
1384
+ persistThreadPick();
1326
1385
  updatePickerButton();
1327
1386
  closePopover({ focusTrigger: false });
1328
1387
  focusComposer();
@@ -1355,6 +1414,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1355
1414
  if (m.needsSignIn) {
1356
1415
  item.appendChild(tag('needs sign-in', 'is-err', m.signInMessage || 'The provider behind this model is not usable yet — Settings › Models › Providers.'));
1357
1416
  }
1417
+ // Credential broker: whether the signed-in person has the key this model spends from.
1418
+ const cb = credentialBadge(m.id);
1419
+ if (cb) item.appendChild(tag(cb.text, cb.missing ? 'is-err' : 'is-key', cb.title));
1358
1420
  if (m.id === st.picker.model) item.appendChild(make('span', 'ask-model-check', '✓'));
1359
1421
  return item;
1360
1422
  };
@@ -1640,6 +1702,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1640
1702
  storeThread(null);
1641
1703
  el.title.textContent = 'Ask Worca';
1642
1704
  applyThreadScope(null); // #397: a brand-new chat starts on Auto
1705
+ restoreBrowserPick(); // …and on the browser-level pick, not the last chat's
1643
1706
  pruneCardEls(); // st.model is already null — renderTranscript's keep set cannot see the old ids
1644
1707
  renderTranscript();
1645
1708
  updateMeters();
@@ -2404,6 +2467,55 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2404
2467
  return { el: rootEl };
2405
2468
  }
2406
2469
 
2470
+ /** The web card (propose_web_access): proposed → applied | failed, or declined. The exact URL is shown, so a
2471
+ * request that smuggles data is visible before the click. Every value is text. */
2472
+ function buildWebCard(block) {
2473
+ const card = block.card || {};
2474
+ const host = card.host || 'a website';
2475
+ if (block.state === 'declined') return { el: make('div', 'ask-card-stub', `Declined — ${card.summary || `Read ${host}`}`) };
2476
+ const rootEl = make('div', `ask-card ask-mcard ask-webcard is-${block.state}`);
2477
+ rootEl.setAttribute('data-ask-webcard', block.state);
2478
+ const result = card.result || null;
2479
+ const head = make('div', 'ask-mcard-head');
2480
+ const title = block.state === 'failed' ? 'Web access not granted'
2481
+ : block.state === 'applied' ? (result && result.scope === 'always' ? 'Always allowed' : 'Allowed for this chat')
2482
+ : 'Ask Worca wants to read a new site';
2483
+ head.appendChild(make('span', 'ask-mcard-title', title));
2484
+ head.appendChild(make('span', 'ask-mcard-kind', 'Web'));
2485
+ rootEl.appendChild(head);
2486
+ const body = make('div', 'ask-mcard-body');
2487
+ const sum = make('div', 'ask-mcard-summary');
2488
+ if (block.state === 'applied') sum.appendChild(svgIcon(WF_ICO.check, 15, 2.4));
2489
+ sum.appendChild(make('span', null, host));
2490
+ body.appendChild(sum);
2491
+ if (card.reason) body.appendChild(make('div', 'ask-mcard-note', card.reason));
2492
+ const ul = make('ul', 'ask-mcard-changes');
2493
+ const li = make('li');
2494
+ li.appendChild(make('span', 'ask-mcard-change-label', 'URL'));
2495
+ const val = make('span', 'ask-mcard-change-val');
2496
+ val.appendChild(make('span', 'ask-mcard-after', String(card.url || '')));
2497
+ li.appendChild(val);
2498
+ ul.appendChild(li);
2499
+ body.appendChild(ul);
2500
+ if (block.state === 'failed') body.appendChild(make('div', 'ask-mcard-failed', `Could not allow: ${block.error || (result && result.error) || 'unknown error'}`));
2501
+ rootEl.appendChild(body);
2502
+ rootEl.appendChild(make('div', 'ask-card-err'));
2503
+ if (block.state === 'proposed') {
2504
+ const actions = make('div', 'ask-mcard-actions');
2505
+ const btn = (cls, text, attr) => { const b = make('button', cls, text); b.type = 'button'; b.setAttribute(attr, ''); return b; };
2506
+ const decline = btn('ask-card-not-now', 'Deny', 'data-ask-web-decline');
2507
+ decline.addEventListener('click', () => postCard(block, rootEl, { state: 'declined' }, decline));
2508
+ const always = btn('ask-card-not-now', 'Always allow', 'data-ask-web-always');
2509
+ always.title = `Adds ${host} to Settings → Ask Worca → Web access`;
2510
+ always.addEventListener('click', () => postCard(block, rootEl, { state: 'applied', scope: 'always' }, always));
2511
+ const chat = btn('ask-card-start', 'Allow for this chat', 'data-ask-web-chat');
2512
+ chat.addEventListener('click', () => postCard(block, rootEl, { state: 'applied', scope: 'chat' }, chat));
2513
+ actions.append(make('span', 'ask-card-actions-spacer'), decline, always, chat);
2514
+ rootEl.appendChild(actions);
2515
+ }
2516
+ return { el: rootEl };
2517
+ }
2518
+
2407
2519
  /** The clone card (propose_clone_project): proposed → cloning → applied | failed, or declined. Every value is text. */
2408
2520
  function buildCloneCard(block) {
2409
2521
  const card = block.card || {};
@@ -2480,6 +2592,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2480
2592
  closePopover({ focusTrigger: true });
2481
2593
  });
2482
2594
  item.appendChild(make('span', 'ask-model-name', m.label || m.id));
2595
+ const cb = credentialBadge(m.id);
2596
+ if (cb) {
2597
+ const t = make('span', `ask-model-tag ${cb.missing ? 'is-err' : 'is-key'}`, cb.text);
2598
+ t.title = cb.title;
2599
+ item.appendChild(t);
2600
+ }
2483
2601
  if (m.id === cur().model) item.appendChild(make('span', 'ask-model-check', '✓'));
2484
2602
  p.appendChild(item);
2485
2603
  }
@@ -3122,7 +3240,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3122
3240
  function isProgressBlock(block) {
3123
3241
  const card = block.card || {};
3124
3242
  if (card.type === PROGRESS_CARD_TYPE) return true;
3125
- if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone') return false;
3243
+ if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' || card.type === 'web') return false;
3126
3244
  return block.state === 'started' || (block.state === 'failed' && !!block.runId);
3127
3245
  }
3128
3246
  function buildCard(block) {
@@ -3134,14 +3252,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3134
3252
  const isSchedule = !!(block.card && block.card.type === 'schedule');
3135
3253
  const isModel = !!(block.card && block.card.type === 'model');
3136
3254
  const isClone = !!(block.card && block.card.type === 'clone');
3255
+ const isWeb = !!(block.card && block.card.type === 'web');
3137
3256
  const isProgress = isProgressBlock(block);
3138
- if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isClone || isProgress || block.state === 'proposed')) return cached.el;
3257
+ if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isClone || isWeb || isProgress || block.state === 'proposed')) return cached.el;
3139
3258
  if (cached) disposeCardEntry(cached);
3140
3259
  const built = isWorkflow ? buildWorkflowCard(block, cached)
3141
3260
  : isMetrics ? buildMetricsCard(block)
3142
3261
  : isSchedule ? buildScheduleCard(block)
3143
3262
  : isModel ? buildModelCard(block)
3144
3263
  : isClone ? buildCloneCard(block)
3264
+ : isWeb ? buildWebCard(block)
3145
3265
  : isProgress ? buildProgressCard(block)
3146
3266
  : { el: block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block) };
3147
3267
  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 });
@@ -3386,10 +3506,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3386
3506
  // Stopped after — and nothing more while the turn is live: the orb row at the
3387
3507
  // bottom of the message owns the elapsed and the meter, and printing either
3388
3508
  // set twice is the noise this replaced. A turn that ended badly says so
3389
- // instead of Done; nothing else marks a stop.
3509
+ // instead of Done; nothing else marks a stop. A turn that ended before any
3510
+ // result, or within a few ms (a signed-out CLI answers in ~20 ms), has no
3511
+ // duration worth printing: plain Stopped, never a dangling "Stopped after"
3512
+ // or "Stopped after 0.0s".
3390
3513
  if (!isLive) {
3391
- if (stopped) parts.push(make('span', 'ask-activity-label', 'Stopped after'));
3392
- parts.push(make('span', 'ask-activity-elapsed', fmtElapsed(r.durationMs) || ''));
3514
+ const shown = fmtElapsed(r.durationMs);
3515
+ const elapsed = shown && shown !== '0.0s' ? shown : '';
3516
+ if (stopped) parts.push(make('span', 'ask-activity-label', elapsed ? 'Stopped after' : 'Stopped'));
3517
+ parts.push(make('span', 'ask-activity-elapsed', elapsed));
3393
3518
  parts.push(make('span', 'ask-activity-spacer'));
3394
3519
  const meter = [fmtCtx(r.usage && r.usage.ctx), fmtUsd(r.costUsd)].filter(Boolean).join(' · ');
3395
3520
  parts.push(make('span', 'ask-activity-meter', meter));
@@ -3515,7 +3640,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3515
3640
  if (b.kind === 'notice') parts.push(`n:${b.id ?? ''}:${b.text || ''}:${b.href || ''}`);
3516
3641
  else if (b.kind === 'card') parts.push(`c:${b.id}:${b.state || ''}:${(b.card && b.card.type) || ''}:${b.runId || ''}:${b.error || ''}`);
3517
3642
  }
3518
- parts.push(`r:${row.status || ''}:${row.errorMessage || ''}:${isLiveRow(row) ? 1 : 0}`);
3643
+ parts.push(`r:${row.status || ''}:${row.errorMessage || ''}:${row.errorCode || ''}:${isLiveRow(row) ? 1 : 0}`);
3519
3644
  return parts.join('|');
3520
3645
  }
3521
3646
 
@@ -3565,7 +3690,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3565
3690
  }
3566
3691
  if (cur.status === 'error') {
3567
3692
  const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
3568
- if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
3693
+ if (cur.errorCode === 'claude-signed-out' && typeof openClaudeSetup === 'function') {
3694
+ // The CLI's raw "Not logged in" → one line whose link opens Connect Claude Code.
3695
+ const line = make('div', 'ask-error-line', "Claude Code isn't signed in. ");
3696
+ const link = make('a', '', 'Sign in…');
3697
+ link.href = '#';
3698
+ link.addEventListener('click', (e) => { e.preventDefault(); openClaudeSetup(); });
3699
+ line.appendChild(link);
3700
+ wrap.appendChild(line);
3701
+ } else if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
3569
3702
  else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
3570
3703
  }
3571
3704
  if (isLiveRow(cur)) {
@@ -3643,12 +3776,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3643
3776
  // A SWITCH starts a fresh ledger, so the new chat rises in. A resync or a
3644
3777
  // reconnect re-loads the SAME thread and must keep it: those repaint rows the
3645
3778
  // user is already reading, mid-turn.
3646
- if (st.threadId !== id) st.seenRows = new Set();
3779
+ const switched = st.threadId !== id;
3780
+ if (switched) st.seenRows = new Set();
3647
3781
  st.threadId = id;
3648
3782
  st.model = createThreadModel({ threadId: id });
3649
3783
  st.model.load(snap);
3650
3784
  el.title.textContent = (snap.thread && snap.thread.title) || 'Ask Worca';
3651
3785
  applyThreadScope(snap.thread && snap.thread.context); // #397: restore the pin
3786
+ // The picker follows the chat — on a SWITCH only: a resync of the same thread
3787
+ // would otherwise clobber a pick the user just made (its PATCH may not have landed).
3788
+ if (switched) applyThreadPick(snap.thread);
3652
3789
  renderTranscript();
3653
3790
  updateMeters();
3654
3791
  // P4: the count rides the snapshot loadThread ALREADY fetched — no extra GET.