@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
@@ -0,0 +1,126 @@
1
+ // The browser's copy of this relay's remote-relay list (Settings → Relays and
2
+ // the composer's @mention popup read it). Filled lazily from
3
+ // GET /api/remote-relays and replaced wholesale by the relay's
4
+ // `remote_relays_updated` socket event; never carries tokens.
5
+
6
+ import { IS_SHARED_VIEW } from './store.js';
7
+ import { loadRemoteRelays } from './api-client.js';
8
+
9
+ // A relay without the feature answers 404; do not ask again on every "@".
10
+ const FAILED_LOAD_RETRY_MS = 60_000;
11
+ const KNOWN_STATUSES = new Set(['online', 'offline', 'unauthorized', 'error', 'unknown']);
12
+
13
+ /** A remote's health for its dot: online, offline, unauthorized, error or unknown. */
14
+ export function remoteRelayStatus(relay) {
15
+ const status = String(relay?.lastStatus || '').trim().toLowerCase();
16
+ return KNOWN_STATUSES.has(status) ? status : 'unknown';
17
+ }
18
+
19
+ let snapshot = null; // { relays: [], self: {} | null } once known
20
+ let loadPromise = null;
21
+ let lastFailedLoadAt = 0;
22
+ const listeners = new Set();
23
+
24
+ function normalizeRelay(raw) {
25
+ if (!raw || typeof raw !== 'object') return null;
26
+ const id = String(raw.id || '').trim();
27
+ if (!id) return null;
28
+ return { ...raw, id };
29
+ }
30
+
31
+ function normalizeSnapshot(payload) {
32
+ const relays = Array.isArray(payload?.relays)
33
+ ? payload.relays.map(normalizeRelay).filter(Boolean)
34
+ : [];
35
+ const self = payload?.self && typeof payload.self === 'object' ? { ...payload.self } : null;
36
+ return { relays, self };
37
+ }
38
+
39
+ function notify() {
40
+ for (const listener of listeners) {
41
+ try {
42
+ listener(snapshot);
43
+ } catch (error) {
44
+ console.warn('[remote-relays] listener failed', error?.message || error);
45
+ }
46
+ }
47
+ }
48
+
49
+ /** The last known `{ relays, self }`, or null before the first load. */
50
+ export function getRemoteRelaysSnapshot() {
51
+ return snapshot;
52
+ }
53
+
54
+ export function getRemoteRelays() {
55
+ return snapshot?.relays || [];
56
+ }
57
+
58
+ /**
59
+ * Replaces the list (socket event, GET response). A payload without a
60
+ * `relays` array is ignored rather than wiping what is known; `self` survives
61
+ * a payload that omits it.
62
+ */
63
+ export function setRemoteRelaysSnapshot(payload) {
64
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.relays)) return snapshot;
65
+ const next = normalizeSnapshot(payload);
66
+ if (!next.self && snapshot?.self) next.self = snapshot.self;
67
+ snapshot = next;
68
+ lastFailedLoadAt = 0;
69
+ notify();
70
+ return snapshot;
71
+ }
72
+
73
+ /** Applies one relay a PATCH/check answered with, without a full reload. */
74
+ export function upsertRemoteRelay(relay) {
75
+ const normalized = normalizeRelay(relay);
76
+ if (!normalized || !snapshot) return snapshot;
77
+ const relays = snapshot.relays.slice();
78
+ const index = relays.findIndex((entry) => entry.id === normalized.id);
79
+ if (index === -1) relays.push(normalized);
80
+ else relays[index] = normalized;
81
+ snapshot = { ...snapshot, relays };
82
+ notify();
83
+ return snapshot;
84
+ }
85
+
86
+ export function removeRemoteRelayFromSnapshot(id) {
87
+ const key = String(id || '').trim();
88
+ if (!snapshot || !key) return snapshot;
89
+ snapshot = { ...snapshot, relays: snapshot.relays.filter((entry) => entry.id !== key) };
90
+ notify();
91
+ return snapshot;
92
+ }
93
+
94
+ /** Fetches the list now. Resolves to the snapshot (null when unavailable). */
95
+ export async function refreshRemoteRelays() {
96
+ if (IS_SHARED_VIEW) return snapshot;
97
+ const payload = await loadRemoteRelays();
98
+ if (payload && Array.isArray(payload.relays)) return setRemoteRelaysSnapshot(payload);
99
+ lastFailedLoadAt = Date.now();
100
+ return snapshot;
101
+ }
102
+
103
+ /** Loads once, lazily (first "@" in the composer); later calls reuse the cache. */
104
+ export function ensureRemoteRelaysLoaded() {
105
+ if (snapshot) return Promise.resolve(snapshot);
106
+ if (IS_SHARED_VIEW) return Promise.resolve(null);
107
+ if (loadPromise) return loadPromise;
108
+ if (lastFailedLoadAt && Date.now() - lastFailedLoadAt < FAILED_LOAD_RETRY_MS) return Promise.resolve(null);
109
+ loadPromise = refreshRemoteRelays().finally(() => {
110
+ loadPromise = null;
111
+ });
112
+ return loadPromise;
113
+ }
114
+
115
+ export function subscribeRemoteRelays(listener) {
116
+ if (typeof listener !== 'function') return () => {};
117
+ listeners.add(listener);
118
+ return () => listeners.delete(listener);
119
+ }
120
+
121
+ /** Test hook: forget everything. */
122
+ export function resetRemoteRelaysStoreForTests() {
123
+ snapshot = null;
124
+ loadPromise = null;
125
+ lastFailedLoadAt = 0;
126
+ }
@@ -33,6 +33,7 @@ import {
33
33
  updateFeatureFlagSetting as requestFeatureFlagSetting,
34
34
  requestRelayRestart,
35
35
  } from './api-client.js';
