@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -10,11 +10,23 @@ import { loadRelayQuestions as loadRelayQuestionsApi, answerRelayQuestion, answe
10
10
  import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
11
11
  import { renderLinkedPlainText } from './router.js';
12
12
  import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
13
+ import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
13
14
  import { isChatInteractionHeld } from './selection-guard.mjs';
15
+ import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
14
16
 
15
17
  let relayQuestionRenderHash = '';
16
18
  let renderedRelayQuestionIds = new Set();
17
19
  const relayQuestionStructuredDrafts = new Map();
20
+ // Ticked choices of a multi-select card (question id → Set of labels), kept
21
+ // across re-renders until the card is answered.
22
+ const relayQuestionMultiDrafts = new Map();
23
+ // The "Select several" switch position per card (question id → boolean) on
24
+ // cards that offer it (Copilot); absent = the provider's flag decides.
25
+ const relayQuestionMultiMode = new Map();
26
+
27
+ function multiModeFor(question) {
28
+ return question ? relayQuestionMultiMode.get(question.id) : undefined;
29
+ }
18
30
  const RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX = 80;
19
31
 
20
32
  function distanceFromBottom(el) {
@@ -75,6 +87,8 @@ export function upsertRelayQuestion(question) {
75
87
  relayQuestions.set(question.id, question);
76
88
  updatePendingQuestionBanner();
77
89
  window.renderConvList?.();
90
+ // A question opening or settling flips the composer's steering hold.
91
+ window.syncComposerButtonState?.();
78
92
  renderRelayQuestions();
79
93
  }
80
94
 
@@ -98,10 +112,19 @@ export async function loadRelayQuestions(conversationId) {
98
112
  relayQuestionStructuredDrafts.delete(questionId);
99
113
  }
100
114
  }
115
+ // Multi-select ticks and the "Select several" switch position die with the
116
+ // card, whoever settled it (this device, another one, a timeout).
117
+ for (const draftMap of [relayQuestionMultiDrafts, relayQuestionMultiMode]) {
118
+ for (const questionId of draftMap.keys()) {
119
+ const question = next.get(questionId);
120
+ if (!question || question.status !== 'pending') draftMap.delete(questionId);
121
+ }
122
+ }
101
123
  relayQuestions.clear();
102
124
  for (const [id, q] of next.entries()) relayQuestions.set(id, q);
103
125
  updatePendingQuestionBanner();
104
126
  window.renderConvList?.();
127
+ window.syncComposerButtonState?.();
105
128
 
106
129
  if (!conversationId && pending.length) {
107
130
  const latest = pending
@@ -212,6 +235,7 @@ export function renderRelayQuestions() {
212
235
  answeredAt: q.answeredAt || '',
213
236
  createdAt: q.createdAt || '',
214
237
  choices: Array.isArray(q.choices) ? q.choices : [],
238
+ multiSelect: isMultiSelectQuestion(q, multiModeFor(q)),
215
239
  schema: q.requestSchema || null,
216
240
  }))
217
241
  );
