@worca/app 1.6.0-rc.1 → 1.6.0-rc.3
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/README.md +1 -1
- package/package.json +1 -1
- package/src/core/ask/events.mjs +18 -3
- package/src/core/ask/models.mjs +8 -1
- package/src/core/ask/turn.mjs +52 -2
- package/src/core/bridge/provider-ops.mjs +40 -4
- package/src/core/bridge/upstream.mjs +29 -0
- package/src/core/failure-policy.mjs +3 -0
- package/src/core/model-test.mjs +1 -0
- package/src/core/recoverable-error.mjs +11 -2
- package/ui/public/app.js +27 -11
- package/ui/public/ask-model.mjs +3 -0
- package/ui/public/ask-panel.mjs +46 -10
- package/ui/public/bridge-view.mjs +42 -4
- package/ui/public/index.html +277 -245
- package/ui/public/style.css +20 -4
package/README.md
CHANGED
|
@@ -234,7 +234,7 @@ See [`docs/team-policy.md`](docs/team-policy.md).
|
|
|
234
234
|
into that model's agent spawns. Share a model catalog as a plugin, with
|
|
235
235
|
secrets required at install time.
|
|
236
236
|
- **No first-party account needed** — run and chat titles are written by the
|
|
237
|
-
model the run or chat itself uses (Settings ›
|
|
237
|
+
model the run or chat itself uses (Settings › Models › Title generation picks
|
|
238
238
|
a fixed one instead), endpoint-routed models carry Claude Code's internal
|
|
239
239
|
haiku/sonnet/opus/fable tier keys so nothing falls back to the Anthropic API,
|
|
240
240
|
and *Hide built-in models* (Settings › Models) drops the built-ins from every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/app",
|
|
3
|
-
"version": "1.6.0-rc.
|
|
3
|
+
"version": "1.6.0-rc.3",
|
|
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",
|
package/src/core/ask/events.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/src/core/ask/models.mjs
CHANGED
|
@@ -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;
|
|
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
|
package/src/core/ask/turn.mjs
CHANGED
|
@@ -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
|
});
|
|
@@ -658,7 +704,11 @@ class AskTurn extends EventEmitter {
|
|
|
658
704
|
await d.fs.writeFile(
|
|
659
705
|
mcpConfigPath,
|
|
660
706
|
JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath, ...(this.reader ? { reader: this.reader } : {}), ...(this.relay ? { relay: this.relay } : {}), ...(this.web ? { web: this.web } : {}) }), null, 2),
|
|
661
|
-
|
|
707
|
+
// Never a key value (webKeyVar: the key rides the process env). A relayed turn runs as
|
|
708
|
+
// the person's agent user (agent-pool.mjs), which reads this file through its group: the
|
|
709
|
+
// scratch dir is setgid worca-share (2770), so 0640 reaches the agent users and nobody
|
|
710
|
+
// else. It carries this turn's relay token, which that agent must present anyway.
|
|
711
|
+
{ encoding: 'utf8', mode: this.relay ? 0o640 : 0o600 },
|
|
662
712
|
);
|
|
663
713
|
// One 30-minute budget for the whole turn, retry included. The timedOut
|
|
664
714
|
// flag and abort() run in ONE synchronous callback, so R-C always reads
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from './providers/copilot.mjs';
|
|
20
20
|
import { keyOptional } from './registry.mjs';
|
|
21
21
|
import { listEndpointModels, catalogEntryForEndpointModel, importableModel } from './providers/endpoint.mjs';
|
|
22
|
-
import { brokerEnabled, brokerInfo, cachedBrokerInfo, mintSpawnToken, revokeSpawnToken, slotBaseUrl } from '../broker-client.mjs';
|
|
22
|
+
import { brokerEnabled, brokerInfo, cachedBrokerInfo, mintSpawnToken, revokeSpawnToken, slotBaseUrl, personSlots } from '../broker-client.mjs';
|
|
23
23
|
import { routeUpstream } from '../broker-routing.mjs';
|
|
24
24
|
import { resolveBillTo } from '../billing.mjs';
|
|
25
25
|
|
|
@@ -129,7 +129,7 @@ const secretSource = (v) => (!v ? null : modelEnvRef(v) ? 'env' : 'stored');
|
|
|
129
129
|
* a network call to GitHub — and is null when the account exposes none.
|
|
130
130
|
* @param {{quota?:boolean, fetch?:typeof fetch}} [opts]
|
|
131
131
|
*/
|
|
132
|
-
export async function providersState({ quota = false, fetch: f } = {}) {
|
|
132
|
+
export async function providersState({ quota = false, fetch: f, person = resolveBillTo(null) } = {}) {
|
|
133
133
|
const all = allProviders();
|
|
134
134
|
const c = all.copilot;
|
|
135
135
|
const token = resolveProviderSecret(c.githubToken);
|
|
@@ -161,11 +161,47 @@ export async function providersState({ quota = false, fetch: f } = {}) {
|
|
|
161
161
|
maxConcurrent: p.maxConcurrent,
|
|
162
162
|
};
|
|
163
163
|
};
|
|
164
|
-
// With the credential broker on, keys and the Copilot sign-in are per person on its key page
|
|
165
|
-
|
|
164
|
+
// With the credential broker on, keys and the Copilot sign-in are per person on its key page:
|
|
165
|
+
// each provider's state is the viewer's own slot there, not worca's (empty) settings.
|
|
166
|
+
const broker = brokerEnabled() ? await brokerProviders(all, person) : { enabled: false };
|
|
166
167
|
return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic'), broker };
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The Providers card's view of the broker: which slot each provider spends from and the viewer's
|
|
172
|
+
* state in it — {slot, state:'set'|'missing'|'invalid'|'operator'|'keyless'|'none', suffix?, error?}.
|
|
173
|
+
* Never throws: an unreachable broker leaves the states out and says why.
|
|
174
|
+
*/
|
|
175
|
+
async function brokerProviders(all, person) {
|
|
176
|
+
const info = cachedBrokerInfo() || await brokerInfo().catch(() => null);
|
|
177
|
+
const out = { enabled: true, keyPage: info?.publicUrl || null, mode: info?.mode || null, providers: {} };
|
|
178
|
+
const slots = info?.slots || [];
|
|
179
|
+
const route = (name) => {
|
|
180
|
+
if (name === 'copilot') {
|
|
181
|
+
const s = slots.find((x) => x.auth === 'copilot');
|
|
182
|
+
return s ? { slot: s.id } : { state: 'none', error: 'the credential broker has no GitHub Copilot slot' };
|
|
183
|
+
}
|
|
184
|
+
const r = routeUpstream({ provider: name, baseUrl: all[name].baseUrl || null }, { slots });
|
|
185
|
+
return r.slot ? { slot: r.slot } : r.keyless ? { state: 'keyless' } : { state: 'none', error: r.error };
|
|
186
|
+
};
|
|
187
|
+
for (const name of ['copilot', 'openai', 'anthropic']) out.providers[name] = route(name);
|
|
188
|
+
const who = out.mode === 'multi' ? person : 'local';
|
|
189
|
+
if (!who || (out.mode === 'multi' && who === 'local')) { out.signInNeeded = true; return out; }
|
|
190
|
+
try {
|
|
191
|
+
const mine = new Map(((await personSlots(who)).slots || []).map((x) => [x.id, x]));
|
|
192
|
+
for (const p of Object.values(out.providers)) {
|
|
193
|
+
if (!p.slot) continue;
|
|
194
|
+
const s = mine.get(p.slot);
|
|
195
|
+
p.state = s ? s.state : 'missing';
|
|
196
|
+
if (s && s.suffix) p.suffix = s.suffix;
|
|
197
|
+
if (s && s.label) p.label = s.label;
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
out.error = err.message;
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
/** Patch a provider from the UI/CLI; masked key echoes are dropped ("keep"). */
|
|
170
206
|
export async function patchProvider(name, patch = {}) {
|
|
171
207
|
if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
|
|
@@ -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))),
|
package/src/core/model-test.mjs
CHANGED
|
@@ -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) {
|
package/ui/public/app.js
CHANGED
|
@@ -773,7 +773,7 @@ function paintBudget() {
|
|
|
773
773
|
topAmt.classList.toggle('over', !!b.blocked);
|
|
774
774
|
}
|
|
775
775
|
applyBudgetToNewView();
|
|
776
|
-
if (currentView() === 'settings') paintBudgetReadout();
|
|
776
|
+
if (currentView() === 'settings' && currentSettingsTab === 'runs') paintBudgetReadout();
|
|
777
777
|
repaintCostBanners();
|
|
778
778
|
}
|
|
779
779
|
|
|
@@ -1066,7 +1066,7 @@ function handleServerMessage(msg) {
|
|
|
1066
1066
|
if (currentView() === 'projects') paintProjectPolicyCells(true);
|
|
1067
1067
|
if (currentView() === 'workspaces') paintWsPolicyLines(true);
|
|
1068
1068
|
if (currentView() === 'team-policy' && !tpState.editing) loadTeamPolicyView();
|
|
1069
|
-
if (currentView() === 'settings') paintTeamCapsReadout(true);
|
|
1069
|
+
if (currentView() === 'settings' && currentSettingsTab === 'runs') paintTeamCapsReadout(true);
|
|
1070
1070
|
if (currentView() === 'settings' && currentSettingsTab === 'plugins') paintPluginsPolicy(true);
|
|
1071
1071
|
if (currentView() === 'new') schedulePolicyLine();
|
|
1072
1072
|
return;
|
|
@@ -10953,7 +10953,7 @@ try {
|
|
|
10953
10953
|
if (mq && typeof mq.addEventListener === 'function') mq.addEventListener('change', () => applyTheme(document.documentElement.dataset.theme));
|
|
10954
10954
|
} catch { /* no media queries here */ }
|
|
10955
10955
|
|
|
10956
|
-
// Settings ›
|
|
10956
|
+
// Settings › Runs › Scheduled runs: the defaults a new schedule inherits.
|
|
10957
10957
|
function setSchedDefaultsMsg(text, kind) { setHintMsg('schedDefaultsMsg', text, kind); }
|
|
10958
10958
|
function paintScheduleSettings(data) {
|
|
10959
10959
|
const d = data && data.schedule;
|
|
@@ -12702,6 +12702,7 @@ async function loadModelsView() {
|
|
|
12702
12702
|
const pdata = await safeJson(pres);
|
|
12703
12703
|
mvState.providers = pres.ok ? pdata : null;
|
|
12704
12704
|
renderModelsViewBody();
|
|
12705
|
+
void paintHelperModelCards();
|
|
12705
12706
|
// "+ Add model…" from a picker on another page: the dialog opens once the catalog is here.
|
|
12706
12707
|
if (mvState.openEditorOnLoad) { mvState.openEditorOnLoad = false; openModelEditorDialog(); }
|
|
12707
12708
|
// Copilot's models feed the editor's upstream-id datalist; fetched in the
|
|
@@ -12716,6 +12717,18 @@ async function loadModelsView() {
|
|
|
12716
12717
|
}
|
|
12717
12718
|
}
|
|
12718
12719
|
|
|
12720
|
+
// Settings › Models › Title generation + Auto workflow model: both pickers list the catalog, so they
|
|
12721
|
+
// repaint whenever it loads — a model added a moment ago is selectable without leaving the tab.
|
|
12722
|
+
async function paintHelperModelCards() {
|
|
12723
|
+
try {
|
|
12724
|
+
const res = await fetch('/api/settings');
|
|
12725
|
+
if (!res.ok) return;
|
|
12726
|
+
const data = await safeJson(res);
|
|
12727
|
+
await paintTitleModelSettings(data);
|
|
12728
|
+
await paintAutoModelSettings(data);
|
|
12729
|
+
} catch { /* the cards keep their last paint */ }
|
|
12730
|
+
}
|
|
12731
|
+
|
|
12719
12732
|
/** The Providers tab: the same card, on a page of its own (§8.1). */
|
|
12720
12733
|
async function loadProvidersView() {
|
|
12721
12734
|
if (!el.providersList) return;
|
|
@@ -13886,7 +13899,7 @@ if (runListEl) {
|
|
|
13886
13899
|
if (runId) confirmCostOverride(runId, overrideBtn);
|
|
13887
13900
|
return;
|
|
13888
13901
|
}
|
|
13889
|
-
if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings'; return; }
|
|
13902
|
+
if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings/runs'; return; }
|
|
13890
13903
|
// Team-cap banner (team-policy design board 9): continue past, or open the page.
|
|
13891
13904
|
const pastBtn = e.target.closest && e.target.closest('.cb-past-team-cap');
|
|
13892
13905
|
if (pastBtn) {
|
|
@@ -14575,7 +14588,7 @@ if (tpSection) tpSection.addEventListener('click', async (e) => {
|
|
|
14575
14588
|
await handlePolicyPluginClick(e);
|
|
14576
14589
|
});
|
|
14577
14590
|
|
|
14578
|
-
// Settings › Budget (board 7): each home's caps as a readout, the tightest as a chip on the labels.
|
|
14591
|
+
// Settings › Runs › Budget (board 7): each home's caps as a readout, the tightest as a chip on the labels.
|
|
14579
14592
|
async function paintTeamCapsReadout(force = false) {
|
|
14580
14593
|
if (!el.teamCapsReadout) return;
|
|
14581
14594
|
const data = await loadTpScopes({ force });
|
|
@@ -17377,7 +17390,7 @@ function paintHdBanners(screen, record, data) {
|
|
|
17377
17390
|
{ pauseReason, pauseDetail, pipelineId: record.id, totalCostUsd: st.totalCostUsd },
|
|
17378
17391
|
{ budget: budgetState.budget || {}, fmt: { usd: fmtUsd, usd4: fmtUsd4, duration: fmtDuration, estTitle } });
|
|
17379
17392
|
const settingsBtn = banner.querySelector('.cb-settings');
|
|
17380
|
-
if (settingsBtn) settingsBtn.addEventListener('click', () => { location.hash = 'settings'; });
|
|
17393
|
+
if (settingsBtn) settingsBtn.addEventListener('click', () => { location.hash = 'settings/runs'; });
|
|
17381
17394
|
const overrideBtn = banner.querySelector('.cb-override');
|
|
17382
17395
|
if (overrideBtn) {
|
|
17383
17396
|
overrideBtn.addEventListener('click', () => {
|
|
@@ -22537,7 +22550,7 @@ el.runDetail?.addEventListener('click', (e) => {
|
|
|
22537
22550
|
if (!r) return;
|
|
22538
22551
|
const override = e.target.closest && e.target.closest('.cb-override');
|
|
22539
22552
|
if (override) { confirmCostOverride(r.runId, override); return; } // async, fire-and-forget
|
|
22540
|
-
if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings'; return; }
|
|
22553
|
+
if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings/runs'; return; }
|
|
22541
22554
|
const past = e.target.closest && e.target.closest('.cb-past-team-cap');
|
|
22542
22555
|
if (past) { confirmPastTeamCap(r.runId, past); return; } // team-policy board 9
|
|
22543
22556
|
if (e.target.closest && e.target.closest('.cb-policy-open')) { location.hash = 'team-policy'; return; }
|
|
@@ -23654,11 +23667,11 @@ const VIEW_MIN_LEVEL = Object.freeze({
|
|
|
23654
23667
|
'team-metrics': 'expert', 'team-policy': 'expert', agents: 'expert', scripts: 'expert',
|
|
23655
23668
|
schedules: 'advanced',
|
|
23656
23669
|
});
|
|
23657
|
-
const SETTINGS_TAB_MIN_LEVEL = Object.freeze({ guardrails: 'advanced', plugins: 'advanced', memory: 'advanced', models: 'expert', providers: 'expert' });
|
|
23670
|
+
const SETTINGS_TAB_MIN_LEVEL = Object.freeze({ ask: 'advanced', guardrails: 'advanced', plugins: 'advanced', memory: 'advanced', models: 'expert', providers: 'expert' });
|
|
23658
23671
|
const VIEW_TITLES = Object.freeze({
|
|
23659
23672
|
stats: 'Statistics', composer: 'Workflow Composer', workspaces: 'Workspaces', 'workspace-create': 'Workspaces',
|
|
23660
23673
|
'agent-create': 'Create agent', 'team-metrics': 'Team metrics', 'team-policy': 'Team policy', agents: 'Agents', scripts: 'Scripts',
|
|
23661
|
-
guardrails: 'Guardrails', plugins: 'Plugins', memory: 'Memory', models: 'Models', providers: 'Providers',
|
|
23674
|
+
guardrails: 'Guardrails', plugins: 'Plugins', memory: 'Memory', models: 'Models', providers: 'Providers', ask: 'Ask Worca',
|
|
23662
23675
|
schedules: 'Schedules',
|
|
23663
23676
|
});
|
|
23664
23677
|
function pageMinLevel() {
|
|
@@ -23715,7 +23728,10 @@ document.addEventListener('worca:level', () => {
|
|
|
23715
23728
|
// The tab is the Settings view's hash param; a guardrail deep link nests its id
|
|
23716
23729
|
// behind it (#settings/guardrails/<id>). parseHash splits on the FIRST '/' only,
|
|
23717
23730
|
// so that is view 'settings', param 'guardrails/<id>' — no parseHash change.
|
|
23718
|
-
const SETTINGS_TABS = ['general', '
|
|
23731
|
+
const SETTINGS_TABS = ['general', 'runs', 'ask', 'guardrails', 'memory', 'plugins', 'models', 'providers'];
|
|
23732
|
+
// The tabs whose cards GET /api/settings paints (loadSettings paints every card, wherever it sits).
|
|
23733
|
+
// Models is not one: loadModelsView repaints its two helper-model cards with the catalog.
|
|
23734
|
+
const SETTINGS_FORM_TABS = ['general', 'runs', 'ask'];
|
|
23719
23735
|
const settingsPanes = $$('[data-view="settings"] .settings-pane');
|
|
23720
23736
|
// Old top-level hashes keep working. The hashchange listener DROPS any view it
|
|
23721
23737
|
// does not know, so without this map a bookmark or an old in-app link would
|
|
@@ -23965,7 +23981,7 @@ function showSettingsTab(param = '') {
|
|
|
23965
23981
|
// request, and re-entry refetches (which is what lets grvExitWizard's
|
|
23966
23982
|
// '#settings/guardrails/<id>' -> '#settings/guardrails' hop reset the wizard).
|
|
23967
23983
|
paintLevelBanner();
|
|
23968
|
-
if (tab
|
|
23984
|
+
if (SETTINGS_FORM_TABS.includes(tab)) loadSettings();
|
|
23969
23985
|
if (tab === 'guardrails') loadGuardrailsView(sub);
|
|
23970
23986
|
if (tab === 'models') loadModelsView(sub);
|
|
23971
23987
|
if (tab === 'providers') loadProvidersView();
|
package/ui/public/ask-model.mjs
CHANGED
|
@@ -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;
|
package/ui/public/ask-panel.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
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
|
|
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
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
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
|
|
3702
|
-
|
|
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
|