@worca/app 1.1.1 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,12 @@
11
11
  // contextMaxBytesPerFile — §5.4 per-source-file inlining cap.
12
12
  // contextMaxBytesTotal — §5.4 total memory budget.
13
13
  // skillMount — §5.6 'copy' (default) | 'symlink' (opt-in).
14
+ // debugSpawnEnabled — the stored spawn-diagnostics preference (a UI checkbox).
15
+ // claude-runner.mjs reads it fresh on every spawn through
16
+ // effectiveDebugSpawn(), so it applies to the UI server AND
17
+ // to CLI runs with no restart. A NON-EMPTY WORCA_DEBUG_SPAWN
18
+ // in the process environment overrides it (power-user
19
+ // override); this module never writes process.env.
14
20
  // pipelineCostLimitUsd — per-pipeline lifetime USD spend cap; unset = no limit.
15
21
  // totalCostLimitUsd — windowed all-pipelines USD spend cap; unset = no limit.
16
22
  // costLimitResetPeriod — total-budget window, 'weekly' | 'monthly' (default).
@@ -42,7 +48,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
42
48
  import { join, resolve } from 'node:path';
43
49
  import { homedir } from 'node:os';
44
50
  import { randomBytes } from 'node:crypto';
45
- import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
51
+ import { EFFORTS, isReservedModelEnvKey, assertModelCost, envFlag } from './model-env.mjs';
46
52
 
47
53
  /**
48
54
  * The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
@@ -526,6 +532,75 @@ export async function setCostLimitResetPeriod(input) {
526
532
  return { costLimitResetPeriod: costLimitResetPeriod() };
527
533
  }
528
534
 
535
+ // ── The keys POST /api/settings understands ──────────────────────────────────
536
+ // The route keeps a legacy contract: a body naming NONE of these clears root
537
+ // (test/settings-projects-root.test.mjs "a bodyless POST resets root"). Every
538
+ // setter's key is listed HERE, beside the setters, so a new key cannot forget
539
+ // to join a hand-maintained exclusion list in the route and wipe the root on
540
+ // its first save.
541
+ export const SETTINGS_POST_KEYS = Object.freeze([
542
+ 'root', 'projectsRoot', 'chat',
543
+ 'pipelineCostLimitUsd', 'totalCostLimitUsd', 'costLimitResetPeriod',
544
+ 'askMaxTurns', 'askMaxBudgetUsd',
545
+ 'debugSpawnEnabled',
546
+ ]);
547
+
548
+ // ── Spawn-debug diagnostics toggle (the stored side of WORCA_DEBUG_SPAWN) ────
549
+ // Like every other stored setting (skillMount, the cost caps, the ask caps) this
550
+ // is READ AT USE TIME: claude-runner.mjs#debugSpawnEnabled calls
551
+ // effectiveDebugSpawn() on every spawn, so a UI save reaches the very next spawn
552
+ // in this process and in any CLI process with no restart, and nothing here ever
553
+ // mutates process.env (a runtime env write would leak an explicit
554
+ // WORCA_DEBUG_SPAWN=0 into every inherited child env and break the runner's
555
+ // "OFF ⇒ byte-identical spawn env" invariant).
556
+ export const DEFAULT_DEBUG_SPAWN_ENABLED = false;
557
+
558
+ const isBool = (v) => typeof v === 'boolean';
559
+
560
+ /** @throws {Error} unless `input` is a boolean (the route and the setter share this). */
561
+ export function assertDebugSpawnInput(input) {
562
+ if (!isBool(input)) throw new Error('debugSpawnEnabled must be true or false');
563
+ }
564
+
565
+ /** STORED spawn-debug preference: boolean, default OFF. Invalid stored value ⇒ OFF (loudly). */
566
+ export function debugSpawnEnabled() {
567
+ const v = readSettings().debugSpawnEnabled;
568
+ if (v === undefined) return DEFAULT_DEBUG_SPAWN_ENABLED;
569
+ if (isBool(v)) return v;
570
+ console.warn(`[worca] invalid debugSpawnEnabled ${JSON.stringify(v)} — using the default (${DEFAULT_DEBUG_SPAWN_ENABLED})`);
571
+ return DEFAULT_DEBUG_SPAWN_ENABLED;
572
+ }
573
+
574
+ /**
575
+ * What the runner will actually do on the next spawn, and why. ONE precedence
576
+ * rule, shared by the runner gate and the settings API: a NON-EMPTY
577
+ * WORCA_DEBUG_SPAWN in the environment wins (parsed with the envFlag rule, so an
578
+ * exported "0"/"false" is an explicit OFF override), otherwise the stored
579
+ * preference applies. An empty export (`export WORCA_DEBUG_SPAWN=` in a profile
580
+ * or a dotenv template) is NOT an override — the runner would read it as OFF
581
+ * while the UI showed the stored value checked, with nothing explaining why.
582
+ * @returns {{enabled: boolean, source: 'env'|'settings'}}
583
+ */
584
+ export function effectiveDebugSpawn() {
585
+ const v = process.env.WORCA_DEBUG_SPAWN;
586
+ if (v !== undefined && v !== '') return { enabled: envFlag('WORCA_DEBUG_SPAWN'), source: 'env' };
587
+ return { enabled: debugSpawnEnabled(), source: 'settings' };
588
+ }
589
+
590
+ /**
591
+ * Persist the preference. Nothing else: the runner reads it back per spawn, so
592
+ * the change is live everywhere without touching this process's environment.
593
+ * @throws {Error} unless `input` is a boolean.
594
+ */
595
+ export async function setDebugSpawnEnabled(input) {
596
+ assertDebugSpawnInput(input);
597
+ const settings = readSettings();
598
+ if (input === DEFAULT_DEBUG_SPAWN_ENABLED) delete settings.debugSpawnEnabled;
599
+ else settings.debugSpawnEnabled = input;
600
+ await persistSettings(settings);
601
+ return { debugSpawnEnabled: debugSpawnEnabled() };
602
+ }
603
+
529
604
  // ---------------------------------------------------------------------------
