@worca/app 1.1.1 → 1.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  });
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Frame classes (spec §6.6 / the P2→P3 contract): job frames carry
8
8
  // {threadId, messageId, seq} and are deduped by the per-job monotonic seq;
9
- // out-of-turn frames (ask-message / ask-title / ask-run-status) upsert by their
10
- // own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
9
+ // out-of-turn frames (ask-message / ask-title / ask-run-status / ask-worktrees)
10
+ // upsert by their own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
11
11
  // re-fetches the thread over REST and resubscribes (spec §10.8).
12
12
 
13
13
  const TERMINAL = new Set(['done', 'stopped', 'error']);
@@ -20,10 +20,11 @@ export function createThreadModel({ threadId }) {
20
20
  let live = null;
21
21
  let inFlight = null;
22
22
  let dirty = newDirty();
23
+ let worktrees = []; // P4 §10: the chat's open worktrees (snapshot + ask-worktrees frames); the panel mirrors it
23
24
 
24
25
  function newDirty() {
25
26
  // runLinks dirt is produced but not yet consumed — no v1 UI renders run links directly; the follower notices carry the visible state.
26
- return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false };
27
+ return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false, worktrees: false };
27
28
  }
28
29
 
29
30
  function rowById(id) {
@@ -31,6 +32,17 @@ export function createThreadModel({ threadId }) {
31
32
  return null;
32
33
  }
33
34
 
35
+ // Agent blocks on the LIVE row, unique by id (upsertBlock replaces in place).
36
+ // Finished turns are already inside thread.totals.agents; ask-done replaces
37
+ // those totals and nulls `live` in ONE frame, so the two never overlap.
38
+ function liveAgentCount() {
39
+ const row = live ? rowById(live.messageId) : null;
40
+ if (!row || !Array.isArray(row.blocks)) return 0;
41
+ let n = 0;
42
+ for (const b of row.blocks) if (b && b.kind === 'agent') n += 1;
43
+ return n;
44
+ }
45
+
34
46
  function upsertRow(message) {
35
47
  const i = rows.findIndex((r) => r && r.id === message.id);
36
48
  if (i >= 0) {
@@ -52,6 +64,19 @@ export function createThreadModel({ threadId }) {
52
64
  dirty.structure = true;
53
65
  }
54
66
 
67
+ // The thread's attachment ledger (attachmentsBytes → the composer's budget
68
+ // pre-check) is seeded by the snapshot; without this it would never learn of
69
+ // an upload made in this session, and the composer would let a whole over-
70
+ // budget base64 POST through to the server's 413. Keyed by the store id, so a
71
+ // row seen through both the broadcast and the local echo counts once.
72
+ function noteAttachmentBlocks(blocks) {
73
+ for (const b of Array.isArray(blocks) ? blocks : []) {
74
+ if (!b || b.kind !== 'attachment' || typeof b.id !== 'string') continue;
75
+ if (attachments.some((a) => a && a.id === b.id)) continue;
76
+ attachments.push({ id: b.id, name: b.name, bytes: Number.isFinite(b.bytes) ? b.bytes : 0, kind: b.attKind ?? 'text', mime: b.mime ?? null });
77
+ }
78
+ }
79
+
55
80
  function markBlockDirty(messageId, blockId) {
56
81
  if (!dirty.blocks.has(messageId)) dirty.blocks.set(messageId, new Set());
57
82
  dirty.blocks.get(messageId).add(blockId);
@@ -118,14 +143,14 @@ export function createThreadModel({ threadId }) {
118
143
  }
119
144
  } else if (frame.type === 'ask-start') {
120
145
  ensureStreamingRow(frame.messageId, frame);
121
- live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null };
146
+ live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null };
122
147
  inFlight = { messageId: frame.messageId };
123
148
  dirty.label = true;
124
149
  } else if (!live && inFlight && frame.messageId === inFlight.messageId) {
125
150
  // Adoption: the ring buffer may have evicted the prefix — accept the first
126
151
  // frame at whatever seq it carries; ask-done.text heals the missing text.
127
152
  ensureStreamingRow(frame.messageId);
128
- live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, adopted: true };
153
+ live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null, adopted: true };
129
154
  } else {
130
155
  return { dropped: 'no-live' };
131
156
  }