36
+ import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
36
37
  import { syncFontScaleSelect } from './font-scaling.js';
37
38
  import { syncPwaAppNameInput } from './pwa-install.js';
38
39
  import { normalizeKnownCwdPath } from './cwd-picker.js';
@@ -42,6 +43,7 @@ import { refreshClaudeAuthSection } from './claude-auth-ui.js';
42
43
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
43
44
  import { refreshCliInstallSections } from './cli-install-ui.js';
44
45
  import { refreshUpdateSection } from './update-ui.js';
46
+ import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
45
47
  import {
46
48
  getPreviews,
47
49
  renderPreviewRowsInto,
@@ -152,9 +154,10 @@ export async function updateTurnCeilingSetting(value) {
152
154
  }
153
155
  }
154
156
 
155
- // Mirrors shared/background-task-timeout.mjs (the browser cannot import it).
156
- // Bounds come from the API response; these are just pre-response defaults.
157
- let backgroundTaskTimeoutMinutes = 0;
157
+ // Starts at the default the relay enforces, so the slider tells the truth while
158
+ // the settings request is in flight or after it failed. Bounds come from the
159
+ // API response.
160
+ let backgroundTaskTimeoutMinutes = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES;
158
161
  let backgroundTaskTimeoutUpdateInFlight = false;
159
162
 
160
163
  function syncBackgroundTaskTimeoutSlider() {
@@ -353,8 +356,8 @@ export function applyCopilotSettingsState(settings = {}, { error = '' } = {}) {
353
356
  status.dataset.state = 'error';
354
357
  } else {
355
358
  status.textContent = copilotSettingsState.engine === 'sdk'
356
- ? 'New Copilot conversations run on the experimental headless SDK worker. No tmux inspector for these sessions.'
357
- : 'New Copilot conversations run on the Copilot CLI with the web-relay extension (current default).';
359
+ ? 'New Copilot conversations run on the headless SDK worker. No tmux inspector for these sessions.'
360
+ : 'New Copilot conversations run on the Copilot CLI with the web-relay extension.';
358
361
  status.dataset.state = copilotSettingsState.engine === 'sdk' ? 'active' : 'unconfigured';
359
362
  }
360
363
  }
