@oar-sh/oar 0.9.12 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -20,6 +20,7 @@ import {
20
20
  } from './api-client.js';
21
21
  import { isSuspendHostActionVisible } from './settings-modal.js';
22
22
  import { describeRelayRestartError } from './relay-restart-error.mjs';
23
+ import { initRelayRestartUi, openRestartRelayConfirmation } from './relay-restart-ui.js';
23
24
  import {
24
25
  applyHostSuspendState,
25
26
  describeHostSuspendBlocker,
@@ -106,21 +107,10 @@ export async function confirmKillCurrentSession() {
106
107
  }
107
108
  }
108
109
 
109
- export function openRestartRelayConfirmation() {
110
- openSummaryModal({
111
- title: 'Restart web relay',
112
- subtitle: 'Queues restart via /api/relay/shutdown',
113
- kind: 'restart-relay',
114
- bodyHtml: `
115
- <p>Queue a manual relay restart now?</p>
116
- <p>The restart waits until the current turn is idle, so it does not interrupt an in-flight turn immediately.</p>
117
- <div class="summary-modal-actions">
118
- <button class="chat-title-action-btn" type="button" onclick="confirmRestartWebRelay()">🌄 Restart web relay</button>
119
- <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
120
- </div>
121
- `,
122
- });
123
- }
110
+ // ⋯ → Restart web relay first shows what the restart would wait for and
111
+ // leave running (relay-restart-ui.js); its buttons land in
112
+ // confirmRestartWebRelay below or in confirmRestartRelayNow.
113
+ export { openRestartRelayConfirmation };
124
114
 
125
115
  export function openEmptyQueueConfirmation() {
126
116
  menuDeps.lockChatActionsMenuShield(350);
@@ -375,6 +365,10 @@ export async function confirmRestartWebRelay() {
375
365
  showTransientRelayNotice('Relay is already shutting down/restarting.', 7000);
376
366
  return;
377
367
  }
368
+ if (result.status === 'shutting_down') {
369
+ showTransientRelayNotice('Nothing was running: the relay is restarting now.', 7000);
370
+ return;
371
+ }
378
372
  const queue = result.queue || {};
379
373
  showTransientRelayNotice(
380
374
  `Relay restart queued (pending=${Number(queue.pendingCount || 0)}, processing=${Number(queue.processingCount || 0)}).`,
@@ -399,6 +393,9 @@ export function initActionConfirmations({
399
393
  syncQueueStatusMenuEntry: typeof syncQueueStatusMenuEntry === 'function' ? syncQueueStatusMenuEntry : menuDeps.syncQueueStatusMenuEntry,
400
394
  refreshSessionWorkerStatus: typeof refreshSessionWorkerStatus === 'function' ? refreshSessionWorkerStatus : menuDeps.refreshSessionWorkerStatus,
401
395
  };
396
+ // The restart confirmations' and banner's inline handlers live on window
397
+ // either way (bootstrap exposes this module's own).
398
+ initRelayRestartUi();
402
399
  if (!exposeOnWindow) return;
403
400
  window.openKillSessionConfirmation = openKillSessionConfirmation;
404
401
  window.openRestartRelayConfirmation = openRestartRelayConfirmation;
@@ -45,7 +45,7 @@ export function areNetworkRequestsEnabled() {
45
45
  return networkRequestsEnabled;
46
46
  }
47
47
 
48
- export async function apiFetch(url, opts = {}) {
48
+ export async function apiFetch(url, { onErrorResponse, ...opts } = {}) {
49
49
  // A keepalive request is a page's last word; it goes out even when the
50
50
  // page's regular network work is already suspended.
51
51
  if (!networkRequestsEnabled && !opts.keepalive) return null;
@@ -61,6 +61,9 @@ export async function apiFetch(url, opts = {}) {
61
61
  });
62
62
  if (!response.ok) {
63
63
  console.error('API error', response.status, url);
64
+ // A caller that shows why a request failed reads the answer here; the
65
+ // result is still null.
66
+ if (typeof onErrorResponse === 'function') await onErrorResponse(response);
64
67
  return null;
65
68
  }
66
69
  noteFetchSuccess();
@@ -585,6 +588,18 @@ export async function updateTaskStallMinutesSetting(minutes) {
585
588
  });
586
589
  }
587
590
 
591
+ // "After a relay restart, tell interrupted sessions to carry on" (off by default).
592
+ export async function loadRestartResumeSetting() {
593
+ return apiFetch('/api/settings/restart-resume');
594
+ }
595
+
596
+ export async function updateRestartResumeSetting(enabled) {
597
+ return apiFetch('/api/settings/restart-resume', {
598
+ method: 'POST',
599
+ body: JSON.stringify({ enabled: enabled === true }),
600
+ });
601
+ }
602
+
588
603
  export async function loadPwaAppNameSetting() {
589
604
  return apiFetch('/api/settings/pwa-app-name');
590
605
  }
@@ -1425,6 +1440,31 @@ export async function cancelRelayRestart(body = {}) {
1425
1440
  });
1426
1441
  }
