@worca/app 1.6.0-rc.2 → 1.6.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/app",
3
- "version": "1.6.0-rc.2",
3
+ "version": "1.6.0",
4
4
  "description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -291,6 +291,7 @@ export function createTurnReducer({
291
291
  let lastResult = null;
292
292
  let reducerErrors = 0;
293
293
  let summary = null;
294
+ let cliErrorText = ''; // what a <synthetic> CLI message said (its API-error line)
294
295
  const pendingHooks = []; // promises returned by onProposal — settle() awaits them
295
296
 
296
297
  // ── helpers ──
@@ -420,7 +421,18 @@ export function createTurnReducer({
420
421
  const content = Array.isArray(msg.content) ? msg.content : [];
421
422
  if (isMain) {
422
423
  sawAssistant = true;
423
- if (id) {
424
+ // A `<synthetic>` message is the CLI speaking for itself — the API-refusal
425
+ // line it fabricates when a call fails (model: "<synthetic>"). It is not
426
+ // the model's answer: it must never enter the answer text (it would read
427
+ // as a reply above the failure notice), so it is kept aside for the error
428
+ // notice's detail instead.
429
+ if (msg.model === '<synthetic>') {
430
+ const t = content
431
+ .filter((c) => c && c.type === 'text' && typeof c.text === 'string')
432
+ .map((c) => c.text)
433
+ .join('');
434
+ if (t) cliErrorText = cliErrorText ? `${cliErrorText}\n${t}` : t;
435
+ } else if (id) {
424
436
  if (messages.has('__main__') && !messages.has(id)) { // deltas arrived before any message_start: adopt them
425
437
  messages.set(id, messages.get('__main__'));
426
438
  messages.delete('__main__');
@@ -710,7 +722,7 @@ export function createTurnReducer({
710
722
  return {
711
723
  text: mainText(), blocks: blocks.map(clone), usage: currentUsage(), costUsd: currentCost(), sessionId,
712
724
  ...terminal(), sawInit, sawAssistant, sawResult, agents: blocks.filter((b) => b.kind === 'agent').length,
713
- runningAgents, labels: [...labels], reducerErrors,
725
+ runningAgents, labels: [...labels], reducerErrors, cliErrorText: cliErrorText || null,
714
726
  };
715
727
  },
716
728
  finish() {
@@ -736,10 +748,13 @@ export function createTurnReducer({
736
748
  a.costUsd = est[i].costUsd == null ? null : Math.round(est[i].costUsd * scale * 1e6) / 1e6;
737
749
  });
738
750
  }
739
- const text = mainText() || (lastResult && typeof lastResult.result === 'string' ? lastResult.result : '');
751
+ // The result-text fallback only speaks for a REAL answer: an is_error
752
+ // result carries the API's refusal line, never the model's reply.
753
+ const text = mainText() || (lastResult && !lastResult.is_error && typeof lastResult.result === 'string' ? lastResult.result : '');
740
754
  summary = {
741
755
  text: redact(text), blocks: blocks.map(clone), usage: currentUsage(), costUsd: currentCost(), sessionId,
742
756
  ...terminal(), sawInit, sawAssistant, sawResult, agents: agents.length, labels: [...labels], reducerErrors,
757
+ cliErrorText: cliErrorText || null,
743
758
  };
744
759
  return summary;
745
760
  },
@@ -6,6 +6,7 @@
6
6
  import { listModels as realListModels, EFFORTS } from '../config.mjs';
7
7
  import { listPluginModels as realPluginModels, pluginModelSecretStatus as realSecretStatus } from '../plugin-models.mjs';
8
8
  import { ASK_LIMITS } from './limits.mjs';
9
+ import { effortlessModels as realEffortless } from '../bridge/upstream.mjs';
9
10
 
10
11
  /**
11
12
  * @param {{
@@ -13,6 +14,7 @@ import { ASK_LIMITS } from './limits.mjs';
13
14
  * pluginModels?: ()=>Array<{plugin:string,id:string,secrets?:string[]}>,
14
15
  * secretStatus?: (plugin:string)=>Array<{key:string,set:boolean}>,
15
16
  * defaults?: {defaultModel:string, defaultEffort:string},
17
+ * effortless?: ()=>Set<string>,
16
18
  * }} [deps]
17
19
  */
18
20
  export function createAskModels({
@@ -20,6 +22,7 @@ export function createAskModels({
20
22
  pluginModels = realPluginModels,
21
23
  secretStatus = realSecretStatus,
22
24
  defaults = ASK_LIMITS,
25
+ effortless = realEffortless,
23
26
  } = {}) {
24
27
  /**
25
28
  * lc id -> the modelSecrets keys that model needs but that are NOT set.
@@ -71,6 +74,7 @@ export function createAskModels({
71
74
  async function askCatalog({ withSecrets = true } = {}) {
72
75
  const all = await listModels('');
73
76
  const models = [];
77
+ const noEffort = effortless();
74
78
  let missing = null; // lazily built on the first plugin entry
75
79
  for (const m of all) {
76
80
  if (!m || typeof m.id !== 'string') continue;
@@ -88,7 +92,10 @@ export function createAskModels({
88
92
  hasEnv: m.hasEnv === true,
89
93
  };
90
94
  if (custom === 'plugin' && typeof m.plugin === 'string' && m.plugin) entry.plugin = m.plugin;
91
- if (m.hidden === true) entry.hidden = true; // the picker skips it; validation does not (#422)
95
+ if (m.hidden === true) entry.hidden = true;
96
+ // Its upstream refused a reasoning effort (bridge/upstream.mjs leaves it out from then
97
+ // on): the picker shows the effort as not applicable. The effort still validates.
98
+ if (noEffort.has(m.id)) entry.noEffort = true; // the picker skips it; validation does not (#422)
92
99
  // Only globals and plugin entries can arrive flagged: composeCatalog emits an
93
100
  // UNSHADOWED built-in as {...m, custom:false, hasEnv:false} with no
94
101
  // ...unreliable(lc) (src/core/config.mjs:200), so a built-in in model_cost_flags
@@ -47,6 +47,27 @@ export function createAskTurn(opts) { return new AskTurn(opts); }
47
47
 
48
48
  const TERMINAL = new Set(['done', 'stopped', 'error']);
49
49
 
50
+ // The classified notice's raw-detail cap — the runner's own reject is already
51
+ // tail-capped tighter than this; the slice(-N) here only bounds the unusual
52
+ // non-runner error paths so a huge message can never bloat the persisted block.
53
+ const ERROR_DETAIL_MAX = 2000;
54
+
55
+ /** The human line a classified failure carries. The block is persisted and
56
+ * shared by every viewer, so the wording is level-neutral: it names where a
57
+ * remedy lives (including which interface mode gates it) instead of assuming
58
+ * one. Pure, exported for tests. */
59
+ export function humanErrorText(errorClass) {
60
+ switch (errorClass) {
61
+ case 'model': return "This model isn't available in your environment — Claude Code couldn't use it. Try another model, or add your custom model in Settings › Models (Expert mode).";
62
+ case 'auth': return 'Authentication failed — check the credentials behind this model.';
63
+ case 'usage_limit': return 'A usage limit was reached — wait for it to reset.';
64
+ case 'rate_limit': return 'The endpoint is rate-limiting — try again shortly.';
65
+ case 'quota': return 'A quota/billing problem was reported — check the account behind this model.';
66
+ case 'network': return 'The endpoint was unreachable — check your connection and retry.';
67
+ default: return null;
68
+ }
69
+ }
70
+
50
71
  class AskTurn extends EventEmitter {
51
72
  constructor({
52
73
  threadId, assistantMessageId, userMessageId,
@@ -546,6 +567,28 @@ class AskTurn extends EventEmitter {
546
567
  for (const b of this.reducer.snapshot().blocks) {
547
568
  if (b && b.kind === 'card' && b.state === 'building') this.reducer.updateBlock(b.id, { state: 'failed', error: 'the reply ended before the proposal was ready' });
548
569
  }
570
+ // A classified failure tells the user what happened and what to do, as a
571
+ // notice block: it rides the terminal write below (like _limitNotice's
572
+ // notice), so the human line survives a reload, and the extra fields
573
+ // (errorClass, detail) let the chat render its recovery affordance. The
574
+ // wording is level-neutral — the block is shared by every viewer — and
575
+ // an UNCLASSIFIED error adds nothing: the raw message stays the only
576
+ // evidence, exactly as before.
577
+ if (kind === 'error' && errorClass) {
578
+ // The detail carries the full evidence: the runner's exit verdict, plus
579
+ // the CLI's own refusal line when it spoke synthetically (it is often
580
+ // the clearest statement of the cause). Skipped when the runner message
581
+ // already contains it — an empty stderr makes the runner echo it verbatim.
582
+ const cli = this.reducer.snapshot().cliErrorText;
583
+ const extra = cli && message && !String(message).includes(cli) ? cli : null;
584
+ const detail = [message, extra].filter((s) => s && String(s).trim()).join('\n');
585
+ this.reducer.addBlock({
586
+ kind: 'notice',
587
+ text: humanErrorText(errorClass),
588
+ errorClass,
589
+ detail: detail ? String(detail).slice(-ERROR_DETAIL_MAX) : null,
590
+ });
591
+ }
549
592
  const summary = this.reducer.finish();
550
593
  const finalStatus = kind === 'error' ? 'error' : status;
551
594
  // Already AUTHORITATIVE: the reducer applied this turn's per-model cost
@@ -594,8 +637,11 @@ class AskTurn extends EventEmitter {
594
637
  // out, the CLI may not even say so (`unrecognized_model` on a first-party id),
595
638
  // so claude-auth asks `claude auth status` instead of trusting the text.
596
639
  const signedOut = await d.failedBecauseSignedOut({ message, model: this.model }).catch(() => false);
640
+ // The persisted blocks ride along, mirroring ask-done: the live client
641
+ // must render the same classified notice a reload re-derives — an
642
+ // ask-error frame without them shows the raw message until refresh.
597
643
  this._frame({
598
- type: 'ask-error', message: message || 'unknown error',
644
+ type: 'ask-error', message: message || 'unknown error', blocks: summary.blocks,
599
645
  ...(errorClass !== undefined ? { errorClass } : {}),
600
646
  ...(signedOut ? { code: CLAUDE_SIGNED_OUT_CODE } : {}),
601
647
  });
@@ -35,6 +35,23 @@ function applySchemaDrops(body, d) {
35
35
  return d ? withoutTools(withToolSchemaKeywordsDropped(body, d.keywords), d.tools) : body;
36
36
  }
37
37
 
38
+ // Models that refused a reasoning effort on the Anthropic passthrough (Copilot serves
39
+ // claude-haiku-4.5 without one): the CLI's output_config.effort is left out of their
40
+ // requests. Learned like schemaDrops, and listed so the Ask picker can grey the effort out.
41
+ const effortDrops = new Set(); // catalog ids
42
+ const EFFORT_REFUSED_RE = /does not support (?:reasoning )?effort|effort[^.]{0,40}not supported/i;
43
+ export function _resetEffortDrops() { effortDrops.clear(); }
44
+ /** Catalog ids whose upstream refused a reasoning effort since boot. */
45
+ export function effortlessModels() { return new Set(effortDrops); }
46
+ function withoutEffort(body) {
47
+ const oc = body && body.output_config;
48
+ if (!oc || typeof oc !== 'object' || !('effort' in oc)) return body;
49
+ const { effort: _drop, ...rest } = oc;
50
+ const out = { ...body };
51
+ if (Object.keys(rest).length) out.output_config = rest; else delete out.output_config;
52
+ return out;
53
+ }
54
+
38
55
  /** Once-per-process warning (dropped fields, queue notices). */
39
56
  function warnOnce(key, line, log) {
40
57
  if (warned.has(key)) return;
@@ -157,6 +174,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
157
174
  let outBody;
158
175
  if (us.api === 'anthropic') {
159
176
  outBody = { ...body, model: us.model };
177
+ if (effortDrops.has(entry.id)) outBody = withoutEffort(outBody);
160
178
  } else {
161
179
  const responses = us.api === 'openai-responses';
162
180
  const t = (responses ? toResponsesRequest : toChatRequest)(body, { upstreamModel: us.model, capabilities: us.capabilities });
@@ -236,6 +254,17 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
236
254
  payload = JSON.stringify(outBody);
237
255
  res = await doFetch(prep);
238
256
  }
257
+ // A reasoning effort the model does not take: leave it out, remember that, retry once.
258
+ if (res.status === 400 && us.api === 'anthropic' && outBody.output_config && 'effort' in outBody.output_config) {
259
+ const text = await res.clone().text().catch(() => '');
260
+ if (EFFORT_REFUSED_RE.test(text)) {
261
+ effortDrops.add(entry.id);
262
+ warnOnce(`effort:${entry.id}`, `[worca] bridge: model ${JSON.stringify(entry.id)} takes no reasoning effort — leaving it out of its requests`, log);
263
+ outBody = withoutEffort(outBody);
264
+ payload = JSON.stringify(outBody);
265
+ res = await doFetch(prep);
266
+ }
267
+ }
239
268
  } catch (err) {
240
269
  if (signal && signal.aborted) return reply.end();
241
270
  const e = mapNetworkError(err, { provider: us.provider });
@@ -84,6 +84,9 @@ export const FAILURE_POLICY = Object.freeze({
84
84
  // A self-parked auto run pauses as RECOVERABLE (the class is kept: "resume when
85
85
  // it clears"); a user who gives up on the prompt pauses as ERROR (a verdict).
86
86
  auth: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
87
+ // The model id itself was refused — retrying the same id is futile. The
88
+ // run parks as an error; resume retries the node once the model is fixed.
89
+ model: both(pause(REASON.ERROR)),
87
90
  quota: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
88
91
  rate_limit: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
89
92
  network: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
@@ -22,6 +22,7 @@ const SYSTEM = 'You are a connectivity check. Reply with exactly OK.';
22
22
  export function hintFor(errorClass) {
23
23
  switch (errorClass) {
24
24
  case 'auth': return 'authentication failed — check the token/secret for this model';
25
+ case 'model': return 'this model id is not served by the endpoint — pick a model the endpoint serves, or add yours in Settings › Models';
25
26
  case 'network': return 'endpoint unreachable — check ANTHROPIC_BASE_URL';
26
27
  case 'rate_limit': return 'the endpoint is rate-limiting or overloaded — try again shortly';
27
28
  case 'quota': return 'quota/billing problem — check the account behind this endpoint';
@@ -15,7 +15,7 @@
15
15
  // recoverable and retried. Structured error-code detection is out of scope.
16
16
  //
17
17
  // @param {Error|string|unknown} err
18
- // @returns {'auth'|'usage_limit'|'rate_limit'|'quota'|'network'|null}
18
+ // @returns {'auth'|'model'|'usage_limit'|'rate_limit'|'quota'|'network'|null}
19
19
  export function classifyError(err) {
20
20
  // A producer that saw MORE evidence than the message carries stamps the
21
21
  // verdict directly: claude-runner classifies the FULL stderr stream line-by-
@@ -32,6 +32,15 @@ export function classifyError(err) {
32
32
  // it, so it pauses like the session limit below instead of retrying as a 429.
33
33
  if (FREE_DAILY_RE.test(msg)) return 'usage_limit';
34
34
  if (/\b401\b|invalid authentication|authentication_error|please run .*login|not logged in/i.test(msg)) return 'auth';
35
+ // The model id itself is the problem — refused by the endpoint it was sent to,
36
+ // or named by a catalog-miss error. The remedy is a different model id, never
37
+ // a retry. The stderr notice `[claude-code:unrecognized_model]` is deliberately
38
+ // NOT classified here: the runner treats it as a benign notice
39
+ // (claude-runner BENIGN_STDERR_PATTERNS) — it fires on every spawn whose id the
40
+ // CLI does not know, and never states the cause. The phrases are specific
41
+ // CLI/API wordings so an ordinary message that merely mentions a model stays
42
+ // unclassified.
43
+ if (/no access to this model|isn't described by this version's model catalog|model not found/i.test(msg)) return 'model';
35
44
  // Session/usage caps that only clear after a multi-hour reset (the CLI prints
36
45
  // "You've hit your session limit · resets 6pm"). Distinct from rate_limit (a
37
46
  // few-second 429/overloaded burst) because retrying is futile — the orchestrator
@@ -136,7 +145,7 @@ export function brokerHint(err, cls = classifyError(err)) {
136
145
  // SAME order as the regex chain above. First-match-wins there equals
137
146
  // strongest-class-wins here, because every per-line match (the patterns are
138
147
  // unanchored) is also a whole-text match.
139
- const CLASS_ORDER = ['auth', 'usage_limit', 'rate_limit', 'quota', 'network'];
148
+ const CLASS_ORDER = ['auth', 'model', 'usage_limit', 'rate_limit', 'quota', 'network'];
140
149
 
141
150
  /** Fold two classification results, keeping the higher-precedence class. */
142
151
  export function strongestClass(a, b) {
@@ -188,6 +188,9 @@ export function createThreadModel({ threadId }) {
188
188
  break;
189
189
  case 'ask-error':
190
190
  row.text = live.text;
191
+ // The terminal blocks ride the frame (as on ask-done): the classified
192
+ // notice arrives live, so the chat renders it without waiting for a reload.
193
+ if (Array.isArray(frame.blocks)) row.blocks = frame.blocks;
191
194
  row.status = 'error';
192
195
  row.errorMessage = frame.message || 'unknown error';
193
196
  row.errorCode = frame.code || null;
@@ -1234,7 +1234,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1234
1234
  const flagged = !!entry && (entry.costUnreliable === true
1235
1235
  || (Array.isArray(entry.secretsMissing) && entry.secretsMissing.length > 0));
1236
1236
  el.modelBtnLabel.textContent = (entry ? entry.label : st.picker.model) + (flagged ? ' ⚠' : '');
1237
- el.modelBtnEffort.textContent = st.picker.effort;
1237
+ // A model that takes no reasoning effort shows none (the bridge leaves it out).
1238
+ el.modelBtnEffort.textContent = entry && entry.noEffort ? '' : st.picker.effort;
1239
+ el.modelBtnEffort.hidden = !!(entry && entry.noEffort);
1238
1240
  }
1239
1241
 
1240
1242
  function coerceEffort(entry, effort) {
@@ -1311,8 +1313,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1311
1313
  .catch(() => { /* the next message stores the pick anyway */ });
1312
1314
  }
1313
1315
 
1314
- function loadCatalog() {
1315
- if (st.catalog) return Promise.resolve(st.catalog);
1316
+ // fresh: fetch again even with a catalog in hand. Models imported (or keys set on the key
1317
+ // page) after the first load reach the menu when it next opens, without a page reload.
1318
+ function loadCatalog({ fresh = false } = {}) {
1319
+ if (st.catalog && !fresh) return Promise.resolve(st.catalog);
1316
1320
  if (st.catalogLoading) return st.catalogLoading;
1317
1321
  st.catalogLoading = Promise.resolve()
1318
1322
  .then(() => fetch('/api/ask/models'))
@@ -1420,7 +1424,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1420
1424
  if (m.id === st.picker.model) item.appendChild(make('span', 'ask-model-check', '✓'));
1421
1425
  return item;
1422
1426
  };
1427
+ let shownPane = 'main';
1423
1428
  const renderPane = (pane) => {
1429
+ shownPane = pane;
1424
1430
  panel.replaceChildren();
1425
1431
  if (pane === 'effort') {
1426
1432
  const back = menuItem('ask-pane-back', () => renderPane('main'));
@@ -1446,11 +1452,19 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1446
1452
  const { primary, rest } = splitCatalog();
1447
1453
  for (const m of primary) panel.appendChild(modelItem(m));
1448
1454
  panel.appendChild(make('div', 'ask-pop-divider'));
1449
- const effortRow = menuItem('ask-effort-row', () => renderPane('effort'));
1455
+ const noEffort = !!catalogEntry(st.picker.model)?.noEffort;
1456
+ const effortRow = menuItem('ask-effort-row', noEffort ? null : () => renderPane('effort'));
1450
1457
  effortRow.setAttribute('data-ask-effort-row', '');
1451
1458
  effortRow.appendChild(make('span', null, 'Effort'));
1452
- effortRow.appendChild(make('span', 'ask-pop-row-value', st.picker.effort));
1453
- effortRow.appendChild(make('span', 'ask-pop-row-chev', '›'));
1459
+ if (noEffort) {
1460
+ // The model's provider refused a reasoning effort; worca leaves it out of the request.
1461
+ effortRow.disabled = true;
1462
+ effortRow.title = 'This model takes no reasoning effort, so none is sent.';
1463
+ effortRow.appendChild(make('span', 'ask-pop-row-value', 'not supported'));
1464
+ } else {
1465
+ effortRow.appendChild(make('span', 'ask-pop-row-value', st.picker.effort));
1466
+ effortRow.appendChild(make('span', 'ask-pop-row-chev', '›'));
1467
+ }
1454
1468
  panel.appendChild(effortRow);
1455
1469
  if (rest.length) {
1456
1470
  const moreRow = menuItem('ask-more-models', () => renderPane('more'));
@@ -1462,7 +1476,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1462
1476
  }
1463
1477
  focusFirst();
1464
1478
  };
1465
- loadCatalog().then(() => { if (st.popover && st.popover.panel === panel) renderPane('main'); });
1479
+ // What we have paints at once; the refetch repaints the main pane if it is still showing.
1480
+ const had = st.catalog;
1481
+ loadCatalog({ fresh: true }).then((c) => {
1482
+ if (c !== had && shownPane === 'main' && st.popover && st.popover.panel === panel) renderPane('main');
1483
+ });
1466
1484
  renderPane('main');
1467
1485
  }
1468
1486
 
@@ -1770,6 +1788,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1770
1788
  a.setAttribute('href', b.href);
1771
1789
  n.appendChild(a);
1772
1790
  }
1791
+ // The raw failure evidence, for those who debug: expert only — the human
1792
+ // line above is the explanation at every level.
1793
+ if (b.errorClass && b.detail) {
1794
+ const det = doc.createElement('details');
1795
+ det.className = 'ask-error-details';
1796
+ det.dataset.minLevel = 'expert';
1797
+ det.appendChild(make('summary', null, 'Details'));
1798
+ det.appendChild(make('div', 'ask-error-detail-text', b.detail));
1799
+ n.appendChild(det);
1800
+ }
1773
1801
  return n;
1774
1802
  }
1775
1803
 
@@ -3689,7 +3717,6 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3689
3717
  else if (b.kind === 'card') wrap.appendChild(buildCard(b, cur));
3690
3718
  }
3691
3719
  if (cur.status === 'error') {
3692
- const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
3693
3720
  if (cur.errorCode === 'claude-signed-out' && typeof openClaudeSetup === 'function') {
3694
3721
  // The CLI's raw "Not logged in" → one line whose link opens Connect Claude Code.
3695
3722
  const line = make('div', 'ask-error-line', "Claude Code isn't signed in. ");
@@ -3698,8 +3725,17 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3698
3725
  link.addEventListener('click', (e) => { e.preventDefault(); openClaudeSetup(); });
3699
3726
  line.appendChild(link);
3700
3727
  wrap.appendChild(line);
3701
- } else if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
3702
- else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
3728
+ } else {
3729
+ // A classified notice (errorClass on the block) IS the explanation —
3730
+ // it renders the human line and, at expert, the raw detail in its
3731
+ // own expander. The raw line here is only for the unclassified case.
3732
+ const classified = (cur.blocks || []).some((b) => b && b.kind === 'notice' && b.errorClass);
3733
+ if (!classified) {
3734
+ const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
3735
+ if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
3736
+ else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
3737
+ }
3738
+ }
3703
3739
  }
3704
3740
  if (isLiveRow(cur)) {
3705
3741
  wrap.appendChild(ensureThinking()); // last child: the bottom of the message
@@ -4517,6 +4517,13 @@ body.rail-collapsed .ask-dock{left:76px;}
4517
4517
  .ask-notice{font-size:12.5px;color:var(--ink-3);}
4518
4518
  .ask-notice-link{color:var(--blue-ink);}
4519
4519
  .ask-error-line{font-size:12.5px;color:var(--red-ink);}
4520
+ /* The classified-failure notice's raw-detail expander: expert only — the
4521
+ human line is the explanation at every level (ui-levels rule 1 keeps it). */
4522
+ .ask-error-details{margin-top:3px;}
4523
+ .ask-error-details summary{cursor:pointer;color:var(--ink-3);font-size:11.5px;user-select:none;}
4524
+ .ask-error-detail-text{margin-top:3px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;
4525
+ background:var(--field);color:var(--ink-3);font-family:var(--mono);
4526
+ font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:160px;overflow-y:auto;}
4520
4527
 
4521
4528
  .ask-card{margin-top:2px;display:flex;flex-direction:column;gap:10px;padding:13px 15px;
4522
4529
  border:1.5px solid var(--line);border-radius:var(--r-card);background:var(--panel);}
@@ -4856,6 +4863,8 @@ body.rail-collapsed .ask-dock{left:76px;}
4856
4863
  .ask-pop-item{display:flex;align-items:center;gap:10px;width:100%;padding:9px 11px;border:0;border-radius:12px;
4857
4864
  background:transparent;text-align:left;font-family:inherit;cursor:pointer;transition:.15s;}
4858
4865
  .ask-pop-item:hover,.ask-pop-item:focus-visible{background:var(--field);outline:none;}
4866
+ .ask-pop-item:disabled{cursor:default;color:var(--ink-3);}
4867
+ .ask-pop-item:disabled:hover{background:transparent;}
4859
4868
  .ask-model-name{font-weight:500;font-size:13.5px;color:var(--ink);}
4860
4869
  .ask-model-check{margin-left:auto;color:var(--blue-ink);font-weight:600;}
4861
4870
  .ask-pop-row-value{margin-left:auto;font-weight:400;font-size:12.5px;color:var(--ink-3);}