@@ -396,7 +399,7 @@ export async function saveCopilotSettings() {
396
399
  settled = result;
397
400
  showTransientRelayNotice(
398
401
  String(result.engine) === 'sdk'
399
- ? 'Copilot engine set to SDK (experimental). New conversations use the headless worker; running sessions keep the extension until they restart.'
402
+ ? 'Copilot engine set to SDK. New conversations use the headless worker; running sessions keep their engine until they restart.'
400
403
  : 'Copilot engine set to Extension. New conversations use the Copilot CLI.',
401
404
  6000,
402
405
  );
@@ -1630,8 +1633,8 @@ subscribePreviews(() => {
1630
1633
  renderPreviewsSection();
1631
1634
  });
1632
1635
 
1633
- // `tab` / `providerTab` are optional deep links (e.g. openSettingsModal('providers', 'claude'));
1634
- // omitting them restores the last tab the user was on.
1636
+ // `tab` / `providerTab` are optional deep links (e.g. openSettingsModal('providers', 'claude')
1637
+ // or openSettingsModal('relays')); omitting them restores the last tab the user was on.
1635
1638
  export function openSettingsModal(tab, providerTab) {
1636
1639
  closeChatActionsMenu();
1637
1640
  const modal = document.getElementById('settings-modal');
@@ -1674,6 +1677,7 @@ export function openSettingsModal(tab, providerTab) {
1674
1677
  void refreshBackgroundTaskTimeoutSetting();
1675
1678
  void refreshPushSettingsSection();
1676
1679
  void refreshPreviewsSection();
1680
+ void refreshRemoteRelaysSection();
1677
1681
  void refreshFeatureFlagsSection();
1678
1682
  void refreshUpdateSection();
1679
1683
  modal?.classList.add('visible');
@@ -1,7 +1,7 @@
1
1
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
2
2
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
3
3
 
4
- const SETTINGS_TABS = ['general', 'providers', 'previews', 'notifications', 'features'];
4
+ const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
7
  const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'grok', 'cursor'];
@@ -29,6 +29,7 @@ import {
29
29
  clearSubagentCancelInFlight,
30
30
  setConversationWatcherCount,
31
31
  setUpdateState,
32
+ applySessionWorkerSteering,
32
33
  } from './store.js';
33
34
  import { scheduleContextUsageRefresh } from './api-client.js';
34
35
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
@@ -41,10 +42,13 @@ import {
41
42
  import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
42
43
  import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
43
44
  import { setPreviews } from './preview-cards.mjs';
45
+ import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
46
+ import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
44
47
  import { applyClaudeAuthState } from './claude-auth-ui.js';
45
48
  import { applyGrokAuthState } from './grok-auth-ui.js';
46
49
  import { applyCliInstallState } from './cli-install-ui.js';
47
50
  import { renderUpdateSection } from './update-ui.js';
51
+ import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
48
52
  import {
49
53
  showThinking,
50
54
  removeThinking,
@@ -66,6 +70,8 @@ import {
66
70
  removeUserBubbleCancelButton,
67
71
  updateSubagentBubbleFromStatus,
68
72
  markSubagentStopUnsupported,
73
+ syncComposerButtonState,
74
+ syncCancellableSteerButtons,
69
75
  } from './conversation-view.js';
70
76
  import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
71
77
  import { clearMessageSearchRuntimeState } from './message-search-view.js';
@@ -385,6 +391,14 @@ export async function connectSocket(overrideDeps) {
385
391
  setUpdateState(payload || null);
386
392
  renderUpdateSection();
387
393
  });
394
+ // Pending host suspend / relay restart: banner + menu labels on every
395
+ // device. Also sent on connect, so a reconnecting client catches up.
396
+ socket.on('host_suspend_state', (payload) => {
397
+ applyHostSuspendState(payload || null);
398
+ });
399
+ socket.on('relay_shutdown_state', (payload) => {
400
+ applyRelayShutdownState(payload || null);
401
+ });
388
402
  socket.on('grok_settings_updated', (payload) => {
389
403
  deps?.applyGrokSettingsState?.(payload || {});
390
404
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
@@ -419,7 +433,8 @@ export async function connectSocket(overrideDeps) {
419
433
  socket.on('user_message', ({ conversationId, messageId, senderClientId, message }) => {
420
434
  const normalizedMessage = {
421
435
  ...(message && typeof message === 'object' ? message : {}),
422
- text: stripRelayPromptContext(message?.text, message?.mode),
436
+ // `origin`: a prompt another relay's agent sent; its header line hides.
437
+ text: stripRelayPromptContext(message?.text, message?.mode, { origin: message?.origin }),
423
438
  };
424
439
  if (senderClientId && senderClientId === CLIENT_ID) {
425
440
  pendingUserMessageIds.delete(messageId);
@@ -492,6 +507,11 @@ export async function connectSocket(overrideDeps) {
492
507
  socket.on('previews', ({ previews }) => {
493
508
  setPreviews(previews);
494
509
  });
510
+ // REPLACE semantics too: the relay ships the whole remote-relay list (no
511
+ // tokens) whenever a remote is added, removed, renamed or changes status.
512
+ socket.on(REMOTE_RELAY_SOCKET_EVENT, (payload) => {
513
+ setRemoteRelaysSnapshot(payload || null);
514
+ });
495
515
  socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
496
516
  if (!messageId || !text) return;
497
517
  const entry = {
@@ -627,11 +647,27 @@ export async function connectSocket(overrideDeps) {
627
647
  runtimeSessionId: String(runtimeSessionId || conversations[id].runtimeSessionId || '').trim() || null,
628
648
  };
629
649
  }
650
+ // The composer reads the worker's steering snapshot under the session id
651
+ // just bound; fetch it now rather than on the next status poll.
652
+ refreshSessionWorkerStatus().catch(() => {});
630
653
  await refreshConversations();
631
654
  if (currentConvId === id) {
632
655
  await openConversation(id);
633
656
  }
634
657
  });
658
+ // A worker's steering snapshot changed (its heartbeat): the composer's
659
+ // Steer/Queue label and the pushed rows' Cancel follow it at once instead of
660
+ // on the next status poll.
661
+ socket.on('session_worker_steering', ({ sdkSessionId, steering } = {}) => {
662
+ const changed = applySessionWorkerSteering(sdkSessionId, steering);
663
+ if (changed === null) {
664
+ refreshSessionWorkerStatus().catch(() => {});
665
+ return;
666
+ }
667
+ if (!changed) return;
668
+ syncComposerButtonState();
669
+ syncCancellableSteerButtons();
670
+ });
635
671
  socket.on('message_status', ({ messageId, conversationId, status }) => {
636
672
  const normalizedStatus = String(status || '').trim().toLowerCase();
637
673
  const clearsProcessingStatus = ['done', 'failed', 'dropped', 'pending', 'parked', 'cancelled'].includes(normalizedStatus);
@@ -0,0 +1,100 @@
1
+ // Mid-turn steering: a message sent while a Claude turn was running is pushed
2
+ // into the live turn. Three settle shapes reach the transcript:
3
+ // - handoff: the interrupted reply settles with kind='absorbed', and the rest
4
+ // of the turn (with the final reply) lands on the steered message's own row.
5
+ // Those rows render as ONE flow — absorbed reply, steered user bubble,
6
+ // continuing reply.
7
+ // - fold: the CLI answered the steer inside the running turn; its row settles
8
+ // with a kind='folded' stub, a compact marker under its own user message.
9
+ // It never merges forward — the next message is an ordinary turn.
10
+ // - stopped: the steer was pushed into a turn the user then stopped, so it
11
+ // went unanswered (kind='stopped', a marker with Resend).
12
+ // The steered user message of a fold or stop is still classed as steered.
13
+ //
14
+ // One idempotent DOM pass, same contract as syncTranscriptSeparators: every
15
+ // insertion path (full render, live append, prepended history page) re-runs it,
16
+ // so classes heal when the pair arrives across two loads and un-apply when a
17
+ // re-render drops the absorbed marker. Kept DOM-only and import-light so it is
18
+ // jsdom-testable (conversation-view.js touches window at module scope).
19
+ import { SEPARATOR_CLASS } from './transcript-separators.mjs';
20
+
21
+ export const ABSORBED_MSG_CLASS = 'msg-absorbed';
22
+ export const STEERED_MSG_CLASS = 'msg-steered';
23
+ export const STEERED_CONTINUATION_CLASS = 'msg-steered-continuation';
24
+ export const FOLDED_MSG_CLASS = 'msg-folded';
25
+ export const STEER_STOPPED_MSG_CLASS = 'msg-steer-stopped';
26
+
27
+ /** The assistant row kinds that are settle markers of a steer, not replies. */
28
+ export const STEER_MARKER_CLASS_BY_KIND = Object.freeze({
29
+ folded: FOLDED_MSG_CLASS,
30
+ stopped: STEER_STOPPED_MSG_CLASS,
31
+ });
32
+
33
+ function isSteerMarker(node) {
34
+ return !!node?.classList
35
+ && node.classList.contains('assistant')
36
+ && (node.classList.contains(FOLDED_MSG_CLASS) || node.classList.contains(STEER_STOPPED_MSG_CLASS));
37
+ }
38
+
39
+ /**
40
+ * The `.msg` row above, refusing to cross a separator row: a steering pair is
41
+ * seconds apart, so a day or compaction break between two rows proves they are
42
+ * NOT that pair, and pulling them together across it would look broken.
43
+ */
44
+ function previousMessageNode(node) {
45
+ for (let prev = node?.previousElementSibling; prev; prev = prev.previousElementSibling) {
46
+ if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
47
+ if (prev.classList?.contains('msg')) return prev;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** The `.msg` row below, refusing to cross a separator row. */
53
+ function nextMessageNode(node) {
54
+ for (let next = node?.nextElementSibling; next; next = next.nextElementSibling) {
55
+ if (next.classList?.contains(SEPARATOR_CLASS)) return null;
56
+ if (next.classList?.contains('msg')) return next;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function setClass(node, className, on) {
62
+ if (!node?.classList) return 0;
63
+ if (node.classList.contains(className) === on) return 0;
64
+ node.classList.toggle(className, on);
65
+ return 1;
66
+ }
67
+
68
+ /**
69
+ * Sync the merge classes over the container's rows. Returns the number of
70
+ * class changes (0 = the pass was a no-op), which the tests assert on.
71
+ */
72
+ export function syncSteeredTurnMerge(container) {
73
+ if (!container || !container.children) return 0;
74
+ let changes = 0;
75
+ for (const node of container.children) {
76
+ if (!node.classList?.contains('msg')) continue;
77
+ if (node.classList.contains('user')) {
78
+ // A user row is steered iff the row above it is an absorbed reply, or
79
+ // its own settle marker (fold/stop) sits right below it.
80
+ const previous = previousMessageNode(node);
81
+ const steered = (!!previous
82
+ && previous.classList.contains('assistant')
83
+ && previous.classList.contains(ABSORBED_MSG_CLASS))
84
+ || isSteerMarker(nextMessageNode(node));
85
+ changes += setClass(node, STEERED_MSG_CLASS, steered);
86
+ continue;
87
+ }
88
+ if (node.classList.contains('assistant')) {
89
+ // The reply continuing a steered message keeps the group together. A
90
+ // settle marker is not a reply: it keeps its own compact styling.
91
+ const previous = previousMessageNode(node);
92
+ const continues = !isSteerMarker(node)
93
+ && !!previous
94
+ && previous.classList.contains('user')
95
+ && previous.classList.contains(STEERED_MSG_CLASS);
96
+ changes += setClass(node, STEERED_CONTINUATION_CLASS, continues);
97
+ }
98
+ }
99
+ return changes;
100
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
1
2
  import { recordCliLifecycleEvent, recordRelayLifecycleEvent } from './status-store.mjs';
2
3
  import { resolveRelayDotState } from './relay-dot-state.mjs';
3
4
  import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
@@ -309,10 +310,23 @@ function cleanupStalePendingUserMessages(maxAgeMs = 15 * 60 * 1000) {
309
310
  }
310
311
  }
311
312
 
312
- export function trackPendingUserMessage(messageId, conversationId, text) {
313
+ // An attachment-only send has no text to fingerprint; its attachments stand
314
+ // in, so a screenshot send is tracked (and its bubble kept across rebuilds)
315
+ // like any other.
316
+ function pendingAttachmentFingerprint(attachments) {
317
+ const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
318
+ if (!list.length) return '';
319
+ const firstSha = String(list[0]?.sha256 || list[0]?.uploaded?.sha256 || '').trim().toLowerCase();
320
+ return `attachments:${list.length}:${firstSha}`;
321
+ }
322
+
323
+ // Always records the entry — the conversation key is what scopes a kept
324
+ // pending bubble to its own conversation — even when there is nothing to
325
+ // fingerprint for the duplicate check.
326
+ export function trackPendingUserMessage(messageId, conversationId, text, { attachments = [] } = {}) {
313
327
  const id = String(messageId || '').trim();
314
- const fingerprint = normalizePendingMessageText(text);
315
- if (!id || !fingerprint) return false;
328
+ if (!id) return false;
329
+ const fingerprint = normalizePendingMessageText(text) || pendingAttachmentFingerprint(attachments);
316
330
  cleanupStalePendingUserMessages();
317
331
  pendingUserMessageEntries.set(id, {
318
332
  conversationKey: pendingConversationKey(conversationId),
@@ -597,10 +611,7 @@ function resolveMessagesNearBottomThreshold(el, thresholdPx = null) {
597
611
  if (Number.isFinite(Number(thresholdPx)) && Number(thresholdPx) >= 0) {
598
612
  return Math.trunc(Number(thresholdPx));
599
613
  }
600
- const viewportHeight = Number(el?.clientHeight || 0);
601
- if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 0;
602
- const relativeThreshold = Math.floor(viewportHeight * 0.08);
603
- return Math.min(48, Math.max(12, relativeThreshold));
614
+ return resolveNearBottomThresholdPx(el?.clientHeight);
604
615
  }
605
616
 
606
617
  export function isMessagesNearBottom(thresholdPx = null) {
@@ -876,6 +887,28 @@ export function resolveConversationUiState({ conversation = null, workerState =
876
887
  return fallbackUiState || 'offline';
877
888
  }
878
889
 
890
+ // Composer steering snapshot from the worker's heartbeat: whether a typed
891
+ // message would steer into the live turn, and why not when it wouldn't.
892
+ function normalizeWorkerSteering(raw) {
893
+ if (!raw || typeof raw !== 'object') return null;
894
+ return {
895
+ turnActive: raw.turnActive === true,
896
+ canSteer: raw.canSteer === true,
897
+ holdReason: String(raw.holdReason || '').trim() || null,
898
+ // The turn the snapshot describes — lets the composer drop a hold that
899
+ // belongs to a previous turn (the heartbeat lags ~10s).
900
+ messageId: String(raw.messageId || '').trim() || null,
901
+ // The worker's own opt-in to mid-turn steering (both SDK workers send
902
+ // it); the composer's only steering gate — no provider rule backs it up.
903
+ supported: raw.supported === true,
904
+ // Pushed-but-unconsumed rows the worker can still pull back out of its
905
+ // runtime: their bubbles keep Cancel while listed here.
906
+ cancellableIds: Array.isArray(raw.cancellableIds)
907
+ ? raw.cancellableIds.map((id) => String(id || '').trim()).filter(Boolean)
908
+ : [],
909
+ };
910
+ }
911
+
879
912
  function normalizeWorkerStateEntry(worker) {
880
913
  const sdkSessionId = normalizeWorkerSessionId(worker?.sdkSessionId);
881
914
  if (!sdkSessionId) return null;
@@ -889,13 +922,17 @@ function normalizeWorkerStateEntry(worker) {
889
922
  workerId: String(worker?.workerId || '').trim() || null,
890
923
  pid: Number.isInteger(Number(worker?.pid)) ? Number(worker.pid) : null,
891
924
  updatedAt: String(worker?.updatedAt || '').trim() || null,
925
+ steering: normalizeWorkerSteering(worker?.steering),
892
926
  };
893
927
  }
894
928
 
895
929
  function buildSessionWorkerStateHash(map) {
896
930
  const parts = [];
897
931
  for (const [sid, state] of map.entries()) {
898
- parts.push(`${sid}:${state.status}:${state.uiState || ''}:${state.derivedUiState || ''}:${state.workerId || ''}:${state.pid || ''}:${state.updatedAt || ''}`);
932
+ const steering = state.steering
933
+ ? `${state.steering.turnActive ? 1 : 0}${state.steering.canSteer ? 1 : 0}${state.steering.supported ? 1 : 0}${state.steering.holdReason || ''}:${state.steering.messageId || ''}:${state.steering.cancellableIds.join(',')}`
934
+ : '';
935
+ parts.push(`${sid}:${state.status}:${state.uiState || ''}:${state.derivedUiState || ''}:${state.workerId || ''}:${state.pid || ''}:${state.updatedAt || ''}:${steering}`);
899
936
  }
900
937
  return parts.sort().join('|');
901
938
  }
@@ -917,6 +954,26 @@ export function setSessionWorkerStatesFromStatusPayload(payload) {
917
954
  return true;
918
955
  }
919
956
 
957
+ /**
958
+ * Apply one worker's steering snapshot as the relay broadcasts it
959
+ * (`session_worker_steering`), ahead of the next status poll. Only an entry
960
+ * the client already knows is patched — the snapshot alone carries none of
961
+ * the entry's other fields. Returns null for an unknown session (the caller
962
+ * refreshes the whole status instead), otherwise whether anything changed.
963
+ */
964
+ export function applySessionWorkerSteering(sdkSessionId, steering) {
965
+ const sid = normalizeWorkerSessionId(sdkSessionId);
966
+ const existing = sid ? sessionWorkerStates.get(sid) : null;
967
+ if (!existing) return null;
968
+ const next = new Map(sessionWorkerStates);
969
+ next.set(sid, { ...existing, steering: normalizeWorkerSteering(steering) });
970
+ const nextHash = buildSessionWorkerStateHash(next);
971
+ if (nextHash === sessionWorkerStateHash) return false;
972
+ sessionWorkerStateHash = nextHash;
973
+ sessionWorkerStates = next;
974
+ return true;
975
+ }
976
+
920
977
  export function getSessionWorkerState(sdkSessionId) {
921
978
  const sid = normalizeWorkerSessionId(sdkSessionId);
922
979
  if (!sid) return null;
@@ -1194,7 +1251,19 @@ export function setModelBanner(message) {
1194
1251
  el.classList.add('visible');
1195
1252
  }
1196
1253
 
1197
- export function showTransientRelayNotice(message, ms = 4000) {
1254
+ let transientNoticeSeq = 0;
1255
+ // The action notice on screen (or covered by a plain one), so a plain notice
1256
+ // that covers it hands the toast back afterwards instead of dropping the
1257
+ // action for good.
1258
+ let activeActionNotice = null;
1259
+
1260
+ /**
1261
+ * `action` ({ actionLabel, onAction }) adds one button to the toast; the
1262
+ * toast then takes pointer input and hides as soon as the button is used.
1263
+ * A plain notice shown over an action notice hands the toast back to it when
1264
+ * it hides, for whatever time the action notice had left.
1265
+ */
1266
+ export function showTransientRelayNotice(message, ms = 4000, action = null) {
1198
1267
  const text = String(message || '').trim();
1199
1268
  if (!text) return;
1200
1269
  // Transient notices render in the fixed #relay-toast, not #model-banner:
@@ -1209,17 +1278,59 @@ export function showTransientRelayNotice(message, ms = 4000) {
1209
1278
  }, Math.max(1500, Number(ms) || 4000));
1210
1279
  return;
1211
1280
  }
1281
+ const durationMs = Math.max(1500, Number(ms) || 4000);
1282
+ const noticeId = String(++transientNoticeSeq);
1283
+ const actionLabel = String(action?.actionLabel || '').trim();
1284
+ const hasAction = !!actionLabel && typeof action?.onAction === 'function';
1285
+ const covered = !hasAction && activeActionNotice && activeActionNotice.expiresAt > Date.now()
1286
+ ? activeActionNotice
1287
+ : null;
1288
+ if (hasAction) {
1289
+ activeActionNotice = { noticeId, message: text, action, expiresAt: Date.now() + durationMs };
1290
+ }
1291
+ const hide = () => {
1292
+ if (toast.dataset.noticeId !== noticeId) return;
1293
+ toast.textContent = '';
1294
+ toast.classList.remove('visible', 'has-action');
1295
+ if (hasAction) {
1296
+ if (activeActionNotice?.noticeId === noticeId) activeActionNotice = null;
1297
+ return;
1298
+ }
1299
+ if (covered && activeActionNotice === covered) {
1300
+ const remainingMs = covered.expiresAt - Date.now();
1301
+ if (remainingMs > 0) showTransientRelayNotice(covered.message, Math.max(4000, remainingMs), covered.action);
1302
+ }
1303
+ };
1304
+ toast.dataset.noticeId = noticeId;
1212
1305
  toast.textContent = text;
1306
+ if (hasAction) {
1307
+ const button = document.createElement('button');
1308
+ button.type = 'button';
1309
+ button.className = 'relay-toast-action';
1310
+ button.textContent = actionLabel;
1311
+ button.addEventListener('click', () => {
1312
+ hide();
1313
+ action.onAction();
1314
+ });
1315
+ toast.appendChild(button);
1316
+ }
1317
+ toast.classList.toggle('has-action', hasAction);
1213
1318
  toast.classList.add('visible');
1214
- setTimeout(() => {
1215
- if (String(toast.textContent || '').trim() === text) {
1216
- toast.textContent = '';
1217
- toast.classList.remove('visible');
1218
- }
1219
- }, Math.max(1500, Number(ms) || 4000));
1319
+ setTimeout(hide, durationMs);
1320
+ }
1321
+
1322
+ // The screen's short side sizes phone-landscape text (index.html); unlike
1323
+ // the viewport, the on-screen keyboard never changes it. Refreshed here for
1324
+ // foldables, whose screen changes when they unfold.
1325
+ function syncScreenShortSide() {
1326
+ const shortSide = Math.min(Number(window.screen?.width) || 0, Number(window.screen?.height) || 0);
1327
+ if (!shortSide) return;
1328
+ document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
1329
+ document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
1220
1330
  }
1221
1331
 
1222
1332
  export function syncViewportMetrics() {
1333
+ syncScreenShortSide();
1223
1334
  const layoutHeight = window.innerHeight || document.documentElement.clientHeight || 0;
1224
1335
  const layoutWidth = window.innerWidth || document.documentElement.clientWidth || 0;
1225
1336
  const vv = window.visualViewport;
@@ -1,4 +1,5 @@
1
1
  import { BASE, CLIENT_ID, authHeaders, conversations } from './store.js';
2
+ import { draftFlushRequestBody, getSyncedDraft, isStaleDraftFlushEntry } from './conversation-draft-sync.mjs';
2
3
 
3
4
  // Durable outbox for Background Sync: message sends, ask_user answers, and
4
5
  // draft flushes that failed (or might die mid-flight on pagehide) are queued
@@ -129,6 +130,10 @@ export async function replayOutboxFromPage() {
129
130
  db = await openOutboxDb();
130
131
  const entries = await readOutboxEntries(db);
131
132
  for (const { key, value } of entries) {
133
+ if (isStaleDraftFlushEntry(value)) {
134
+ await deleteOutboxEntry(db, key);
135
+ continue;
136
+ }
132
137
  let response = null;
133
138
  try {
134
139
  response = await fetch(`${BASE}${value.path}`, {
@@ -185,23 +190,26 @@ export async function initOutboxFallbackReplay() {
185
190
  * replay treats as final. Only queues when the draft differs from the last
186
191
  * persisted state.
187
192
  */
188
- export async function enqueueDraftFlushForBackgroundSync(conversationId) {
193
+ export async function enqueueDraftFlushForBackgroundSync(conversationId, { afterPendingSend = false } = {}) {
189
194
  const id = String(conversationId || '').trim();
190
195
  if (!id) return false;
191
196
  const conversation = conversations[id];
192
197
  if (!conversation) return false;
193
198
  const input = document.getElementById('msg-input');
194
- const draftText = String(input ? input.value : (conversation.draftText || ''));
195
- if (draftText === String(conversation.draftText || '')) return false;
199
+ const body = draftFlushRequestBody({
200
+ inputText: input ? input.value : (conversation.draftText || ''),
201
+ synced: getSyncedDraft(id),
202
+ fallbackText: conversation.draftText,
203
+ fallbackUpdatedAt: conversation.draftUpdatedAt,
204
+ clientId: CLIENT_ID,
205
+ afterPendingSend,
206
+ });
207
+ if (!body) return false;
196
208
  const queued = await enqueueOutboxRequest({
197
209
  kind: 'draft-flush',
198
210
  path: `/api/conversation/${encodeURIComponent(id)}/draft`,
199
211
  method: 'PATCH',
200
- body: JSON.stringify({
201
- draftText,
202
- clientId: CLIENT_ID,
203
- baseDraftUpdatedAt: conversation.draftUpdatedAt || null,
204
- }),
212
+ body: JSON.stringify(body),
205
213
  });
206
214
  if (queued) void registerOutboxSync();
207
215
  return queued;