1427
1442
 
1443
+ // The queued restart's state, and the picture for a restart asked now: the
1444
+ // sessions it would wait for, the messages it would hold, the background
1445
+ // tasks and whether they survive (routes/relay-restart-routes.mjs).
1446
+ export async function loadRelayRestartPreview(action = 'restart') {
1447
+ return apiFetchOrThrow(`/api/relay/shutdown?action=${encodeURIComponent(action)}`);
1448
+ }
1449
+
1450
+ export async function keepWaitingForRelayRestart(body = {}) {
1451
+ return apiFetchOrThrow('/api/relay/shutdown/keep-waiting', {
1452
+ method: 'POST',
1453
+ body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
1454
+ });
1455
+ }
1456
+
1457
+ export async function loadRelayRestartMaxWaitSetting() {
1458
+ return apiFetch('/api/settings/relay-restart-max-wait');
1459
+ }
1460
+
1461
+ export async function updateRelayRestartMaxWaitSetting(maxWaitMinutes) {
1462
+ return apiFetchOrThrow('/api/settings/relay-restart-max-wait', {
1463
+ method: 'POST',
1464
+ body: JSON.stringify({ maxWaitMinutes: Number(maxWaitMinutes) }),
1465
+ });
1466
+ }
1467
+
1428
1468
  export async function requestQueueEmpty(body = {}) {
1429
1469
  return apiFetchOrThrow('/api/queue/empty', {
1430
1470
  method: 'POST',
@@ -1436,8 +1476,22 @@ export async function requestQueueEmpty(body = {}) {
1436
1476
  });
1437
1477
  }
1438
1478
 
1439
- export async function sendMessage(body) {
1440
- return apiFetch('/api/message', { method: 'POST', body: JSON.stringify(body) });
1479
+ // Null on any failure, like apiFetch. A send the relay refused for a reason
1480
+ // the user can act on (a 409 such as "this conversation's model is no longer
1481
+ // available") hands that reason to onRefused first, so the composer can say
1482
+ // it instead of "could not be sent". A replayed send that already landed
1483
+ // (DUPLICATE_MESSAGE_ID) is not a refusal.
1484
+ export async function sendMessage(body, { onRefused } = {}) {
1485
+ return apiFetch('/api/message', {
1486
+ method: 'POST',
1487
+ body: JSON.stringify(body),
1488
+ onErrorResponse: async (response) => {
1489
+ if (response.status !== 409 || typeof onRefused !== 'function') return;
1490
+ const payload = await response.json().catch(() => null);
1491
+ const reason = readableServerError(payload?.error);
1492
+ if (reason && payload?.code !== 'DUPLICATE_MESSAGE_ID') onRefused(reason, payload);
1493
+ },
1494
+ });
1441
1495
  }
1442
1496
 
1443
1497
  // Raw fetch instead of apiFetch: a rejected bootstrap (bad CWD, unavailable
@@ -1506,13 +1560,15 @@ export async function loadRelayQuestion(questionId) {
1506
1560
  return apiFetch(`/api/relay-question/${encodeURIComponent(id)}`);
1507
1561
  }
1508
1562
 
1509
- export async function answerRelayQuestion(questionId, answer, sdkSessionId = null) {
1563
+ // `onErrorResponse` reads the relay's refusal (why, and a card's choices).
1564
+ export async function answerRelayQuestion(questionId, answer, sdkSessionId = null, { onErrorResponse } = {}) {
1510
1565
  const id = String(questionId || '').trim();
1511
1566
  if (!id) return null;
1512
1567
  const sessionId = String(sdkSessionId || '').trim();
1513
1568
  return apiFetch(`/api/relay-question/${encodeURIComponent(id)}/answer`, {
1514
1569
  method: 'POST',
1515
1570
  body: JSON.stringify({ answer, sdk_session_id: sessionId || undefined }),
1571
+ onErrorResponse,
1516
1572
  });
1517
1573
  }
1518
1574
 
@@ -14,6 +14,7 @@ import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
14
14
  import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
15
15
  import { isChatInteractionHeld } from './selection-guard.mjs';
16
16
  import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
+ import { questionAcceptsFreeText } from './question-answer-rule.mjs';
17
18
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
18
19
 
19
20
  let relayQuestionRenderHash = '';
@@ -265,6 +266,10 @@ export function renderRelayQuestions() {
265
266
  const modeTag = question.mode ? ` <span class="msg-mode">${escHtml(question.mode)}</span>` : '';
266
267
  const contextText = String(question?.context?.rationale || '').trim();
267
268
  const contextHtml = contextText ? `<div class="relay-question-context">${renderLinkedPlainText(contextText)}</div>` : '';
269
+ // The card's own title ("Agent sessions", "Remote relay", a Claude
270
+ // question's header), above the question.
271
+ const headerText = String(question?.context?.header || '').trim();
272
+ const headerHtml = headerText ? `<div class="relay-question-header">${escHtml(headerText)}</div>` : '';
268
273
  const fields = schemaFieldsFromQuestion(question);
269
274
  const structuredQuestion = fields.length > 0;
270
275
 
@@ -296,14 +301,21 @@ export function renderRelayQuestions() {
296
301
  choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
297
302
  }</div>`;
298
303
  }
299
- const showReplyControls = question.status === 'pending';
304
+ // A card that takes no typed text (approval and permission cards) is
305
+ // answered by its choices alone: no reply box, and a multi-select one
306
+ // keeps only its "Reply with selection".
307
+ const freeText = questionAcceptsFreeText(question);
308
+ const showReplyControls = question.status === 'pending' && (freeText || multiSelect);
300
309
  const draftText = String(relayQuestionDrafts.get(question.id) || '');
301
310
  const replyPlaceholder = multiSelect
302
311
  ? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
303
312
  : 'Type a reply… (Ctrl/Cmd+Enter to send)';
313
+ const replyInputHtml = freeText
314
+ ? `<textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>`
315
+ : '';
304
316
  const replyHtml = showReplyControls
305
317
  ? `<div class="relay-question-reply">
306
- <textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
318
+ ${replyInputHtml}
307
319
  <button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
308
320
  </div>`
309
321
  : '';
@@ -320,6 +332,7 @@ export function renderRelayQuestions() {
320
332
  wrapper.innerHTML = `
321
333
  <div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
322
334
  <div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
335
+ ${headerHtml}
323
336
  <div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
324
337
  ${contextHtml}
325
338
  ${answeredHtml}
@@ -580,6 +593,15 @@ async function enqueueRelayAnswerForSync(questionId, body) {
580
593
  return queued;
581
594
  }
582
595
 
596
+ // The relay's reason for refusing an answer (a card that takes only its
597
+ // choices names them), or '' when it gave none.
598
+ function relayAnswerRefusalText(refusal) {
599
+ const reason = String(refusal?.error || '').trim();
600
+ if (!reason) return '';
601
+ const choices = Array.isArray(refusal?.choices) ? refusal.choices.map((choice) => String(choice)).filter(Boolean) : [];
602
+ return choices.length ? `${reason}. Choices: ${choices.join(', ')}.` : reason;
603
+ }
604
+
583
605
  export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
584
606
  const input = document.getElementById(`relay-question-input-${questionId}`);
585
607
  // Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
@@ -600,15 +622,18 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
600
622
  const question = relayQuestions.get(questionId) || null;
601
623
  const sdkSessionId = String(question?.sdkSessionId || '').trim();
602
624
  const singleFieldStructuredAnswer = resolveSingleFieldStructuredAnswer(question, answer);
625
+ let refusal = null;
603
626
  const r = singleFieldStructuredAnswer
604
627
  ? await answerRelayQuestionStructured(questionId, singleFieldStructuredAnswer, sdkSessionId || null)
605
- : await answerRelayQuestion(questionId, answer, sdkSessionId || null);
628
+ : await answerRelayQuestion(questionId, answer, sdkSessionId || null, {
629
+ onErrorResponse: async (response) => { refusal = await response.json().catch(() => null); },
630
+ });
606
631
  if (!r?.question) {
607
632
  const queued = await enqueueRelayAnswerForSync(questionId, singleFieldStructuredAnswer
608
633
  ? { structuredAnswer: singleFieldStructuredAnswer, sdk_session_id: sdkSessionId || undefined }
609
634
  : { answer, sdk_session_id: sdkSessionId || undefined });
610
635
  if (queued) return;
611
- throw new Error('Failed to submit relay question answer');
636
+ throw new Error(relayAnswerRefusalText(refusal) || 'Failed to submit relay question answer');
612
637
  }
613
638
  relayQuestionDrafts.delete(questionId);
614
639
  relayQuestionStructuredDrafts.delete(questionId);
@@ -12,13 +12,16 @@ import { apiFetch } from './api-client.js';
12
12
  import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
13
13
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
14
14
  import {
15
+ TASK_OUTPUT_NONE_MESSAGES,
15
16
  endedOutcome,
16
17
  normalizeStallThreshold,
18
+ pipeHoldNotice,
17
19
  runnerChip,
18
20
  stallHint,
19
21
  stepsLabel,
20
22
  taskHasConsole,
21
23
  taskOutputUnavailable,
24
+ taskPipeHold,
22
25
  } from './task-console-render.mjs';
23
26
 
24
27
  // Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
@@ -52,6 +55,12 @@ const stopsInFlight = new Set();
52
55
  const workflowFoldState = new Map();
53
56
  let currentConversationId = null;
54
57
  let elapsedTimer = null;
58
+ // Counts the `background_tasks` events per conversation. A reload's payload
59
+ // is the set as the relay had it when the request was answered; one that was
60
+ // asked for before the latest event can land after it and would put the
61
+ // older set back (the panel shrinking under the reader, then growing again
62
+ // with the next poll).
63
+ const taskEventCounts = new Map();
55
64
 
56
65
  const TASK_TYPE_ICONS = {
57
66
  local_bash: '⌨️',
@@ -363,14 +372,35 @@ function notifyChange(conversationId) {
363
372
  }
364
373
  }
365
374
 
375
+ /**
376
+ * Taken before a reload of the conversation is asked for, and handed to
377
+ * isBackgroundTasksSnapshotCurrent() with its answer.
378
+ */
379
+ export function backgroundTasksSnapshotMark(conversationId) {
380
+ return taskEventCounts.get(String(conversationId || '').trim()) || 0;
381
+ }
382
+
383
+ /**
384
+ * Whether a reload's sets (`backgroundTasks`, `endedBackgroundTasks`) may
385
+ * replace the conversation's on the page: no `background_tasks` event for it
386
+ * arrived since `mark` was taken. Otherwise the event's set is at least as
387
+ * new, and the next reload brings whatever came after it. Without a mark:
388
+ * always.
389
+ */
390
+ export function isBackgroundTasksSnapshotCurrent(conversationId, mark) {
391
+ return mark === undefined || mark === null || mark === backgroundTasksSnapshotMark(conversationId);
392
+ }
393
+
366
394
  /**
367
395
  * The live set (REPLACE semantics). `endedTasks`, when the event or payload
368
396
  * carries it, replaces the ended set too; an older relay's event without it
369
- * leaves the ended rows alone.
397
+ * leaves the ended rows alone. `event`: it came as a `background_tasks`
398
+ * event (see isBackgroundTasksSnapshotCurrent).
370
399
  */
371
- export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks } = {}) {
400
+ export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event = false } = {}) {
372
401
  const id = String(conversationId || '').trim();
373
402
  if (!id) return;
403
+ if (event) taskEventCounts.set(id, backgroundTasksSnapshotMark(id) + 1);
374
404
  const normalized = normalizeTaskList(tasks);
375
405
  // Drop fold choices for tasks that left this conversation's set so the map
376
406
  // cannot grow across long sessions.
@@ -460,6 +490,20 @@ export function findBackgroundTask(conversationId, taskId) {
460
490
  return ended ? { task: ended, ended: true } : null;
461
491
  }
462
492
 
493
+ const panelRenderListeners = new Set();
494
+
495
+ /** Called after the panel drew its rows (the task console keeps above its row). */
496
+ export function onBackgroundTasksPanelRendered(listener) {
497
+ panelRenderListeners.add(listener);
498
+ return () => panelRenderListeners.delete(listener);
499
+ }
500
+
501
+ function notifyPanelRendered() {
502
+ for (const listener of panelRenderListeners) {
503
+ try { listener(); } catch (error) { console.warn('background task panel listener failed', error?.message || error); }
504
+ }
505
+ }
506
+
463
507
  /** Called with the conversation id after any change of tasks or health. */
464
508
  export function subscribeBackgroundTasks(listener) {
465
509
  changeListeners.add(listener);
@@ -538,7 +582,7 @@ function taskTitleHtml(task) {
538
582
  return `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(runId)}" title="Open this sub-agent's thoughts, tool calls and reply">${text}</button>`;
539
583
  }
540
584
  if (taskOutputUnavailable(task)) {
541
- return `<span class="bg-task-desc bg-task-desc-no-output" title="output not available for this provider">${text}</span>`;
585
+ return `<span class="bg-task-desc bg-task-desc-no-output" title="${escHtml(TASK_OUTPUT_NONE_MESSAGES.provider)}">${text}</span>`;
542
586
  }
543
587
  if (taskHasConsole(task)) {
544
588
  return `<button type="button" class="task-console-open bg-task-desc" data-task-console="${escHtml(task.taskId)}" title="Open this task's output">${text}</button>`;
@@ -553,10 +597,13 @@ function chipHtml(health) {
553
597
  }
554
598
 
555
599
  // Always present on a live row (hidden while output is fresh) so the 1 s
556
- // timer can update it in place.
557
- function stallHtml(hint) {
600
+ // timer can update it in place. A command piped into tail is quiet
601
+ // until it ends: its hover title says so.
602
+ function stallHtml(hint, task) {
558
603
  const classes = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
559
- return `<span class="${classes}"${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
604
+ const notice = pipeHoldNotice(taskPipeHold(task));
605
+ const title = notice ? ` title="${escHtml(notice.title)}"` : '';
606
+ return `<span class="${classes}"${title}${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
560
607
  }
561
608
 
562
609
  function taskSteps(task) {
@@ -613,7 +660,7 @@ export function renderTaskRow(conversationId, task) {
613
660
  ${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
614
661
  ${steps ? `<span class="bg-task-steps">${escHtml(steps)}</span>` : ''}
615
662
  ${chipHtml(health)}
616
- ${taskHasConsole(task) ? stallHtml(stall) : ''}
663
+ ${taskHasConsole(task) ? stallHtml(stall, task) : ''}
617
664
  <span class="bg-task-elapsed">${escHtml(elapsed)}</span>
618
665
  ${task.stoppable === false ? '' : `<button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>`}
619
666
  </span>
@@ -684,6 +731,7 @@ export function renderBackgroundTasksPanel() {
684
731
  const staleList = panel.querySelector('#background-tasks-list');
685
732
  if (staleList) staleList.innerHTML = '';
686
733
  stopElapsedTimer();
734
+ notifyPanelRendered();
687
735
  return;
688
736
  }
689
737
  const wasOpen = panel.hasAttribute('open');
@@ -733,6 +781,7 @@ export function renderBackgroundTasksPanel() {
733
781
  if (wasOpen) panel.setAttribute('open', '');
734
782
  if (tasks.length) startElapsedTimer();
735
783
  else stopElapsedTimer();
784
+ notifyPanelRendered();
736
785
  }
737
786
 
738
787
  // A preview created, closed, or flipping online/offline re-renders the panel;
@@ -149,6 +149,7 @@ import {
149
149
  canMergeIntoTranscript,
150
150
  } from './conversation-view.js';
151
151
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
152
+ import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
152
153
  import { newerPageReachesEnd } from './transcript-island.mjs';
153
154
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
154
155
  import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
@@ -339,6 +340,7 @@ import {
339
340
  updateBackgroundTaskTimeoutSetting,
340
341
  previewTaskStallMinutesSetting,
341
342
  updateTaskStallMinutesSetting,
343
+ updateRestartResumeSettingFromToggle,
342
344
  updateSubagentRecordRetentionSetting,
343
345
  } from './settings-modal.js';
344
346
  import {
@@ -3758,6 +3760,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3758
3760
  const savedScrollTop = messagesEl?.scrollTop || 0;
3759
3761
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3760
3762
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3763
+ const tasksMark = backgroundTasksSnapshotMark(currentId);
3761
3764
  const jumpEpoch = getTranscriptJumpEpoch();
3762
3765
  const { response, stale } = await loadKeepingLiveRows(
3763
3766
  () => loadConversation(currentId, { limit: requestLimit }),
@@ -3783,6 +3786,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3783
3786
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3784
3787
  followLiveUpdates: preserveBottom,
3785
3788
  mergeFrom: merge?.mergeFrom || null,
3789
+ backgroundTasksAsOf: tasksMark,
3786
3790
  });
3787
3791
  // A merge does not scroll as the full render does: a reader at the end
3788
3792
  // is taken to the new end here.
@@ -4061,6 +4065,7 @@ async function refreshCurrentView() {
4061
4065
 
4062
4066
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
4063
4067
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
4068
+ const tasksMark = backgroundTasksSnapshotMark(currentId);
4064
4069
  const { response: r, stale } = await loadKeepingLiveRows(
4065
4070
  () => loadConversation(currentId, { limit: requestLimit }),
4066
4071
  getTranscriptLiveRowEpoch,
@@ -4085,6 +4090,7 @@ async function refreshCurrentView() {
4085
4090
  followLiveUpdates: preserveBottom && !keepTranscript,
4086
4091
  keepTranscript,
4087
4092
  mergeFrom: merge?.mergeFrom || null,
4093
+ backgroundTasksAsOf: tasksMark,
4088
4094
  });
4089
4095
  // As in the live poll: a merge leaves the scrolling to the caller.
4090
4096
  if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
@@ -5102,6 +5108,7 @@ window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting
5102
5108
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
5103
5109
  window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
5104
5110
  window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
5111
+ window.updateRestartResumeSettingFromToggle = updateRestartResumeSettingFromToggle;
5105
5112
  window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
5106
5113
  window.togglePushOnThisDevice = togglePushOnThisDevice;
5107
5114
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
@@ -89,11 +89,14 @@ function normalizeEffortList(efforts = []) {
89
89
  * 3. else the lowest supported rung ABOVE it, ignoring 'none'
90
90
  * (a remembered 'minimal' on a model whose floor is 'low' → low);
91
91
  * 4. else the next candidate.
92
- * With no candidate usable: the lowest non-'none' rung the model offers, or
93
- * 'none' when that is all there is. Effort names off the ladder (image
94
- * quality values, unknown providers) only ever match exactly.
92
+ * With no candidate usable: the lowest non-'none' rung the model offers (by
93
+ * rung, whatever the list order), or 'none' when that is all there is; a
94
+ * list with names off the ladder keeps its own order there. A caller with a
95
+ * default of its own passes `fallback`, returned instead in that case.
96
+ * Effort names off the ladder (image quality values, unknown providers) only
97
+ * ever match exactly.
95
98
  */
96
- export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = []) {
99
+ export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
97
100
  const options = normalizeEffortList(supportedEfforts);
98
101
  if (!options.length) return '';
99
102
  const rungOf = (effort) => REASONING_EFFORT_LADDER.indexOf(effort);
@@ -109,6 +112,9 @@ export function nearestSupportedReasoningEffort(candidates = [], supportedEffort
109
112
  const above = ranked.find((option) => rungOf(option) > rung && option !== 'ultracode');
110
113
  if (above) return above;
111
114
  }
115
+ if (fallback !== undefined) return fallback;
116
+ const lowest = ranked.find((option) => option !== 'ultracode');
117
+ if (lowest && options.every((option) => rungOf(option) >= 0)) return lowest;
112
118
  return options.find((option) => option !== 'none') || options[0];
113
119
  }
114
120
 
@@ -33,15 +33,25 @@ export function isConversationUsingClaudeCloudProvider(conversation = null) {
33
33
  // The marker that tells a cloud chat from a local Claude one at a glance.
34
34
  const CLAUDE_CLOUD_MARKER = '☁';
35
35
 
36
+ const PROVIDER_NAMES = Object.freeze({
37
+ openai: 'OpenAI',
38
+ claude: 'Claude',
39
+ 'claude-cloud': 'Claude Cloud',
40
+ cursor: 'Cursor',
41
+ grok: 'Grok',
42
+ github: 'Copilot',
43
+ 'github-copilot': 'Copilot',
44
+ });
45
+
46
+ // A provider type's name as the list pill and the question card say it; ''
47
+ // when unknown. The relay's question push uses it too
48
+ // (services/push-dispatch-service.mjs).
49
+ export function providerDisplayName(providerType = '') {
50
+ return PROVIDER_NAMES[String(providerType || '').trim().toLowerCase()] || '';
51
+ }
52
+
36
53
  function conversationProviderName(conversation = null) {
37
- const providerType = resolveConversationProviderType(conversation);
38
- if (providerType === 'openai') return 'OpenAI';
39
- if (providerType === 'claude') return 'Claude';
40
- if (providerType === 'claude-cloud') return 'Claude Cloud';
41
- if (providerType === 'cursor') return 'Cursor';
42
- if (providerType === 'grok') return 'Grok';
43
- if (providerType === 'github' || providerType === 'github-copilot') return 'Copilot';
44
- return '';
54
+ return providerDisplayName(resolveConversationProviderType(conversation));
45
55
  }
46
56
 
47
57
  // An unresolved provider stays unlabelled rather than defaulting to Copilot, so
@@ -67,6 +67,7 @@ import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomp
67
67
  import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
68
68
  import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
69
69
  import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
70
+ import { isRestartResumeNote, renderRestartResumeNoteHtml } from './relay-restart-note-view.mjs';
70
71
  import {
71
72
  AGENT_EXCHANGE_CLOSED_CLASS,
72
73
  AGENT_EXCHANGE_HEAD_CLASS,
@@ -398,14 +399,18 @@ const conversationHistoryLoader = createInfiniteLoader({
398
399
  syncSteeredTurnMerge(el);
399
400
  // The page's agent exchanges fold before the height delta is measured.
400
401
  syncAgentExchangeFolds(el);
402
+ // Here, not a frame later: the position taken above is the reader's only
403
+ // until they scroll again, and a scroll in that frame (a jump to the end
404
+ // and up again) was undone by it.
405
+ el.scrollTop = previousScrollTop + (el.scrollHeight - previousScrollHeight);
406
+ // The page's images have no height yet: the place read is held while
407
+ // they come in, as after a restore.
408
+ const place = captureConversationReadingAnchor();
409
+ if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
410
+ // Once the loader has taken this page's cursor: still near the top, the
411
+ // next page loads.
401
412
  requestAnimationFrame(() => {
402
- if (!el || String(currentConvId || '').trim() !== currentId) return;
403
- const nextScrollHeight = el.scrollHeight;
404
- el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
405
- // The page's images have no height yet: the place read is held while
406
- // they come in, as after a restore.
407
- const place = captureConversationReadingAnchor();
408
- if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
413
+ if (String(currentConvId || '').trim() !== currentId) return;
409
414
  void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
410
415
  });
411
416
  },
@@ -1336,6 +1341,21 @@ function createRelayEventNode(msg, msgId) {
1336
1341
  return div;
1337
1342
  }
1338
1343
 
1344
+ // The relay's note to carry on after a restart cut a turn off: a folded row
1345
+ // like an event row (relay-restart-note-view.mjs).
1346
+ function createRestartResumeNoteNode(msg, msgId) {
1347
+ const div = document.createElement('div');
1348
+ div.className = 'msg relay-event relay-restart-resume';
1349
+ if (msgId) div.dataset.messageId = msgId;
1350
+ const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
1351
+ div.dataset.messageRole = fingerprint.role || '';
1352
+ div.dataset.messageTextFingerprint = fingerprint.text || '';
1353
+ div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
1354
+ div.innerHTML = renderRestartResumeNoteHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
1355
+ setAgentExchangeRow(div, msg, msgId);
1356
+ return div;
1357
+ }
1358
+
1339
1359
  // Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
1340
1360
  // into one row; this session's reply to it always shows. What the fold pass
1341
1361
  // needs of a message is kept on its node when the row is built, so the pass
@@ -1469,6 +1489,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1469
1489
  // An event message the relay wrote into a watcher's conversation: a
1470
1490
  // compact, foldable row, never a bubble of the user's.
1471
1491
  if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
1492
+ if (isRestartResumeNote(msg)) return createRestartResumeNoteNode(msg, msgId);
1472
1493
 
1473
1494
  // A prompt another relay's agent sent carries `origin`: its header line is
1474
1495
  // hidden (the badge below says the same), and a stray remote-relay mention
@@ -4602,7 +4623,10 @@ export async function sendMessage() {
4602
4623
  : undefined,
4603
4624
  };
4604
4625
 
4605
- const r = await sendMessageApi(body);
4626
+ // A refusal the user can act on (the conversation's model left the
4627
+ // catalog) is shown as the relay words it.
4628
+ let refusal = '';
4629
+ const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
4606
4630
  if (!r) {
4607
4631
  // Offline: park the send in the durable outbox instead of bouncing it
4608
4632
  // back into the composer. The client-generated messageId makes a replay
@@ -4636,7 +4660,7 @@ export async function sendMessage() {
4636
4660
  sendLanded,
4637
4661
  });
4638
4662
  if (!mobileSend && viewingSendConversation()) input.focus();
4639
- setModelBanner('⚠️ Message could not be sent. Please try again.');
4663
+ setModelBanner(refusal ? `⚠️ ${refusal}` : '⚠️ Message could not be sent. Please try again.');
4640
4664
  return;
4641
4665
  }
4642
4666