@@ -249,24 +273,41 @@ export function renderRelayQuestions() {
249
273
  interactiveHtml = renderMultiFieldForm(question);
250
274
  } else {
251
275
  const choices = Array.isArray(question.choices) ? question.choices : [];
252
- const choiceHtml = choices.length
253
- ? (question.status === 'pending'
254
- ? `<div class="relay-question-choices">${
255
- choices.map((choice) => `<button class="relay-question-choice" data-choice="${escHtml(choice)}" data-relay-focus-key="choice-${escHtml(choice)}" onclick="submitRelayQuestionChoice('${question.id}', this.dataset.choice)">${escHtml(choice)}</button>`).join('')
256
- }</div>`
257
- : `<div class="relay-question-choices">${
258
- choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
259
- }</div>`)
276
+ // A multi-select card (the provider said several may be picked) offers
277
+ // checkmarks and ONE Reply; ticking never answers by itself.
278
+ const multiSelect = isMultiSelectQuestion(question, multiModeFor(question));
279
+ const ticked = relayQuestionMultiDrafts.get(question.id) || new Set();
280
+ // Copilot cards: a switch between one-click answers and checkmarks,
281
+ // pre-set from the model's flag or the question's wording.
282
+ const toggleHtml = question.status === 'pending' && offersMultiSelectToggle(question)
283
+ ? `<label class="relay-question-multi-toggle"><input type="checkbox" data-relay-focus-key="multi-toggle"${multiSelect ? ' checked' : ''} onchange="setRelayQuestionMultiMode('${question.id}', this.checked)"> Select several</label>`
260
284
  : '';
285
+ let choiceHtml = '';
286
+ if (choices.length && question.status === 'pending' && multiSelect) {
287
+ choiceHtml = `<div class="relay-question-choices relay-question-multi" id="relay-question-multi-${question.id}">${
288
+ choices.map((choice) => `<label class="relay-question-choice relay-question-choice-check"><input type="checkbox" data-choice-value="${escHtml(choice)}" data-relay-focus-key="multi-${escHtml(choice)}"${ticked.has(choice) ? ' checked' : ''} onchange="onRelayQuestionMultiSelectChange('${question.id}', this.dataset.choiceValue, this.checked)"> ${escHtml(choice)}</label>`).join('')
289
+ }</div>`;
290
+ } else if (choices.length && question.status === 'pending') {
291
+ choiceHtml = `<div class="relay-question-choices">${
292
+ choices.map((choice) => `<button class="relay-question-choice" data-choice="${escHtml(choice)}" data-relay-focus-key="choice-${escHtml(choice)}" onclick="submitRelayQuestionChoice('${question.id}', this.dataset.choice)">${escHtml(choice)}</button>`).join('')
293
+ }</div>`;
294
+ } else if (choices.length) {
295
+ choiceHtml = `<div class="relay-question-choices">${
296
+ choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
297
+ }</div>`;
298
+ }
261
299
  const showReplyControls = question.status === 'pending';
262
300
  const draftText = String(relayQuestionDrafts.get(question.id) || '');
301
+ const replyPlaceholder = multiSelect
302
+ ? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
303
+ : 'Type a reply… (Ctrl/Cmd+Enter to send)';
263
304
  const replyHtml = showReplyControls
264
305
  ? `<div class="relay-question-reply">
265
- <textarea id="relay-question-input-${question.id}" placeholder="Type a reply… (Ctrl/Cmd+Enter to send)" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
266
- <button class="relay-question-submit" onclick="submitRelayQuestionAnswer('${question.id}')">Reply</button>
306
+ <textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
307
+ <button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
267
308
  </div>`
268
309
  : '';
269
- interactiveHtml = `${choiceHtml}${replyHtml}`;
310
+ interactiveHtml = `${choiceHtml}${toggleHtml}${replyHtml}`;
270
311
  }
271
312
 
272
313
  const answeredHtml = question.status === 'answered' && question.answer