530
605
  // Global model catalog (configurable-models-design.md §4.1). Stored entries are
531
606
  // MINIMAL — label only when it differs from id, efforts only when a proper
package/ui/public/app.js CHANGED
@@ -626,6 +626,12 @@ function handleServerMessage(msg) {
626
626
  refreshBudget();
627
627
  return;
628
628
  }
629
+ // Another tab saved a Settings card: repaint ours from the server so a stale
630
+ // checkbox/field cannot be "saved" back over the change.
631
+ if (msg.type === 'settings-changed') {
632
+ loadSettings();
633
+ return;
634
+ }
629
635
  if (msg.type === 'projects-changed') {
630
636
  refreshAllCounts();
631
637
  if (currentView() === 'projects') loadProjectsView();
@@ -770,6 +776,7 @@ function onHello(msg) {
770
776
  kind: r0.kind || 'run',
771
777
  pipelineId: r0.pipelineId || null,
772
778
  pauseReason: r0.pauseReason || null,
779
+ pauseDetail: r0.pauseDetail || null,
773
780
  workspaceId: r0.workspaceId || undefined,
774
781
  projectNames: Array.isArray(r0.projectNames) && r0.projectNames.length ? r0.projectNames : undefined,
775
782
  });
@@ -898,6 +905,7 @@ function nowHMS() {
898
905
  function makeRun({
899
906
  runId, title, projectDir, status = 'running', startedAt, local = false,
900
907
  pendingQuestion = null, kind = 'run', pipelineId = null, pauseReason = null,
908
+ pauseDetail = null,
901
909
  workspaceId = undefined, workspaceName = undefined, projectNames = null,
902
910
  }) {
903
911
  return {
@@ -912,6 +920,7 @@ function makeRun({
912
920
  pipelineId, // matches a History row id once persisted; used to hide lingerers from History
913
921
  pauseReason, // why it paused, or null — ANY orchestrator pause code rides here
914
922
  // (e.g. 'usage_limit'); only the cost pair renders a cost banner
923
+ pauseDetail, // the human-readable cause behind an 'error' pause, or null
915
924
  workspaceId,
916
925
  workspaceName,
917
926
  // Stable ordering key: assigned once per runId, never bumped by activity
@@ -4054,7 +4063,8 @@ function renderGateBody(r, panel, pq) {
4054
4063
  }
4055
4064
 
4056
4065
  // Recovery prompt: a node hit a recoverable error (auth / rate-limit / quota /
4057
- // network). Show the cause and let the user fix it then Retry, or Abort the run.
4066
+ // network). Show the cause and let the user fix it then Retry, or park the run
4067
+ // with Pause run — nothing here ends a run, only Stop does.
4058
4068
  function renderRecoveryBody(r, panel, pq) {
4059
4069
  const rec = pq.recovery || {};
4060
4070
  const intro = document.createElement('div');
@@ -4062,7 +4072,7 @@ function renderRecoveryBody(r, panel, pq) {
4062
4072
  const hint = rec.cls === 'auth'
4063
4073
  ? 'Re-authenticate (e.g. run `claude setup-token` or `/login`), then Retry.'
4064
4074
  : 'Fix the problem (wait out a limit, restore connectivity, top up credit), then Retry.';
4065
- intro.textContent = `This step could not reach the model. ${hint}`;
4075
+ intro.textContent = `This step could not reach the model. ${hint} Or pause the run and come back later — only Stop ends it.`;
4066
4076
  panel.appendChild(intro);
4067
4077
 
4068
4078
  if (rec.message) {
@@ -4074,15 +4084,21 @@ function renderRecoveryBody(r, panel, pq) {
4074
4084
 
4075
4085
  const foot = document.createElement('div');
4076
4086
  foot.className = 'qpanel-foot gate-actions';
4077
- const abort = document.createElement('button');
4078
- abort.type = 'button';
4079
- abort.className = 'btn recovery-abort';
4080
- abort.textContent = 'Abort run';
4087
+ // The give-up option comes from the failure policy's row (options ride the
4088
+ // prompt): 'pause' parks the run, 'abort' ends it. Older payloads carry none.
4089
+ const giveUp = (Array.isArray(rec.options) && rec.options.find((o) => o && o.id !== 'retry')) || { id: 'pause' };
4090
+ const pause = document.createElement('button');
4091
+ pause.type = 'button';
4092
+ pause.className = giveUp.id === 'abort' ? 'btn recovery-abort' : 'btn recovery-pause';
4093
+ pause.textContent = giveUp.id === 'abort' ? 'Abort run' : 'Pause run';
4094
+ pause.title = giveUp.id === 'abort'
4095
+ ? 'End the run here with this error. The worktree is torn down.'
4096
+ : 'Park the run here with this error as the reason. Nothing is discarded — Resume retries the step later.';
4081
4097
  const retry = document.createElement('button');
4082
4098
  retry.type = 'button';
4083
4099
  retry.className = 'btn btn-primary recovery-retry';
4084
4100
  retry.textContent = 'Retry';
4085
- foot.append(abort, retry);
4101
+ foot.append(pause, retry);
4086
4102
  panel.appendChild(foot);
4087
4103
  }
4088
4104
 
@@ -4269,6 +4285,9 @@ function onDone(r, msg) {
4269
4285
  // unconditionally so a later reasonless done clears it, matching the server's
4270
4286
  // own entry.pauseReason reset in wireRun.
4271
4287
  r.pauseReason = msg.reason || null;
4288
+ // An 'error' pause also carries the cause it parked on; assigned unconditionally
4289
+ // for the same reason as the code above — a later reasonless done must clear it.
4290
+ r.pauseDetail = msg.detail || null;
4272
4291
  finishRun(r, msg.status || 'done');
4273
4292
  // Nothing else picks up the FINAL spend delta: a non-cost `done` broadcasts no
4274
4293
  // budget-changed, and startBudgetTick refetches only while runs are live. Without
@@ -7682,9 +7701,11 @@ async function loadSettings() {
7682
7701
  paintSettings(data);
7683
7702
  paintBudgetSettings(data);
7684
7703
  paintAskSettings(data);
7704
+ paintDebugSpawnSettings(data);
7685
7705
  paintBudgetReadout();
7686
7706
  refreshBudget();
7687
7707
  paintChatSettings(data.chat);
7708
+ loadAskHistory();
7688
7709
  setSettingsMsg('');
7689
7710
  } catch (e) { setSettingsMsg(e.message, 'err'); }
7690
7711
  }
@@ -7962,10 +7983,27 @@ if (el.budgetReset) {
7962
7983
  }
7963
7984
 
7964
7985
  // ---- Ask Worca limits card (budget-card pattern above) ---------------------
7965
- function setAskLimitsMsg(text, kind) {
7966
- const n = document.getElementById('askLimitsMsg');
7986
+ // Shared by the small Settings cards (Ask Worca, Spawn diagnostics): one hint
7987
+ // setter and one "POST /api/settings → parse → paint or show the error" routine,
7988
+ // so a fix to the fetch/parse/error path lands in every card at once.
7989
+ function setHintMsg(id, text, kind) {
7990
+ const n = document.getElementById(id);
7967
7991
  if (n) { n.textContent = text || ''; n.className = `hint${kind ? ` ${kind}` : ''}`; }
7968
7992
  }
7993
+ async function postSettingsCard(body, { setMsg, paint, savedText = 'Saved.' }) {
7994
+ setMsg('');
7995
+ let res;
7996
+ try {
7997
+ res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
7998
+ } catch (e) { setMsg(e.message || 'network error', 'err'); return; }
7999
+ const data = await safeJson(res);
8000
+ if (!res.ok) { setMsg(data.error || `HTTP ${res.status}`, 'err'); return; }
8001
+ // A 2xx with an unparsable body yields {} — leave the card as the user set it
8002
+ // rather than painting every field as "unset".
8003
+ if (Object.keys(data).length) paint(data);
8004
+ setMsg(savedText);
8005
+ }
8006
+ function setAskLimitsMsg(text, kind) { setHintMsg('askLimitsMsg', text, kind); }
7969
8007
  function paintAskSettings(data) {
7970
8008
  const turns = document.getElementById('askMaxTurns');
7971
8009
  const budget = document.getElementById('askMaxBudgetUsd');
@@ -7976,17 +8014,8 @@ function paintAskSettings(data) {
7976
8014
  budget.disabled = noCap.checked;
7977
8015
  budget.value = data.askMaxBudgetUsd == null ? '' : String(data.askMaxBudgetUsd);
7978
8016
  }
7979
- async function postAskLimits(body) {
7980
- setAskLimitsMsg('');
7981
- let res = null;
7982
- try {
7983
- res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
7984
- } catch { setAskLimitsMsg('network error', 'err'); return; }
7985
- let data = null;
7986
- try { data = await res.json(); } catch { data = null; }
7987
- if (!res.ok) { setAskLimitsMsg((data && data.error) || `save failed (${res.status})`, 'err'); return; }
7988
- paintAskSettings(data || {});
7989
- setAskLimitsMsg('Saved.');
8017
+ function postAskLimits(body) {
8018
+ return postSettingsCard(body, { setMsg: setAskLimitsMsg, paint: paintAskSettings });
7990
8019
  }
7991
8020
  function saveAskLimits() {
7992
8021
  const turnsRaw = document.getElementById('askMaxTurns').value.trim();
@@ -8014,6 +8043,108 @@ document.getElementById('askNoCap')?.addEventListener('change', () => {
8014
8043
  if (budget) budget.disabled = document.getElementById('askNoCap').checked;
8015
8044
  });
8016
8045
 
8046
+ // ---- Ask Worca chat history (same card, same hint pattern) -----------------
8047
+ // The counts line paints with the settings view; the destructive button refetches
8048
+ // GET /api/ask/history so the dialog quotes what is about to go, then one bulk
8049
+ // DELETE /api/ask/threads. The server broadcasts ask-history-cleared afterwards
8050
+ // — the ask panel resets itself off that frame, nothing to do here.
8051
+ function setAskHistoryMsg(text, kind) {
8052
+ const n = document.getElementById('askHistoryMsg');
8053
+ if (n) { n.textContent = text || ''; n.className = `hint${kind ? ` ${kind}` : ''}`; }
8054
+ }
8055
+ function askHistoryCount(n, one, many = `${one}s`) {
8056
+ return `${n} ${n === 1 ? one : many}`;
8057
+ }
8058
+ function normalizeAskHistory(data) {
8059
+ const num = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
8060
+ return { threads: num(data?.threads), worktrees: num(data?.worktrees), attachments: num(data?.attachments), inFlight: num(data?.inFlight) };
8061
+ }
8062
+ function paintAskHistory(counts) {
8063
+ const line = document.getElementById('askHistoryCounts');
8064
+ const btn = document.getElementById('askHistoryDelete');
8065
+ if (!line || !btn) return;
8066
+ line.textContent = counts.threads
8067
+ ? `${askHistoryCount(counts.threads, 'chat')} · ${askHistoryCount(counts.worktrees, 'worktree')}`
8068
+ : 'No saved chats.';
8069
+ btn.disabled = counts.threads === 0;
8070
+ }
8071
+ async function fetchAskHistory() {
8072
+ const res = await fetch('/api/ask/history');
8073
+ const data = await safeJson(res);
8074
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
8075
+ return normalizeAskHistory(data);
8076
+ }
8077
+ async function loadAskHistory() {
8078
+ if (!document.getElementById('askHistoryCounts')) return;
8079
+ try { paintAskHistory(await fetchAskHistory()); } catch (e) { setAskHistoryMsg(e.message, 'err'); }
8080
+ }
8081
+ // One bullet per non-zero count; the in-progress line only when a turn is live.
8082
+ function askHistoryConfirmMessage(c) {
8083
+ const lines = ['This permanently deletes all Ask Worca chat history:'];
8084
+ if (c.threads) lines.push(`• ${askHistoryCount(c.threads, 'chat thread')} and ${c.threads === 1 ? 'its transcript' : 'their transcripts'}`);
8085
+ if (c.worktrees) {
8086
+ lines.push(`• ${askHistoryCount(c.worktrees, 'git worktree')} checked out for ${c.threads === 1 ? 'that chat' : 'those chats'} (removed from ${c.worktrees === 1 ? 'its source repo' : 'their source repos'})`);
8087
+ }
8088
+ if (c.attachments) lines.push(`• ${askHistoryCount(c.attachments, 'attachment')}`);
8089
+ if (c.inFlight) lines.push(`• ${askHistoryCount(c.inFlight, 'chat')} currently in progress will be stopped`);
8090
+ lines.push('Runs started from these chats are not affected. This cannot be undone.');
8091
+ return lines.join('\n');
8092
+ }
8093
+ async function deleteAskHistory() {
8094
+ setAskHistoryMsg('');
8095
+ let counts;
8096
+ try { counts = await fetchAskHistory(); } catch (e) { setAskHistoryMsg(e.message, 'err'); return; }
8097
+ paintAskHistory(counts);
8098
+ if (!counts.threads) return; // emptied meanwhile — nothing to confirm
8099
+ const ok = await confirmModal({
8100
+ title: 'Delete all chat history?',
8101
+ message: askHistoryConfirmMessage(counts),
8102
+ confirmLabel: 'Delete everything',
8103
+ danger: true,
8104
+ });
8105
+ if (!ok) return;
8106
+ let res = null;
8107
+ try {
8108
+ res = await fetch('/api/ask/threads', { method: 'DELETE' });
8109
+ } catch { setAskHistoryMsg('network error', 'err'); return; }
8110
+ const data = await safeJson(res);
8111
+ if (!res.ok) { setAskHistoryMsg(data.error || `HTTP ${res.status}`, 'err'); await loadAskHistory(); return; }
8112
+ const removed = normalizeAskHistory(data.removed);
8113
+ const failed = Array.isArray(data.failed) ? data.failed.length : 0;
8114
+ const summary = `Deleted ${askHistoryCount(removed.threads, 'chat')} and ${askHistoryCount(removed.worktrees, 'worktree')}.`;
8115
+ if (failed) setAskHistoryMsg(`${summary} ${askHistoryCount(failed, 'chat')} could not be removed.`, 'err');
8116
+ else setAskHistoryMsg(summary);
8117
+ await loadAskHistory();
8118
+ }
8119
+ document.getElementById('askHistoryDelete')?.addEventListener('click', deleteAskHistory);
8120
+
8121
+ // ---- Spawn-debug diagnostics card (shares the Ask card's helpers above) ----
8122
+ function setDebugSpawnMsg(text, kind) { setHintMsg('debugSpawnMsg', text, kind); }
8123
+ // The checkbox is the STORED preference; the note says when the environment
8124
+ // overrides it (a non-empty WORCA_DEBUG_SPAWN at launch), so an operator never
8125
+ // sees an unchecked box while diagnostics are flowing — or the reverse.
8126
+ function paintDebugSpawnSettings(data) {
8127
+ const cb = document.getElementById('debugSpawnEnabled');
8128
+ if (!cb) return;
8129
+ cb.checked = !!data.debugSpawnEnabled;
8130
+ const eff = data.debugSpawnEffective;
8131
+ const envOverride = !!eff && eff.source === 'env';
8132
+ setHintMsg('debugSpawnEnvNote', envOverride
8133
+ ? `WORCA_DEBUG_SPAWN is set in the environment: diagnostics are ${eff.enabled ? 'ON' : 'OFF'} regardless of this setting.`
8134
+ : '', envOverride ? 'warn' : '');
8135
+ }
8136
+ function postDebugSpawn(body) {
8137
+ return postSettingsCard(body, {
8138
+ setMsg: setDebugSpawnMsg, paint: paintDebugSpawnSettings,
8139
+ savedText: 'Saved. Applies to the next spawn — no restart needed.',
8140
+ });
8141
+ }
8142
+ function saveDebugSpawn() {
8143
+ postDebugSpawn({ debugSpawnEnabled: document.getElementById('debugSpawnEnabled').checked });
8144
+ }
8145
+ document.getElementById('debugSpawnSave')?.addEventListener('click', saveDebugSpawn);
8146
+ document.getElementById('debugSpawnReset')?.addEventListener('click', () => postDebugSpawn({ debugSpawnEnabled: false }));
8147
+
8017
8148
  // Browse… for the projects root: native OS dialog, in-app modal fallback —
8018
8149
  // the same two endpoints the add-project Browse button uses (app.js:3793).
8019
8150
  if (el.settingsProjectsRootBrowse) {
@@ -9435,7 +9566,7 @@ if (runListEl) {
9435
9566
 
9436
9567
  // qpanel actions. Resolve the run per-card via the enclosing .run-card so
9437
9568
  // delegation works for any dynamically-built card.
9438
- const qbtn = e.target.closest && e.target.closest('.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-abort');
9569
+ const qbtn = e.target.closest && e.target.closest('.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-pause, .qpanel .recovery-abort');
9439
9570
  if (qbtn) {
9440
9571
  const card = qbtn.closest('.run-card');
9441
9572
  const runId = card && card.dataset.runId;
@@ -9444,6 +9575,7 @@ if (runListEl) {
9444
9575
  if (qbtn.classList.contains('gate-continue')) postAnswer(r, { decision: 'continue' });
9445
9576
  else if (qbtn.classList.contains('gate-another')) postAnswer(r, { decision: 'another' });
9446
9577
  else if (qbtn.classList.contains('recovery-retry')) postAnswer(r, { decision: 'retry' });
9578
+ else if (qbtn.classList.contains('recovery-pause')) postAnswer(r, { decision: 'pause' });
9447
9579
  else if (qbtn.classList.contains('recovery-abort')) postAnswer(r, { decision: 'abort' });
9448
9580
  else submitAnswer(r, qbtn.closest('.qpanel'));
9449
9581
  }
@@ -10288,12 +10420,19 @@ const PAUSED_STATUSES = ['paused', 'pausing', 'interrupted'];
10288
10420
  // Disable a history Resume button while a total-budget pause is still blocked by
10289
10421
  // the current window. Shared by setupHdActions (first paint) and
10290
10422
  // refreshHistResumeGating (every later budget change).
10291
- function applyHistResumeGate(btn, pauseReason, budget) {
10423
+ function applyHistResumeGate(btn, pauseReason, budget, pauseDetail = '') {
10292
10424
  const totalBlocked = pauseReason === 'cost_total' && !!(budget && budget.blocked);
10293
10425
  btn.disabled = totalBlocked;
10294
- btn.title = totalBlocked
10295
- ? `Total budget reached — blocked until ${fmtResetAtLocal(budget.windowEndMs)} or a higher total limit`
10296
- : '';
10426
+ if (totalBlocked) {
10427
+ btn.title = `Total budget reached — blocked until ${fmtResetAtLocal(budget.windowEndMs)} or a higher total limit`;
10428
+ } else if (pauseReason === 'error' || pauseReason === 'recoverable') {
10429
+ // An error pause is never gated — the tooltip carries the cause instead.
10430
+ btn.title = pauseReason === 'recoverable'
10431
+ ? `Paused on a recoverable error${pauseDetail ? `: ${pauseDetail}` : ''} — resume once it clears`
10432
+ : `Paused after an error${pauseDetail ? `: ${pauseDetail}` : ''} — fix the cause, then resume`;
10433
+ } else {
10434
+ btn.title = '';
10435
+ }
10297
10436
  }
10298
10437
 
10299
10438
  // Re-gate the mounted history Resume button from the dataset.pauseReason stamp
@@ -10312,7 +10451,7 @@ function refreshHistResumeGating() {
10312
10451
  // otherwise a `cost_total` block that lands later leaves the button enabled and
10313
10452
  // the user clicks into a guaranteed 403. Re-gate, then restore the D3 error
10314
10453
  // title when gating did not take the button away.
10315
- applyHistResumeGate(btn, root.dataset.pauseReason || '', budgetState.budget);
10454
+ applyHistResumeGate(btn, root.dataset.pauseReason || '', budgetState.budget, root.dataset.pauseDetail || '');
10316
10455
  if (btn.dataset.resumeState === 'error' && btn.dataset.resumeError && !btn.disabled) {
10317
10456
  btn.title = btn.dataset.resumeError;
10318
10457
  }
@@ -10434,14 +10573,21 @@ function buildHistCard(projectDir, p, ghAvailable = false) {
10434
10573
  // Pause note. Resume + its budget gating live on the detail page now; the
10435
10574
  // dataset stamp survives for parity/debugging.
10436
10575
  const pauseReason = typeof p.pauseReason === 'string' ? p.pauseReason : '';
10576
+ const pauseDetail = typeof p.pauseDetail === 'string' ? p.pauseDetail : '';
10437
10577
  if (pauseReason) node.dataset.pauseReason = pauseReason;
10578
+ if (pauseDetail) node.dataset.pauseDetail = pauseDetail;
10438
10579
  const noteEl = node.querySelector('.hist-pausenote');
10439
- const costPaused = PAUSED_STATUSES.includes(String(p.status || '').toLowerCase())
10440
- && pauseReason.startsWith('cost_');
10441
- noteEl.hidden = !costPaused;
10580
+ const parked = PAUSED_STATUSES.includes(String(p.status || '').toLowerCase());
10581
+ const costPaused = parked && pauseReason.startsWith('cost_');
10582
+ const errorPaused = parked && (pauseReason === 'error' || pauseReason === 'recoverable');
10583
+ noteEl.hidden = !(costPaused || errorPaused);
10442
10584
  noteEl.textContent = costPaused
10443
- ? (pauseReason === 'cost_total' ? 'paused · total budget' : 'paused · cost limit') : '';
10585
+ ? (pauseReason === 'cost_total' ? 'paused · total budget' : 'paused · cost limit')
10586
+ : (errorPaused ? (pauseReason === 'recoverable' ? 'paused · recoverable' : 'paused · error') : '');
10587
+ // The cause is too long for the caption line — it rides as the tooltip.
10588
+ noteEl.title = errorPaused ? pauseDetail : '';
10444
10589
  noteEl.classList.toggle('total', costPaused && pauseReason === 'cost_total');
10590
+ noteEl.classList.toggle('error', errorPaused);
10445
10591
 
10446
10592
  renderRetainedWork(node, p); // badge only — the card has no banner node
10447
10593
  setupPrButton(node, projectDir, p, ghAvailable);
@@ -11466,14 +11612,48 @@ function hdRetainedFor(record, st) {
11466
11612
  return { retained: derived, provisional: true };
11467
11613
  }
11468
11614
 
11615
+ // Error-pause banner (D11): the cause, and the promise that nothing was discarded.
11616
+ function renderErrorPauseBanner(detail, reason = 'error') {
11617
+ const recoverable = reason === 'recoverable';
11618
+ const el = document.createElement('div');
11619
+ el.className = 'pause-error-banner';
11620
+ el.dataset.reason = reason;
11621
+ const b = document.createElement('b');
11622
+ b.textContent = recoverable ? 'Paused on a recoverable error' : 'Paused after an error';
11623
+ const text = document.createElement('div');
11624
+ text.className = 'peb-text';
11625
+ text.textContent = detail || 'The run hit an error it could not recover from.';
11626
+ const hint = document.createElement('div');
11627
+ hint.className = 'peb-hint';
11628
+ hint.textContent = recoverable
11629
+ ? 'The run could not reach the model and parked itself. Nothing was discarded — once the cause clears (re-login, connectivity, credit, a rate limit), Resume retries the step.'
11630
+ : 'Nothing was discarded: the worktree and the run position are kept. Fix the cause, then Resume.';
11631
+ el.append(b, text, hint);
11632
+ return el;
11633
+ }
11634
+
11469
11635
  function paintHdBanners(screen, record, data) {
11470
11636
  const st = data.state;
11471
11637
  const banners = screen.querySelector('.hd-banners');
11472
11638
 
11473
- // Cost-pause banner. pauseReason lives on LIST rows only (rowToState has none),
11474
- // so a deep link gets it late rebuild idempotently instead of once.
11475
- const pauseReason = typeof record.pauseReason === 'string' ? record.pauseReason : '';
11639
+ // Pause banners. The LIST row is authoritative, but a deep link has only the
11640
+ // stub until the row lands so fall back to the DETAIL payload, which now
11641
+ // carries both keys too. Rebuild idempotently instead of once.
11642
+ const pauseReason = typeof record.pauseReason === 'string' ? record.pauseReason
11643
+ : (typeof st.pauseReason === 'string' ? st.pauseReason : '');
11476
11644
  if (pauseReason) screen.dataset.pauseReason = pauseReason; else delete screen.dataset.pauseReason;
11645
+ const pauseDetail = typeof record.pauseDetail === 'string' ? record.pauseDetail
11646
+ : (typeof st.pauseDetail === 'string' ? st.pauseDetail : '');
11647
+ if (pauseDetail) screen.dataset.pauseDetail = pauseDetail; else delete screen.dataset.pauseDetail;
11648
+ // Keyed on the detail so a corrected cause replaces the banner instead of stacking.
11649
+ const wantErr = (pauseReason === 'error' || pauseReason === 'recoverable') && HD_RESUMABLE.has(String(st.status || '').toLowerCase());
11650
+ const oldErr = banners.querySelector('.pause-error-banner');
11651
+ if (oldErr && (!wantErr || oldErr.dataset.detail !== pauseDetail || oldErr.dataset.reason !== pauseReason)) oldErr.remove();
11652
+ if (wantErr && !banners.querySelector('.pause-error-banner')) {
11653
+ const errBanner = renderErrorPauseBanner(pauseDetail, pauseReason);
11654
+ errBanner.dataset.detail = pauseDetail;
11655
+ banners.prepend(errBanner);
11656
+ }
11477
11657
  // Rebuild the cost banner ONLY when the reason actually changed. An
11478
11658
  // unconditional remove+rebuild detaches the `.cb-override` button mid-flight:
11479
11659
  // that click awaits confirmModal then resumePipeline, and ANY paintHistory()
@@ -11585,7 +11765,7 @@ function setupHdActions(screen, record, data) {
11585
11765
  const resumeBtn = screen.querySelector('.hd-resume');
11586
11766
  if (HD_RESUMABLE.has(status) && st.resumable !== false) {
11587
11767
  resumeBtn.hidden = false;
11588
- applyHistResumeGate(resumeBtn, screen.dataset.pauseReason || '', budgetState.budget);
11768
+ applyHistResumeGate(resumeBtn, screen.dataset.pauseReason || '', budgetState.budget, screen.dataset.pauseDetail || '');
11589
11769
  resumeBtn.addEventListener('click', () => {
11590
11770
  const r = hdCurrentRecord(record); // never the load-time object
11591
11771
  resumePipeline(r, r.projectDir || null, resumeBtn);
@@ -13486,6 +13666,21 @@ function rdStateCopy(r, stepName) {
13486
13666
  // line and the banner above the graph never disagree.
13487
13667
  if (r.pauseReason === 'cost_pipeline') return 'Paused — pipeline cost limit reached.';
13488
13668
  if (r.pauseReason === 'cost_total') return 'Paused — total budget reached.';
13669
+ if (r.pauseReason === 'error') {
13670
+ const why = r.pauseDetail ? `: ${r.pauseDetail}` : '';
13671
+ return `Paused after an error${why}. Fix the cause, then Resume — the worktree and progress are kept.`;
13672
+ }
13673
+ if (r.pauseReason === 'recoverable') {
13674
+ const why = r.pauseDetail ? ` (${r.pauseDetail})` : '';
13675
+ return `Paused on a recoverable error${why}. Once it clears, Resume retries the step — the worktree and progress are kept.`;
13676
+ }
13677
+ if (r.pauseReason === 'usage_limit') {
13678
+ return `Paused — session/usage limit reached${r.pauseDetail ? ` (${r.pauseDetail})` : ''}. Resume after the reset.`;
13679
+ }
13680
+ if (r.pauseReason && (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted')) {
13681
+ // A legacy reason is the orchestrator's own text (a pre-policy session/usage-limit line).
13682
+ return `Paused — ${r.pauseReason}. Resume once it clears.`;
13683
+ }
13489
13684
  if (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted') {
13490
13685
  return 'Paused by you. Agents in flight finished their checkpoint; nothing new is dispatched.';
13491
13686
  }
@@ -14274,10 +14469,14 @@ function statusPill(r) {
14274
14469
  // A cost pause names its cause so the pill alone explains why the run parked.
14275
14470
  if (r.pauseReason === 'cost_pipeline') return { family: 'amber', text: 'Paused · cost limit' };
14276
14471
  if (r.pauseReason === 'cost_total') return { family: 'amber', text: 'Paused · total budget' };
14472
+ // An error pause is parked and resumable (never dead), so it stays in the amber family.
14473
+ if (r.pauseReason === 'error') return { family: 'amber', text: 'Paused · error' };
14474
+ if (r.pauseReason === 'recoverable') return { family: 'amber', text: 'Paused · recoverable' };
14475
+ if (r.pauseReason === 'usage_limit') return { family: 'amber', text: 'Paused · usage limit' };
14277
14476
  return { family: 'amber', text: 'Paused' };
14278
14477
  }
14279
14478
  // Same family as `paused`: an interrupted run is parked and resumable, and
14280
- // PAUSED_STATUSES (app.js:8726) already treats it that way.
14479
+ // PAUSED_STATUSES (app.js:10286) already treats it that way.
14281
14480
  if (r.status === 'interrupted') return { family: 'amber', text: 'Interrupted' };
14282
14481
  if (r.pendingQuestion != null) return { family: 'amber', text: 'Paused · awaiting answers' };
14283
14482
  if (r.status === 'starting') return { family: 'peach', text: 'Starting' };
@@ -14857,9 +15056,16 @@ function paintRunCard(r) {
14857
15056
  const totalBlocked = r.pauseReason === 'cost_total' && budgetState.budget?.blocked;
14858
15057
  if (resumeBtn) {
14859
15058
  resumeBtn.disabled = !!totalBlocked;
14860
- resumeBtn.title = totalBlocked
14861
- ? `Total budget reached — blocked until ${fmtResetAtLocal(budgetState.budget.windowEndMs)} or a higher total limit`
14862
- : stockResumeTitle();
15059
+ if (totalBlocked) {
15060
+ resumeBtn.title = `Total budget reached — blocked until ${fmtResetAtLocal(budgetState.budget.windowEndMs)} or a higher total limit`;
15061
+ } else if (r.pauseReason === 'error' || r.pauseReason === 'recoverable') {
15062
+ // The cause the run parked on, so the card alone explains what to fix.
15063
+ resumeBtn.title = r.pauseReason === 'recoverable'
15064
+ ? `Paused on a recoverable error${r.pauseDetail ? `: ${r.pauseDetail}` : ''} — resume once it clears`
15065
+ : `Paused after an error${r.pauseDetail ? `: ${r.pauseDetail}` : ''} — fix the cause, then resume`;
15066
+ } else {
15067
+ resumeBtn.title = stockResumeTitle();
15068
+ }
14863
15069
  }
14864
15070
  }
14865
15071
 
@@ -15273,6 +15479,19 @@ function paintRdBanners(screen, r) {
15273
15479
  banners.prepend(fresh); // above the retained-work banner
15274
15480
  }
15275
15481
 
15482
+ // ---- error-pause banner (D11) ----
15483
+ // Same conditional-rebuild shape as paintHdBanners': keyed on the detail so a
15484
+ // repaint neither stacks a second banner nor freezes a corrected cause.
15485
+ const errPaused = isPaused(r) && (r.pauseReason === 'error' || r.pauseReason === 'recoverable');
15486
+ const errDetail = r.pauseDetail || '';
15487
+ const oldErr = banners.querySelector('.pause-error-banner');
15488
+ if (oldErr && (!errPaused || oldErr.dataset.detail !== errDetail)) oldErr.remove();
15489
+ if (errPaused && !banners.querySelector('.pause-error-banner')) {
15490
+ const errBanner = renderErrorPauseBanner(errDetail);
15491
+ errBanner.dataset.detail = errDetail;
15492
+ banners.prepend(errBanner);
15493
+ }
15494
+
15276
15495
  // ---- retained work (D11) ----
15277
15496
  // renderRetainedWork only READS `p.retainedWork`, so a derived carrier is fine
15278
15497
  // for the paint; every MUTATING helper below gets the same carrier so the
@@ -15319,11 +15538,12 @@ el.runDetail?.addEventListener('click', (e) => {
15319
15538
  if (override) { confirmCostOverride(r.runId, override); return; } // async, fire-and-forget
15320
15539
  if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings'; return; }
15321
15540
  const qbtn = e.target.closest && e.target.closest(
15322
- '.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-abort');
15541
+ '.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-pause, .qpanel .recovery-abort');
15323
15542
  if (!qbtn) return;
15324
15543
  if (qbtn.classList.contains('gate-continue')) postAnswer(r, { decision: 'continue' });
15325
15544
  else if (qbtn.classList.contains('gate-another')) postAnswer(r, { decision: 'another' });
15326
15545
  else if (qbtn.classList.contains('recovery-retry')) postAnswer(r, { decision: 'retry' });
15546
+ else if (qbtn.classList.contains('recovery-pause')) postAnswer(r, { decision: 'pause' });
15327
15547
  else if (qbtn.classList.contains('recovery-abort')) postAnswer(r, { decision: 'abort' });
15328
15548
  else submitAnswer(r, qbtn.closest('.qpanel'));
15329
15549
  });