@@ -149,11 +174,13 @@ export function createThreadModel({ threadId }) {
149
174
  if (frame.block && frame.block.id != null) {
150
175
  upsertBlock(row, frame.block);
151
176
  markBlockDirty(frame.messageId, frame.block.id);
177
+ if (frame.type === 'ask-block' && frame.block.kind === 'agent') dirty.meters = true; // "N agents" moves live
152
178
  }
153
179
  break;
154
180
  case 'ask-usage':
155
181
  live.usage = frame.usage ?? null;
156
182
  live.costUsd = frame.costUsd ?? null;
183
+ live.estimatedCostUsd = Number.isFinite(frame.estimatedCostUsd) ? frame.estimatedCostUsd : null; // display-only
157
184
  dirty.meters = true;
158
185
  break;
159
186
  case 'ask-done':
@@ -187,6 +214,7 @@ export function createThreadModel({ threadId }) {
187
214
  const m = frame.message;
188
215
  if (!m || typeof m.id !== 'string') return { dropped: 'no-live' };
189
216
  upsertRow(m);
217
+ noteAttachmentBlocks(m.blocks);
190
218
  dirty.messages.add(m.id);
191
219
  return { ok: true };
192
220
  }
@@ -202,6 +230,11 @@ export function createThreadModel({ threadId }) {
202
230
  dirty.runLinks = true;
203
231
  return { ok: true };
204
232
  }
233
+ case 'ask-worktrees':
234
+ if (!Array.isArray(frame.worktrees)) return { dropped: 'no-live' };
235
+ worktrees = frame.worktrees.slice();
236
+ dirty.worktrees = true;
237
+ return { ok: true };
205
238
  default:
206
239
  return { dropped: 'no-live' };
207
240
  }
@@ -217,6 +250,7 @@ export function createThreadModel({ threadId }) {
217
250
  for (const l of Array.isArray(snapshot.runLinks) ? snapshot.runLinks : []) {
218
251
  links.set(l.runId, { pipelineId: l.pipelineId ?? null, cardId: l.cardId ?? null, status: l.status ?? null, phase: l.phase ?? null });
219
252
  }
253
+ worktrees = Array.isArray(snapshot.worktrees) ? snapshot.worktrees.slice() : []; // the fresh-thread load carries no key
220
254
  live = null;
221
255
  inFlight = snapshot.inFlight ?? null;
222
256
  dirty = newDirty();
@@ -225,6 +259,7 @@ export function createThreadModel({ threadId }) {
225
259
  dirty.meters = true;
226
260
  dirty.runLinks = true;
227
261
  dirty.label = true;
262
+ dirty.worktrees = true;
228
263
  },
229
264
  apply(frame) {
230
265
  if (!frame || frame.threadId !== threadId) return { dropped: 'other-thread' };
@@ -239,11 +274,22 @@ export function createThreadModel({ threadId }) {
239
274
  messages() { return rows; },
240
275
  thread() { return thread; },
241
276
  totals() {
242
- return { ...thread.totals, live: live ? { usage: live.usage, costUsd: live.costUsd } : null };
277
+ const base = thread.totals && typeof thread.totals === 'object' ? thread.totals : {};
278
+ const liveAgents = live ? liveAgentCount() : 0;
279
+ return {
280
+ ...base,
281
+ ...(liveAgents ? { agents: (Number.isFinite(base.agents) ? base.agents : 0) + liveAgents } : {}),
282
+ live: live ? { usage: live.usage, costUsd: live.costUsd, estimatedCostUsd: live.estimatedCostUsd } : null,
283
+ };
243
284
  },
244
285
  inFlight() { return inFlight; },
245
286
  live() { return live; },
246
287
  runLinks() { return links; },
288
+ worktrees() { return worktrees; },
289
+ setWorktrees(list) { // the panel's heal (refreshWorktrees) feeds the same store the frames do
290
+ worktrees = Array.isArray(list) ? list.slice() : [];
291
+ dirty.worktrees = true;
292
+ },
247
293
  attachmentsBytes() { return attachments.reduce((n, a) => n + (a && Number.isFinite(a.bytes) ? a.bytes : 0), 0); },
248
294
  findCard(cardId) {
249
295
  for (const r of rows) {
@@ -253,11 +299,18 @@ export function createThreadModel({ threadId }) {
253
299
  return null;
254
300
  },
255
301
  noteLocalUserMessage({ id, text, attachments: atts }) {
302
+ // The POST-side ask-message broadcast can land BEFORE the 202 resolves. That
303
+ // row is the persisted one (seq, store-minted attachment ids); an echo that
304
+ // replaced it would turn an image thumbnail back into a name pill until the
305
+ // next reload. Nothing the echo carries is newer than it, so keep it.
306
+ if (rowById(id)) { dirty.messages.add(id); return; }
307
+ const blocks = (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes, attKind: a.attKind ?? 'text', mime: a.mime ?? null }));
256
308
  upsertRow({
257
309
  id, threadId, seq: undefined, role: 'user', text: String(text ?? ''),
258
- blocks: (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes })),
310
+ blocks,
259
311
  status: null, reason: null, model: null, effort: null, usage: null, costUsd: null, durationMs: null, createdAt: null,
260
312
  });
313
+ noteAttachmentBlocks(blocks);
261
314
  dirty.messages.add(id);
262
315
  },
263
316
  });