@@ -278,7 +319,7 @@ export function renderRelayQuestions() {
278
319
 
279
320
  wrapper.innerHTML = `
280
321
  <div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
281
- <div class="relay-question-head">Copilot question${modeTag} · ${fmtDate(question.createdAt)}</div>
322
+ <div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
282
323
  <div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
283
324
  ${contextHtml}
284
325
  ${answeredHtml}
@@ -467,6 +508,7 @@ export async function submitRelayStructuredAnswer(questionId) {
467
508
  if (r.question) relayQuestions.set(questionId, r.question);
468
509
  updatePendingQuestionBanner();
469
510
  window.renderConvList?.();
511
+ window.syncComposerButtonState?.();
470
512
  renderRelayQuestions();
471
513
  window.showTransientRelayNotice?.(`✅ Answer received · Agent continuing…`, 7000);
472
514
  } catch (e) {
@@ -480,6 +522,44 @@ export async function submitRelayQuestionChoice(questionId, choice) {
480
522
  await submitRelayQuestionAnswer(questionId, choice);
481
523
  }
482
524
 
525
+ /** Ticked labels of a multi-select card, in the card's own order. */
526
+ function tickedMultiSelectLabels(questionId) {
527
+ const container = document.getElementById(`relay-question-multi-${questionId}`);
528
+ if (!container) return [...(relayQuestionMultiDrafts.get(questionId) || [])];
529
+ return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
530
+ .map((el) => String(el.getAttribute('data-choice-value') || ''));
531
+ }
532
+
533
+ /**
534
+ * The "Select several" switch: flip the card between one-click answers and
535
+ * checkmarks. Ticks made so far are kept, and the card re-renders in place.
536
+ */
537
+ export function setRelayQuestionMultiMode(questionId, enabled) {
538
+ const id = String(questionId || '');
539
+ if (!id) return;
540
+ relayQuestionMultiMode.set(id, enabled === true);
541
+ renderRelayQuestions();
542
+ }
543
+
544
+ export function onRelayQuestionMultiSelectChange(questionId, label, checked) {
545
+ const id = String(questionId || '');
546
+ const set = relayQuestionMultiDrafts.get(id) || new Set();
547
+ if (checked) set.add(String(label || ''));
548
+ else set.delete(String(label || ''));
549
+ relayQuestionMultiDrafts.set(id, set);
550
+ }
551
+
552
+ /**
553
+ * Answer a multi-select card: every ticked choice, plus whatever was typed as
554
+ * an extra answer, joined the way the provider's own UI would ("A, C").
555
+ */
556
+ export async function submitRelayQuestionMultiSelect(questionId) {
557
+ const input = document.getElementById(`relay-question-input-${questionId}`);
558
+ const answer = composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '');
559
+ if (!answer) return;
560
+ await submitRelayQuestionAnswer(questionId, answer);
561
+ }
562
+
483
563
  // Offline fallback: park the answer in the durable outbox so Background Sync
484
564
  // (or the next page load) delivers it. A replay against an already-answered
485
565
  // or expired question gets a 4xx and is dropped, so this cannot double-answer.
@@ -499,7 +579,13 @@ async function enqueueRelayAnswerForSync(questionId, body) {
499
579
 
500
580
  export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
501
581
  const input = document.getElementById(`relay-question-input-${questionId}`);
502
- const answer = String(presetAnswer != null ? presetAnswer : (input?.value || '')).trim();
582
+ // Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
583
+ // choices along with the typed text, exactly like its Reply button.
584
+ const pendingQuestion = relayQuestions.get(questionId) || null;
585
+ const composed = presetAnswer == null && isMultiSelectQuestion(pendingQuestion, multiModeFor(pendingQuestion))
586
+ ? composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '')
587
+ : null;
588
+ const answer = String(presetAnswer != null ? presetAnswer : (composed ?? (input?.value || ''))).trim();
503
589
  if (!answer) return;
504
590
 
505
591
  const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
@@ -522,9 +608,12 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
522
608
  }
523
609
  relayQuestionDrafts.delete(questionId);
524
610
  relayQuestionStructuredDrafts.delete(questionId);
611
+ relayQuestionMultiDrafts.delete(questionId);
612
+ relayQuestionMultiMode.delete(questionId);
525
613
  relayQuestions.set(questionId, r.question);
526
614
  updatePendingQuestionBanner();
527
615
  window.renderConvList?.();
616
+ window.syncComposerButtonState?.();
528
617
  renderRelayQuestions();
529
618
  window.showTransientRelayNotice?.(`✅ Answer received: ${answer} · Agent continuing…`, 7000);
530
619
  } catch (e) {
@@ -221,7 +221,7 @@ export function renderAttachmentPreview() {
221
221
  if (!selectedAttachments.length) {
222
222
  el.innerHTML = '';
223
223
  el.classList.remove('visible');
224
- window.syncComposerControlState?.();
224
+ window.syncComposerButtonState?.();
225
225
  syncComposerAttachmentWarning();
226
226
  return;
227
227
  }
@@ -251,7 +251,7 @@ export function renderAttachmentPreview() {
251
251
  `;
252
252
  }).join('');
253
253
  el.classList.add('visible');
254
- window.syncComposerControlState?.();
254
+ window.syncComposerButtonState?.();
255
255
  syncComposerAttachmentWarning();
256
256
  }
257
257
 
@@ -293,6 +293,41 @@ export function clearAttachments() {
293
293
  renderAttachmentPreview();
294
294
  }
295
295
 
296
+ function composerAttachmentSha(attachment) {
297
+ return String(attachment?.sha256 || attachment?.uploaded?.sha256 || '').trim().toLowerCase();
298
+ }
299
+
300
+ /**
301
+ * Removes the composer entries a send took with it and keeps anything added
302
+ * after that snapshot, uploading or not. Matches by identity first, then by
303
+ * content hash — switching away and back mid-send rebuilds the composer from
304
+ * the draft cache as new objects — and removes one entry per sent attachment,
305
+ * so a genuine second copy survives. Persisting what remains is the caller's
306
+ * job.
307
+ */
308
+ export function removeComposerAttachments(sent = []) {
309
+ const pending = (Array.isArray(sent) ? sent : []).filter(Boolean);
310
+ if (!pending.length) return;
311
+ const removed = new Set();
312
+ const unmatched = [];
313
+ for (const att of pending) {
314
+ if (selectedAttachments.includes(att) && !removed.has(att)) removed.add(att);
315
+ else unmatched.push(att);
316
+ }
317
+ for (const att of unmatched) {
318
+ const sha = composerAttachmentSha(att);
319
+ if (!sha) continue;
320
+ const match = selectedAttachments.find((candidate) => !removed.has(candidate) && composerAttachmentSha(candidate) === sha);
321
+ if (match) removed.add(match);
322
+ }
323
+ if (!removed.size) return;
324
+ const kept = selectedAttachments.filter((att) => !removed.has(att));
325
+ for (const att of removed) releaseAttachmentPreviewUrl(att);
326
+ selectedAttachments.length = 0;
327
+ selectedAttachments.push(...kept);
328
+ renderAttachmentPreview();
329
+ }
330
+
296
331
  /**
297
332
  * Replaces composer attachments wholesale, used when hydrating a conversation's
298
333
  * cached draft attachments. Object URLs from the outgoing set are released.
@@ -0,0 +1,9 @@
1
+ // Mirrors shared/background-task-timeout.mjs, which the browser cannot import
2
+ // (only server/public is served). shared/background-task-timeout.test.mjs
3
+ // asserts the two stay identical.
4
+ //
5
+ // The settings slider shows this until the relay's own value loads, and keeps
6
+ // showing it if that request fails, so it must be the default the relay
7
+ // enforces: 4 hours. 0 means no limit only when chosen explicitly.
8
+
9
+ export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;
@@ -5,6 +5,8 @@
5
5
  // `backgroundTasks` on reload; hidden whenever the set is empty.
6
6
 
7
7
  import { apiFetch } from './api-client.js';
8
+ import { humanizeModelLabel } from './model-selector-options.mjs';
9
+ import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
8
10
  import {
9
11
  getPreviewsForConversation,
10
12
  renderPreviewRowsInto,
@@ -69,11 +71,26 @@ function taskKindLabel(task) {
69
71
  return label === 'Subagent' && subagentType ? subagentType : label;
70
72
  }
71
73
 
72
- function formatTaskModel(task) {
73
- const model = String(task?.model || '').trim();
74
- if (!model) return '';
75
- const short = model.replace(/^claude-/, '');
76
- return task?.modelInherited === true ? `${short} (inherited)` : short;
74
+ // "claude-opus-5-5" → "Opus 5.5". A bare alias ("opus") is what a spawn pin
75
+ // carries until the subagent's first answer names the real model.
76
+ export function formatTaskModelLabel(model) {
77
+ const value = String(model || '').trim();
78
+ if (!value) return '';
79
+ if (/^claude-/i.test(value)) return humanizeModelLabel(value);
80
+ if (/^[a-z]+(\[[^\]]+\])?$/i.test(value)) return value.charAt(0).toUpperCase() + value.slice(1);
81
+ return humanizeModelLabel(value);
82
+ }
83
+
84
+ // Detail line: the live tool call when the worker saw one ("🪓 Tool
85
+ // (PowerShell): cd …", cropped to the row by CSS), else the SDK's progress
86
+ // summary, else the bare tool name.
87
+ export function taskActivityText(task) {
88
+ const toolCall = String(task?.lastToolCall || '').trim();
89
+ if (toolCall) return { text: prefixToolActivityEmoji(toolCall), toolCall: true };
90
+ const summary = String(task?.summary || '').trim();
91
+ if (summary) return { text: summary, toolCall: false };
92
+ const lastToolName = String(task?.lastToolName || '').trim();
93
+ return { text: lastToolName ? `using ${lastToolName}` : '', toolCall: false };
77
94
  }
78
95
 
79
96
  // Compact token counts: "812 tok", "62.2k tok", "1.2M tok". Exported for the
@@ -180,7 +197,7 @@ function buildWorkflowAgentRow(agent) {
180
197
  const activityText = state === 'running' && lastToolName ? `— using ${lastToolName}` : '';
181
198
 
182
199
  const meta = [];
183
- const model = String(agent.model ?? '').trim().replace(/^claude-/, '');
200
+ const model = formatTaskModelLabel(agent.model);
184
201
  if (model) meta.push(model);
185
202
  const tokens = formatTaskTokens(agent.tokens);
186
203
  if (tokens) meta.push(tokens);
@@ -364,16 +381,11 @@ function renderTaskRow(conversationId, task) {
364
381
  const key = `${conversationId}:${task.taskId}`;
365
382
  const stopping = stopsInFlight.has(key);
366
383
  const elapsed = formatElapsed(task.startedAt);
367
- // Second line: progress first ("using Bash" prefixed so a bare tool name
368
- // can't read as the task's kind), then model. Token usage renders as its
369
- // own always-visible element on the row's right side instead of riding
370
- // this clampable line, so a long summary can never crowd it out.
371
- const summary = String(task.summary || '').trim();
372
- const lastToolName = String(task.lastToolName || '').trim();
373
- const detail = [
374
- summary || (lastToolName ? `using ${lastToolName}` : ''),
375
- formatTaskModel(task),
376
- ].filter(Boolean).join(' · ');
384
+ // Token usage and the model render on the row's right side instead of
385
+ // riding the detail line, so a long tool call can never crowd them out.
386
+ const activity = taskActivityText(task);
387
+ const modelLabel = formatTaskModelLabel(task.model);
388
+ const modelTitle = task.modelInherited === true ? `${task.model} (inherited from the session)` : task.model;
377
389
  const tokens = formatTaskTokens(task.totalTokens);
378
390
  // Workflow rows with a digest get a fold chevron plus an (initially empty)
379
391
  // tree container right below the row; renderBackgroundTasksPanel fills the
@@ -392,10 +404,13 @@ function renderTaskRow(conversationId, task) {
392
404
  ${foldButton}<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
393
405
  <span class="bg-task-main">
394
406
  <span class="bg-task-desc">${escHtml(task.description || task.taskId)}</span>
395
- ${detail ? `<span class="bg-task-detail">${escHtml(detail)}</span>` : ''}
407
+ ${activity.text ? `<span class="bg-task-detail${activity.toolCall ? ' bg-task-toolcall' : ''}" title="${escHtml(activity.text)}">${escHtml(activity.text)}</span>` : ''}
396
408
  </span>
397
409
  <span class="bg-task-side">
398
- <span class="bg-task-badge">${escHtml(taskKindLabel(task))}</span>
410
+ <span class="bg-task-side-head">
411
+ ${modelLabel ? `<span class="bg-task-model" title="${escHtml(modelTitle)}">${escHtml(modelLabel)}</span>` : ''}
412
+ <span class="bg-task-badge">${escHtml(taskKindLabel(task))}</span>
413
+ </span>
399
414
  <span class="bg-task-side-status">
400
415
  ${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
401
416
  <span class="bg-task-elapsed">${escHtml(elapsed)}</span>
@@ -51,6 +51,7 @@ import {
51
51
  imageEditTarget,
52
52
  hasPendingUserMessageForConversation,
53
53
  } from './store.js';
54
+ import { createTranscriptResizeKeeper } from './transcript-viewport-anchor.mjs';
54
55
  import {
55
56
  verifyExistingSession,
56
57
  verifyToken,
@@ -76,7 +77,7 @@ import {
76
77
  reportSharedViewerPresence,
77
78
  setNetworkRequestsEnabled,
78
79
  } from './api-client.js';
79
- import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading } from './journal-view.js';
80
+ import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading, initConversationFilter } from './journal-view.js';
80
81
  import {
81
82
  newConversation,
82
83
  confirmNewConversationModel,
@@ -87,7 +88,7 @@ import {
87
88
  loadRelayQuestions,
88
89
  getPendingQuestionCountsByConversation,
89
90
  } from './ask-user-view.js';
90
- import { openPendingQuestionFromBanner, submitRelayQuestionChoice, submitRelayQuestionAnswer, submitRelayStructuredAnswer, onRelayQuestionDraftInput, handleRelayQuestionKey } from './ask-user-view.js';
91
+ import { openPendingQuestionFromBanner, submitRelayQuestionChoice, submitRelayQuestionAnswer, submitRelayQuestionMultiSelect, submitRelayStructuredAnswer, onRelayQuestionDraftInput, onRelayQuestionMultiSelectChange, setRelayQuestionMultiMode, handleRelayQuestionKey } from './ask-user-view.js';
91
92
  import { loadRelayBoards, submitRelayBoardAction } from './relay-board-view.js';
92
93
  import {
93
94
  restoreInFlightThinking,
@@ -99,7 +100,10 @@ import {
99
100
  updateComposerSlashMenu,
100
101
  getConversationLoadedMessageCount,
101
102
  loadOlderConversationMessages,
102
- syncComposerControlState,
103
+ syncComposerButtonState,
104
+ syncCancellableSteerButtons,
105
+ syncComposerAfterUserEdit,
106
+ isConversationDraftHeldBySend,
103
107
  persistComposerAttachments,
104
108
  flushConversationDraft,
105
109
  initConversationHistoryLazyLoading,
@@ -115,6 +119,14 @@ import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserS
115
119
  import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
116
120
  import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
117
121
  import { initSlashAutocomplete } from './slash-autocomplete.mjs';
122
+ import { initMentionAutocomplete } from './mention-autocomplete.mjs';
123
+ import { consumeConversationDeepLink } from './conversation-deep-link.mjs';
124
+ import {
125
+ addRemoteRelayFromForm,
126
+ saveRemoteRelayPublicUrl,
127
+ showRemoteRelayTokenField,
128
+ toggleRemoteRelayInbound,
129
+ } from './remote-relays-settings.js';
118
130
  import { dataTransferHasFiles } from './composer-paste.mjs';
119
131
  import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
120
132
  import {
@@ -297,7 +309,9 @@ import {
297
309
  confirmEmptyQueue,
298
310
  openSuspendHostConfirmation,
299
311
  confirmSuspendHost,
312
+ cancelQueuedHostSuspend,
300
313
  } from './action-confirmations.js';
314
+ import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
301
315
  import {
302
316
  startClaudeRelogin,
303
317
  submitClaudeLoginCodeFromInput,
@@ -694,13 +708,10 @@ function initPushNotificationClientHooks() {
694
708
  });
695
709
  }
696
710
 
711
+ // ?push_conv=<id> (a notification tap) and ?conv=<id> (another relay's
712
+ // provenance badge links here) both open that conversation once.
697
713
  function consumePushConversationDeepLink() {
698
- const url = new URL(window.location.href);
699
- const conversationId = String(url.searchParams.get('push_conv') || '').trim();
700
- if (!conversationId) return '';
701
- url.searchParams.delete('push_conv');
702
- history.replaceState(null, document.title, `${url.pathname}${url.search}${url.hash}`);
703
- return conversationId;
714
+ return consumeConversationDeepLink();
704
715
  }
705
716
 
706
717
  function handleForegroundTransition(reason, { immediate = false } = {}) {
@@ -1139,7 +1150,7 @@ function syncModelMetadataBlocker(message = '') {
1139
1150
  reasoningSelect.disabled = blocked;
1140
1151
  reasoningSelect.title = blocked ? 'Reasoning metadata unavailable' : 'Reasoning effort';
1141
1152
  }
1142
- window.syncComposerControlState?.();
1153
+ window.syncComposerButtonState?.();
1143
1154
  }
1144
1155
 
1145
1156
  function currentConversationHasMessages() {
@@ -3238,8 +3249,13 @@ async function refreshSessionWorkerStatus() {
3238
3249
  const status = await refreshWorkspaceRootHints();
3239
3250
  if (!status) return;
3240
3251
  syncQueueStatusMenuEntry(status);
3252
+ applyPendingActionsFromStatus(status);
3241
3253
  if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
3242
3254
  renderConvList();
3255
+ // A worker steering snapshot may have changed; the composer's Steer
3256
+ // button (gate, hold) and the pushed rows' Cancel controls derive from it.
3257
+ syncComposerButtonState();
3258
+ syncCancellableSteerButtons();
3243
3259
  }
3244
3260
  updateCliStatus();
3245
3261
  const nextWorkerState = currentSdkSessionId ? getSessionWorkerState(currentSdkSessionId) : null;
@@ -3311,9 +3327,18 @@ function startLiveConversationPolling() {
3311
3327
  }, 900);
3312
3328
  }
3313
3329
 
3330
+ const transcriptResizeKeeper = createTranscriptResizeKeeper({
3331
+ getElement: () => document.getElementById('messages'),
3332
+ });
3333
+
3314
3334
  function setupViewportTracking() {
3315
3335
  syncViewportMetrics();
3316
- const update = () => syncViewportMetrics();
3336
+ // After the metrics: --app-height decides the transcript's height, and the
3337
+ // keeper's restore reads the post-resize layout.
3338
+ const update = () => {
3339
+ syncViewportMetrics();
3340
+ transcriptResizeKeeper.handleResize();
3341
+ };
3317
3342
  window.addEventListener('resize', update, { passive: true });
3318
3343
  window.addEventListener('orientationchange', update, { passive: true });
3319
3344
  if (window.visualViewport) {
@@ -3352,7 +3377,7 @@ function setupViewportTracking() {
3352
3377
  if (input && input.dataset.viewportBound !== '1') {
3353
3378
  input.dataset.viewportBound = '1';
3354
3379
  input.addEventListener('input', () => {
3355
- syncComposerControlState();
3380
+ syncComposerAfterUserEdit();
3356
3381
  }, { passive: true });
3357
3382
  input.addEventListener('focus', () => {
3358
3383
  document.body.classList.add('keyboard-open');
@@ -3427,6 +3452,7 @@ function initMessageScrollPersistence() {
3427
3452
  if (!el || el.dataset.scrollPersistenceBound === '1') return;
3428
3453
  el.dataset.scrollPersistenceBound = '1';
3429
3454
  el.addEventListener('scroll', () => {
3455
+ transcriptResizeKeeper.recordScroll();
3430
3456
  const convId = String(currentConvId || '').trim();
3431
3457
  if (!convId) return;
3432
3458
  saveConversationScrollTop(convId, el.scrollTop);
@@ -3932,6 +3958,7 @@ async function initApp() {
3932
3958
  syncQueueStatusMenuEntry();
3933
3959
  if (!sharedMode) {
3934
3960
  syncSuspendHostVisibility();
3961
+ initHostSuspendUi();
3935
3962
  }
3936
3963
  setupViewportTracking();
3937
3964
  bindChatSelectionGuard();
@@ -3944,7 +3971,11 @@ async function initApp() {
3944
3971
  // The direct flush below can be killed mid-flight by the browser; the
3945
3972
  // queued copy survives and is replayed by Background Sync. If the direct
3946
3973
  // write wins, the replay hits a draft version conflict and is dropped.
3947
- if (!appSharedMode) void enqueueDraftFlushForBackgroundSync(currentConvId);
3974
+ if (!appSharedMode) {
3975
+ void enqueueDraftFlushForBackgroundSync(currentConvId, {
3976
+ afterPendingSend: isConversationDraftHeldBySend(currentConvId),
3977
+ });
3978
+ }
3948
3979
  void flushConversationDraft(currentConvId);
3949
3980
  closeTmuxInspectorView();
3950
3981
  });
@@ -4163,7 +4194,9 @@ async function initApp() {
4163
4194
  initChatTitleCopy();
4164
4195
  initEmojiPicker();
4165
4196
  initSlashAutocomplete();
4197
+ initMentionAutocomplete();
4166
4198
  initConversationListLazyLoading();
4199
+ initConversationFilter();
4167
4200
  initConversationHistoryLazyLoading();
4168
4201
  initBubbleActionHandlers();
4169
4202
  initMessageScrollPersistence();
@@ -4294,6 +4327,10 @@ window.togglePushOnThisDevice = togglePushOnThisDevice;
4294
4327
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
4295
4328
  window.openSettingsModal = openSettingsModal;
4296
4329
  window.closeSettingsModal = closeSettingsModal;
4330
+ window.addRemoteRelayFromForm = addRemoteRelayFromForm;
4331
+ window.showRemoteRelayTokenField = showRemoteRelayTokenField;
4332
+ window.saveRemoteRelayPublicUrl = saveRemoteRelayPublicUrl;
4333
+ window.toggleRemoteRelayInbound = toggleRemoteRelayInbound;
4297
4334
  window.doAuth = doAuth;
4298
4335
  window.initApp = initApp;
4299
4336
  window.connectSocket = connectSocket;
@@ -4358,14 +4395,17 @@ window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
4358
4395
  window.toggleEmojiPicker = toggleEmojiPicker;
4359
4396
  window.submitRelayQuestionChoice = submitRelayQuestionChoice;
4360
4397
  window.submitRelayQuestionAnswer = submitRelayQuestionAnswer;
4398
+ window.submitRelayQuestionMultiSelect = submitRelayQuestionMultiSelect;
4361
4399
  window.submitRelayStructuredAnswer = submitRelayStructuredAnswer;
4362
4400
  window.onRelayQuestionDraftInput = onRelayQuestionDraftInput;
4401
+ window.onRelayQuestionMultiSelectChange = onRelayQuestionMultiSelectChange;
4402
+ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
4363
4403
  window.handleRelayQuestionKey = handleRelayQuestionKey;
4364
4404
  window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
4365
4405
  window.submitRelayBoardAction = submitRelayBoardAction;
4366
4406
  window.compactCurrentConversation = compactCurrentConversation;
4367
4407
  window.sendMessage = sendMessage;
4368
- window.syncComposerControlState = syncComposerControlState;
4408
+ window.syncComposerButtonState = syncComposerButtonState;
4369
4409
  window.syncComposerPlaceholder = syncComposerPlaceholder;
4370
4410
  window.persistComposerAttachments = persistComposerAttachments;
4371
4411
  window.appendMessage = appendMessage;
@@ -4387,6 +4427,7 @@ window.openSuspendHostConfirmation = openSuspendHostConfirmation;
4387
4427
  window.confirmKillCurrentSession = confirmKillCurrentSession;
4388
4428
  window.confirmRestartWebRelay = confirmRestartWebRelay;
4389
4429
  window.confirmSuspendHost = confirmSuspendHost;
4430
+ window.cancelQueuedHostSuspend = cancelQueuedHostSuspend;
4390
4431
  window.confirmEmptyQueue = confirmEmptyQueue;
4391
4432
  window.openMessageSearchModal = openMessageSearchModal;
4392
4433
  window.closeMessageSearchModal = closeMessageSearchModal;